Skip to content
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ The Knowledge feature (built across Plans 1-6 in `docs/history/knowledge-base-bu
- **HTTP routes:** `crates/biorouter-server/src/routes/knowledge.rs` covers `/knowledge/bases`, `/ingest` (SSE), `/graph`, `/history`, `/preview`, `/restore`, `/page`, `/active`, `/export`, `/import`.
- **Frontend:** `ui/desktop/src/components/knowledge/` (view shell, KB selector, ingest panel, force-graph + change-log drawer). The chat-side KB chip lives at `ui/desktop/src/components/bottom_menu/BottomMenuKnowledgeSelection.tsx`.
- **Storage layout:** `~/.config/biorouter/knowledge/<kb-id>/` with `raw/`, `knowledge/`, `index.md`, `log.md`, `schema.md`, and a hidden `.git/`.
- **One axis, one pointer.** A session's knowledge bases are the *visible* set — everything not in `.hidden-kbs` (machine-wide) or `.hidden-kb-sessions/<sha256(session_id)>` (per session; an empty `[]` means "hide nothing", not "inherit"). KB-less search spans this set with per-hit `kb_id` attribution. Its **primary** is the write target, default single-base read target and Knowledge view's subject. Soul is the product default when the user has expressed no preference. A missing `.active-kb` / `.active-kb-sessions/<digest>` inherits; a bare id pins a choice; a blank file explicitly chooses no primary and must not fall back to Soul. KB-less writes with that explicit no-primary state fail with the candidate list. The primary must remain visible; the daemon repairs selection when its base is hidden or deleted. `kb_set_active` changes the primary without narrowing search. Set-only edits send neither `primary_kb` nor `clear_primary`; see [`docs/knowledge-base/multi-kb-implementation-plan.md`](docs/knowledge-base/multi-kb-implementation-plan.md).
- **One axis, one pointer.** A session's knowledge bases are the *visible* set — everything not in `.hidden-kbs` (machine-wide) or `.hidden-kb-sessions/<sha256(session_id)>` (per session; an empty `[]` means "hide nothing", not "inherit"). KB-less search spans this set with per-hit `kb_id` attribution. Its **primary** is the write target, default single-base read target and Knowledge view's subject. Soul is the product default when the user has expressed no preference. A missing `.active-kb` / `.active-kb-sessions/<digest>` inherits; a bare id pins a choice; a blank file explicitly chooses no primary and must not fall back to Soul. KB-less writes with that explicit no-primary state fail with the candidate list. The primary must remain visible; the daemon repairs selection when its base is hidden or deleted — and the two repairs differ (D2): hiding *promotes* to the first remaining base, deleting *clears* every pointer that named the base to that explicit blank, and a chat that only inherited the pointer is left inheriting. A blank `.active-kb` after a delete is the repair, not its absence. ⚠ The renderer adopts these repairs by re-reading; it never writes one (no `clear_primary` after a delete, no prune against its own base list), and every selection read from the desktop carries `userActionHeaders()`, because the gate refuses a private chat's selection without it. `kb_set_active` changes the primary without narrowing search. Set-only edits send neither `primary_kb` nor `clear_primary`; see [`docs/knowledge-base/multi-kb-implementation-plan.md`](docs/knowledge-base/multi-kb-implementation-plan.md).
- **Sub-agent loop:** `crates/biorouter-mcp/src/knowledge/subagent/loop_.rs` drives ingest / query / lint macros. Mutating tools accept an optional `txn` so a macro's tool calls commit as one logical change.

When working on the Knowledge feature:
Expand Down
74 changes: 73 additions & 1 deletion crates/biorouter-mcp/src/knowledge/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,16 @@ impl GitRepo {
pub fn log(&self, limit: usize) -> Result<Vec<HistoryEntry>> {
let mut walk = self.inner.revwalk()?;
walk.push_head()?;
walk.set_sorting(git2::Sort::TIME)?;
// ⚠ TOPOLOGICAL, not `TIME` alone. Commit times have one-second
// resolution and a digest makes several commits inside one second —
// `add_raw_source`, the squash commit, a lint autofix — and libgit2's
// time sort leaves equal timestamps in no useful order: measured, it
// listed HEAD and then the rest of the tie OLDEST first, so the Change
// log put a base's `create` above the ingests made after it (QA
// 2026-09-10 F13 asked for the log to match `git log`). Topological
// order never lists a parent before its child; `TIME` only breaks ties
// between branches, which a squash-committed history never has.
walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
let mut out = Vec::new();
for oid in walk.flatten().take(limit) {
let commit = self.inner.find_commit(oid)?;
Expand Down Expand Up @@ -775,6 +784,69 @@ mod tests {
assert_eq!(log[1].summary, "one");
}

/// QA 2026-09-10 F13 asked the history route to match `git log`, and a
/// digest is exactly where it did not: `add_raw_source`, the squash commit
/// and a lint autofix land within one second, and `Sort::TIME` alone leaves
/// commits with EQUAL timestamps in no particular order — measured, it
/// listed a base's `create` commit above the two ingests made after it.
/// The timestamps here are pinned equal, so the tie is certain rather than
/// a matter of how fast this machine commits.
#[test]
fn log_keeps_commit_order_when_commits_share_a_timestamp() {
let dir = tempfile::tempdir().unwrap();
let repo = GitRepo::init(dir.path()).unwrap();
let sig = git2::Signature::new(
"Biorouter Knowledge",
"knowledge@biorouter.local",
&git2::Time::new(1_789_000_000, 0),
)
.unwrap();
let mut expected = Vec::new();
for (step, kind) in [
ChangeKind::Manual,
ChangeKind::Ingest,
ChangeKind::Ingest,
ChangeKind::Lint,
ChangeKind::Restore,
]
.into_iter()
.enumerate()
{
std::fs::write(dir.path().join(format!("{step}.md")), step.to_string()).unwrap();
let mut index = repo.inner.index().unwrap();
stage_all(&mut index).unwrap();
index.write().unwrap();
let tree = repo.inner.find_tree(index.write_tree().unwrap()).unwrap();
let parent = repo.inner.head().ok().and_then(|h| h.peel_to_commit().ok());
let parents: Vec<&git2::Commit> = parent.iter().collect();
let summary = format!("step {step}");
let oid = repo
.inner
.commit(
Some("HEAD"),
&sig,
&sig,
&render_message(kind, &summary, None),
&tree,
&parents,
)
.unwrap();
expected.push((oid.to_string(), summary));
}
expected.reverse();

let listed: Vec<(String, String)> = repo
.log(10)
.unwrap()
.into_iter()
.map(|entry| (entry.commit_sha, entry.summary))
.collect();
assert_eq!(
listed, expected,
"newest first, and never a parent before its child"
);
}

#[test]
fn txn_lifecycle_squash_merges_into_main() {
let dir = tempfile::tempdir().unwrap();
Expand Down
143 changes: 143 additions & 0 deletions crates/biorouter-server/src/routes/session_reach.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2207,4 +2207,147 @@ mod bypass_tests {
"this 400 did not come from `set_selection`: only it echoes the kb id: {body}"
);
}

/// `DELETE /knowledge/bases/{id}` through the real router tree, with the
/// proof — as the Knowledge view sends it.
async fn delete_knowledge_base(state: Arc<AppState>, kb_id: &str) -> (StatusCode, String) {
let app = crate::routes::configure(state, "task-58-secret".to_string());
let res = app
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/knowledge/bases/{kb_id}"))
.header("X-User-Action", TEST_USER_ACTION_KEY)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = res.status();
let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
(status, String::from_utf8_lossy(&bytes).into_owned())
}

fn selection_json(body: &str) -> serde_json::Value {
serde_json::from_str(body).unwrap_or_else(|_| panic!("not a selection: {body}"))
}

/// QA 2026-09-10 F14, the daemon's half, end to end through the real router,
/// the reach gate and a PRIVATE chat — the configuration every chat on a
/// UCSF install is in.
///
/// QA read the blank `.active-kb` it found after deleting the primary as
/// "the daemon does not repair the selection". The blank IS the repair for a
/// delete (D2 in `docs/knowledge-base/multi-kb-implementation-plan.md`):
/// hiding promotes to the next base, deleting clears to the explicit
/// no-primary, and a chat that merely inherited keeps inheriting. What this
/// pins is the rest of the contract the Knowledge view now relies on instead
/// of re-deriving it: nothing is left pointing at the deleted base, in any
/// scope, and the person at the keyboard can choose again — for a private
/// chat — and have it stick.
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn deleting_the_primary_leaves_no_pointer_at_it_and_the_user_can_choose_again() {
use biorouter_mcp::knowledge::service::PrimaryUpdate;

install_test_user_action_key();
// A throwaway knowledge root: this test creates bases and moves
// pointers, which it must never do in a real one.
let knowledge_root = tempfile::tempdir().unwrap();
let state = AppState::new_with_knowledge_root(knowledge_root.path().to_path_buf())
.await
.unwrap();
let svc = state.knowledge_service.clone();
let pinning = seed_private_chat(&state, "F14 pinning chat (test fixture)").await;
let inheriting = seed_private_chat(&state, "F14 inheriting chat (test fixture)").await;
svc.create_base("soul", "Soul", None).unwrap();
svc.create_base("doomed", "Doomed", None).unwrap();

// The machine default names the base about to go, so the inheriting
// chat shows it as its primary too; the other chat pins it itself — as
// the person does, with the proof, through the gate.
svc.set_selection(None, None, PrimaryUpdate::Set("doomed"))
.unwrap();
let (status, body) = post_knowledge_active(
state.clone(),
serde_json::json!({ "session_id": pinning.id(), "primary_kb": "doomed" }),
Some(TEST_USER_ACTION_KEY),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
for chat in [pinning.id(), inheriting.id()] {
let (status, body) =
get_knowledge_active(state.clone(), chat, Some(TEST_USER_ACTION_KEY)).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(selection_json(&body)["primary_kb"], "doomed", "{body}");
}

let (status, body) = delete_knowledge_base(state.clone(), "doomed").await;
assert_eq!(status, StatusCode::NO_CONTENT, "{body}");

// No scope reports the deleted base, as primary or as a member…
for chat in [pinning.id(), inheriting.id()] {
let (status, body) =
get_knowledge_active(state.clone(), chat, Some(TEST_USER_ACTION_KEY)).await;
assert_eq!(status, StatusCode::OK, "{body}");
let selection = selection_json(&body);
assert!(selection["primary_kb"].is_null(), "{body}");
assert_eq!(selection["kb_ids"], serde_json::json!(["soul"]), "{body}");
}
let machine = svc.selection(None).unwrap();
assert_eq!(machine.primary_kb, None);

// …and none is left STORING it. The two pointers that named it are the
// explicit no-primary — a blank file, which must not fall back to Soul —
// and the chat that only inherited was left inheriting: no file of its
// own was invented for it.
let active_kb = std::fs::read_to_string(knowledge_root.path().join(".active-kb")).unwrap();
assert_eq!(
active_kb.trim(),
"",
"the machine pointer still names something"
);
let sessions = knowledge_root.path().join(".active-kb-sessions");
let stored: Vec<String> = std::fs::read_dir(&sessions)
.unwrap()
.map(|entry| std::fs::read_to_string(entry.unwrap().path()).unwrap())
.collect();
assert_eq!(
stored,
vec![String::new()],
"exactly one chat pinned the base, and its pointer must now be blank"
);
assert_eq!(svc.get_primary_for_session(inheriting.id()).unwrap(), None);

// The person chooses again — for a PRIVATE chat, which needs the proof —
// and it sticks: in the answer, in a fresh read, and on disk.
let (status, body) = post_knowledge_active(
state.clone(),
serde_json::json!({ "session_id": inheriting.id(), "primary_kb": "soul" }),
Some(TEST_USER_ACTION_KEY),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(selection_json(&body)["primary_kb"], "soul", "{body}");
let (status, body) =
get_knowledge_active(state.clone(), inheriting.id(), Some(TEST_USER_ACTION_KEY)).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(selection_json(&body)["primary_kb"], "soul", "{body}");
assert_eq!(
svc.get_primary_for_session(inheriting.id())
.unwrap()
.as_deref(),
Some("soul")
);

// The same write without the proof is still refused, and moves nothing.
let (status, _) = post_knowledge_active(
state.clone(),
serde_json::json!({ "session_id": pinning.id(), "primary_kb": "soul" }),
None,
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(svc.get_primary_for_session(pinning.id()).unwrap(), None);
}
}
128 changes: 128 additions & 0 deletions crates/biorouter-server/tests/knowledge_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,134 @@ async fn history_write_restore_roundtrip() {
);
}

/// QA 2026-09-10 F13: after a digest the Change log listed only `create
/// knowledge base …`, while `git log` in the base held the two `[ingest]`
/// commits that wrote every page. The drawer was at fault — it read once,
/// before the digest (`ui/desktop/src/components/knowledge/hooks/useHistory.ts`)
/// — but the obvious suspect was this route: does it filter by a commit-message
/// prefix, or read a side-log instead of git? It does neither, and this pins
/// that it keeps not doing so. Every commit the knowledge write paths make, of
/// every kind, comes back, in `git log`'s order, the ingest pair included.
#[tokio::test]
async fn history_lists_every_commit_git_holds_ingest_included() {
use biorouter_mcp::knowledge::{git::GitRepo, types::ChangeKind};

let (_d, root, app) = build_test_router_with_root();
create_kb(app.clone(), "hist-f13", "History F13").await;

// What a digest does first: stage the source, which commits
// `[ingest] ingested <source id>`.
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/bases/hist-f13/raw")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"text": "Metformin is a biguanide used as first-line therapy.",
"title": "Pasted knowledge"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), 200);
let staged: serde_json::Value = serde_json::from_slice(
&axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
let source_id = staged["source_id"].as_str().unwrap().to_string();

// …then the digest itself: pages written on a transaction branch and
// squash-committed onto main as ONE commit, the way the ingest and lint
// macros commit.
let kb_root = root.join("hist-f13");
let repo = GitRepo::open(&kb_root).unwrap();
for (kind, label, summary, page) in [
(
ChangeKind::Ingest,
"ingest",
"ingest pasted-knowledge",
"metformin",
),
(ChangeKind::Lint, "lint", "lint autofix", "biguanide"),
] {
let txn = repo.begin_txn(label).unwrap();
let notes = kb_root.join("knowledge").join("notes");
std::fs::create_dir_all(&notes).unwrap();
std::fs::write(
notes.join(format!("{page}.md")),
valid_page("note", page, &format!("# {page}")),
)
.unwrap();
repo.commit_on_txn(&txn, "work in progress").unwrap();
repo.commit_txn(&txn, kind, summary, Some("+1 page"))
.unwrap();
}

let res = app
.oneshot(
Request::builder()
.uri("/bases/hist-f13/history?limit=200")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), 200);
let history: Vec<serde_json::Value> = serde_json::from_slice(
&axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
let listed: Vec<(String, String)> = history
.iter()
.map(|entry| {
(
entry["kind"].as_str().unwrap().to_string(),
entry["summary"].as_str().unwrap().to_string(),
)
})
.collect();
let expected: Vec<(String, String)> = [
("lint", "lint autofix".to_string()),
("ingest", "ingest pasted-knowledge".to_string()),
("ingest", format!("ingested {source_id}")),
("manual", "create knowledge base hist-f13".to_string()),
]
.into_iter()
.map(|(kind, summary)| (kind.to_string(), summary))
.collect();
assert_eq!(listed, expected, "the history route must list every commit");

// …sha for sha, in the order `git log` prints them. The CLI is the ground
// truth QA compared against; it is optional here only so a machine without
// it does not fail a test about something else.
let shas: Vec<&str> = history
.iter()
.map(|entry| entry["commit_sha"].as_str().unwrap())
.collect();
match std::process::Command::new("git")
.arg("-C")
.arg(&kb_root)
.args(["log", "--format=%H"])
.output()
{
Ok(out) if out.status.success() => {
let log = String::from_utf8(out.stdout).unwrap();
assert_eq!(shas, log.lines().collect::<Vec<_>>());
}
_ => eprintln!("git CLI unavailable; compared with the commits this test made instead"),
}
}

// ──────────────────────────────────────────────────────────────────────────────
// Task 8: POST /bases/:id/raw
// ──────────────────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading