diff --git a/CLAUDE.md b/CLAUDE.md index 93c10fa..0bc436d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,276 +1,160 @@ # CLAUDE.md -Guidance for Claude Code (claude.ai/code) working in this repository. +Guidance for Claude Code (claude.ai/code) and other agents working in this repository. +`AGENTS.md` is a symlink to this file. -## What B2 is +## What B2 is for -A personal, local-first Markdown knowledge vault with an AI layer that **surfaces semantically -similar notes** for you to connect. The Markdown files stay plain and yours; B2 is the intelligence -layer over them, not a container around them. This Cargo workspace is the **index engine + its two -dumb adapters** — the `b2` CLI and the Tauri desktop app (with the `ui/` frontend). +B2 is my daily notes app, built to replace Obsidian. The reason to replace it is research: +Obsidian is a good place to keep notes and a poor place to think with them. B2 keeps what +Obsidian gets right (plain Markdown in a folder I own, usable by any editor, no lock-in) and +adds an intelligence layer that helps me do research across what I have already written: -## Where the truth lives +- **Find the connections I haven't made.** Every note shows the related notes it isn't linked + to yet, and **Why?** explains what the two have in common, with citations. +- **Say how ideas relate, not just that they do.** A link can be typed as `supports` or + `contradicts`, because stance is the one thing similarity can't infer. +- **Get answers I can check.** Search says "no matches" when the vault holds no evidence, and + chat answers only from my notes, citing each claim back to the passage it came from. +- **Let agents work the vault too.** Every command has a `--json` form, so an agent can search, + read and walk the graph the way I do. -Read before changing behaviour. The code is a *projection of the spec*, and comments cite it -constantly (`data-model.md §2`, `index-engine.md §6`, invariant ids like `S2`, `D1`). +Two ideas shape every decision, and they are the ones to protect: -| Source | Role | -|---|---| -| [`docs/invariants.md`](docs/invariants.md) | The **invariant register** — the normative list of what must always be true, cited by id. **On conflict with any other doc, it wins.** | -| [`docs/data-model.md`](docs/data-model.md) | The *what*: note + connection in Markdown, the two storage tiers, the relation vocabulary. | -| [`docs/index-engine.md`](docs/index-engine.md) | The *how*: the SQLite (FTS5 + in-process vector scan) projection, table DDL, data flows. | -| [`ADRs/`](ADRs/README.md) | **Architecture Decision Records** — why each of the above reads the way it does. Key architectural choices only, terse. Add one when a decision is expensive to reverse and its *why* isn't readable off the code; do **not** add one per feature or bug. | -| [`docs/evals.md`](docs/evals.md) | The **eval suite guide** — every instrument, how to read its output, the exit gate, the verdict record, and the **process rules**; read before touching the corpus, the labels, or the metrics. | -| [GitHub Issues](https://github.com/AlteredCraft/B2/issues) | Backlog and planned work. Decision history = the issue that drove a verdict + the commit that shipped it. | +1. **The vault is the truth; the index is disposable.** `index = projection of (the vault + directory)`. Drop `.b2/b2.sqlite`, rebuild, get the same index. So I can move, split, merge + and rewrite notes without fear, and B2 writes nothing I didn't ask for. +2. **Build for tomorrow's model.** Every AI part sits behind a small seam, and B2 does the least + today's model needs. A better model should drop in, not force a redesign. + +The human is the judge. B2 suggests; I decide what gets linked, typed and kept. + +## Where we are, and how work starts -## Commands +The build-out is done: the engine, the `b2` CLI, the desktop app and grounded chat all work end +to end. The next phase is **using B2 every day and letting that use drive the work.** -`make` (no args) lists every target with a one-line summary, grouped setup / dev / gates / coverage / -model — that listing is the command reference; what follows is what it can't tell you. +- New work starts from an issue labelled **`observed`**: what I was doing, what I expected, what + happened, how often. Prefer fixing friction I actually hit over tuning I might need. +- The backlog was reset on 2026-09-23. Closed ideas (rerankers, scaling levers, resource search, + sync, highlights) keep their write-ups. Reopen one only when an observation calls for it. +- Measure before you tune. A change to ranking or model quality is judged by the eval harness + ([docs/evals.md](docs/evals.md)), never by intuition. +- If a request would pull B2 away from the vision above (a proprietary format, an unbidden write, + an AI step with no seam), say so before building it. -**The two gates.** Same commands, same order, whether you run them or GitHub does (ADR-0018): +## Where the truth lives + +The code is a projection of the spec, and comments cite it (`data-model.md §2`, invariant ids +like `S2`, `D1`). Read the relevant part before changing behaviour. + +| Source | Role | +|---|---| +| [docs/invariants.md](docs/invariants.md) | What must always be true, cited by id. **On conflict with anything else, it wins.** | +| [docs/data-model.md](docs/data-model.md) | The *what*: notes, connections, the two storage tiers, the relation vocabulary. | +| [docs/index-engine.md](docs/index-engine.md) | The *how*: the SQLite projection, its tables, the four flows, the Why? tool turn. | +| [docs/architecture.md](docs/architecture.md) | The map: crates, flows, seams, how the tests hold it up. Start here in the code. | +| [docs/quickstart.md](docs/quickstart.md) | Using B2: commands, config, and **every environment variable**. | +| [ADRs/](ADRs/README.md) | Why the above reads the way it does. Add one only for a decision that is expensive to reverse and whose *why* isn't readable off the code. | +| [GitHub Issues](https://github.com/AlteredCraft/B2/issues) | The backlog. Decision history is the issue that drove a change plus the commit that shipped it. | + +`crates/b2-desktop/CLAUDE.md` holds the desktop crate's own rules. Read it before touching that +crate or `ui/`. + +## The shape of the system + +A Cargo workspace plus a frontend. Details and the reasons live in +[docs/architecture.md](docs/architecture.md). + +- **`b2-core`**: the whole engine, and the one typed API, the `Vault` façade (`vault.rs`). + Model-free, deterministic, fast to test. Never add candle, tokenizers or an HTTP client here. +- **`b2-embed`**: the real local embedder (candle, bge). All heavy ML dependencies live here. +- **`b2-llm`**: the real chat provider, a small sync OpenAI-compatible streaming client. Ollama + is the guided default; any compatible endpoint works. +- **`b2-cli`** and **`b2-desktop`** (Tauri) are the two **dumb adapters**: parse input, make one + `Vault` call, render the result. The desktop reuses the CLI's `--json` view types as its IPC + contract. The frontend is `ui/` (Vite, vanilla TypeScript, CodeMirror 6). + +Engine logic belongs in `b2-core`, behind the façade, never in an adapter. Add a façade operation +when a command needs it; don't pre-build surface. + +## Principles that hold everywhere + +- **No unbidden writes.** Reading, walking and reindexing write nothing to the vault. Every write + is the mechanics of a command I ran, and the body of a note is always mine (W1 to W3). +- **A note is its path.** Identity is the vault-relative path; nothing is minted or stamped into + files. +- **Degrade honestly.** An unembedded vault still searches by keyword; a model that can't use + tools still gets an answer; a failed condense falls back to the raw question. Say what is + missing rather than failing or pretending. +- **Model output is untrusted.** Chat stores nothing, never rewrites its answer, and a bad tool + call gets an error result, not a crash. Rendering note content is a trust boundary (ADR-0016). +- **Limits are configurable and loud.** A cap gets a clear error, a setting, and a place in + Settings, not a silent trim. +- **Determinism in the core.** No wall clock and no randomness in `b2-core`: time is passed in, + and logging is only emitted there (the adapters own the subscriber and the clock). +- **Errors shown to people are generic and actionable.** Nothing internal (sqlite, io, serde) + leaks; `B2_DEBUG` opts into detail. +- **No async.** Nothing in B2's code is async, and the `b2` binary links no tokio (ADR-0011). + +## Working in the repo + +`make` with no arguments lists every target. Two gates, the same ones CI runs (ADR-0018): ```bash -make check # FAST (~3s warm): fmt-check → clippy -D warnings → engine suite → ui/ suite. - # The loop you run while working. No network, no desktop build. -make ci # COMPLETE (~18s warm): no-tokio → ui-build → fmt-check → clippy (whole workspace) - # → cargo suite → ui/ suite → audit. Verbatim what CI runs. Run before pushing. +make check # fast loop: fmt, clippy -D warnings, engine suite, ui/ suite +make ci # everything CI runs; run it before pushing ``` -**Test suites.** `make test` = `cargo test -p b2-core`, the fast deterministic model-free engine -suite (the bulk of the weight). `make test-ui` = the `ui/` pure-logic suite (node's own runner over -`src/**/*.test.ts`, globbed recursively, running off the source with types stripped). Narrower runs: -`cargo test -p b2-core --test discover` (one file), `cargo test -p b2-core one_note_reindex` (name -filter). Whole-workspace `cargo test` compiles candle and embeds `ui/dist` — run `make ui-build` once -first. - -**Out of CI, real model** (ADR-0005, ADR-0013): `make init` provisions bge-base-en-v1.5 into the XDG -cache; `make eval` / `eval-sweep` / `eval-stemmer` / `eval-metal` / `eval-chat` score quality; -`make stability` is the model-free rank probe (`stability-bless` accepts an *intended* ranking -change — it records what is, never what is better); `make calibrate VAULT=` is the real-vault -transfer check any distributional constant owes. Grounded chat needs a model server — -`ollama serve` + `ollama pull llama3.2`, or `B2_LLM=fake` for the deterministic provider. - -**Coverage** needs both `cargo-llvm-cov` and `rustup component add llvm-tools-preview` (per -toolchain). Without them the recipes die on cargo's bare "no such command: `llvm-cov`", which names -neither — `make doctor` checks both and prints the fix. - -**Metal** is a *build* switch, not runtime: `cargo run -p b2-cli --features metal -- reindex`, and -`make app` auto-selects it on Apple Silicon (`make app-cpu` forces CPU). Flipping device re-embeds -the vault, because the device is part of the embedding space's identity (ADR-0007). - -**Desktop path quirk:** `make app` runs `cargo tauri dev` with cwd `crates/b2-desktop/`, so a -*relative* `B2_LOG_FILE` lands under that crate dir. Pass an absolute path: -`B2_LOG_FILE=$PWD/logs/desktop.jsonl B2_VAULT_PATH=~/notes make app`. - -### Environment variables - -- **`B2_VAULT_PATH`** — the vault root, so commands need no `-C`/`--vault` (an explicit flag wins). - Read-only commands fall back to the current dir; **commands that write** (`reindex`/`add`/`mv`/ - `rm`/`link`) require an explicit vault and refuse otherwise (`Cli::require_vault`), so a stale - binary or typo'd var can't silently touch the wrong dir. -- **`B2_EMBEDDER=fake`** — the deterministic fake embedder everywhere (offline/dev; what the test - suite runs under). **`B2_LLM=fake`** is its sibling for chat. -- **`B2_DEBUG`** — print internal error detail after the generic message. -- **`B2_LLM_URL` / `B2_LLM_MODEL`** — the OpenAI-compatible endpoint + model (defaults - `http://localhost:11434/v1` + `llama3.2`). **`B2_LLM_API_KEY`** carries a cloud endpoint's bearer - token — never a flag, because a key in a flag is a key in `ps`. `ask`/`chat`'s `--llm-url` / - `--llm-model` beat the env, which beats the default; resolution lives once in - `b2_llm::LlmConfig::from_env`, and the desktop layers its Settings over the same base. The **key** - layers the other way: the desktop stores it in the **macOS Keychain**, never in `chat.json`, and - `B2_LLM_API_KEY` overrides whatever is stored (`b2_llm::ApiKeySource` — `none`/`environment`/ - `stored`/`session` — is what the Settings copy reads; the key never crosses back to the webview). - Chat config is adapter-level, never vault or index state, so a chat-model swap costs no reindex. - **`B2_LLM_MAX_TOOL_CALLS`** (default 64) caps the tool calls — and the highest call `index` — one - model reply may carry. It bounds memory a server can make B2 allocate, so a reply past it **fails** - (`LlmError::TooManyToolCalls` → `b2_core::Error::ToolCallLimit`, typed across the seam, WARN-logged, - with its own user message) rather than being trimmed, and `why_similar` never degrades past it. - `b2_llm::parse_max_tool_calls` is the **one** judge of a typed cap — a whole number from 1 to - `MAX_TOOL_CALLS_CEILING` (4096; a memory bound needs a bound of its own) — shared by the variable - and the desktop's Settings → Chat field, which **beats** the variable (the `with_overrides` rule, - not the key's). A refused env value keeps the default and says so at WARN. -- **`B2_LOG`** — structured debug logging as **JSON Lines** (stdout stays pure data), one flat object - per event, to stderr or to **`B2_LOG_FILE=`** (append mode, so runs accumulate into one - reportable dataset). The value is a tracing filter (`debug`, `b2::sqlite=debug`, …); `B2_DEBUG` or - `B2_LOG_FILE` alone implies **`b2=debug`** — the kernel's own targets only, so Tauri/wry/`ureq` - records stay out of the dataset. Emitted: per-statement SQLite timings from SQLite's own profiler - (`b2::sqlite` — SQL template, `duration_us`, `vm_steps`/`fullscan_steps`; at/over - `B2_SLOW_QUERY_MS`, default 100, logged at WARN with `slow=true`), a span per `Vault` op - (`b2::vault`), and flow milestones (`b2::ingest`, `b2::search`). The core only *emits* — the - subscriber and its clock live in the adapter (`init_logging` in both), so `b2-core` stays - wall-clock-free and the instrumentation is inert unless an adapter opts in. - -## Architecture - -**`index = a pure projection of (the vault directory)`** (ADR-0002; invariants S1–S5). Two tiers: the -vault directory is the source of truth, `/.b2/b2.sqlite` is a disposable cache — FTS5 + -plain-table vectors + the typed `edges` graph. A note's identity is its **vault-relative path** -(ADR-0003). **B2 makes no unbidden write at all** (ADR-0004): reading, walking and reindexing write -nothing; every write is the mechanics of a command the human invoked. - -### Workspace crates - -- **`b2-core`** — the whole index engine and the typed `Vault` façade. Deliberately **model-free** (no - candle) so its suite stays fast and deterministic. Deps: rusqlite (bundled SQLite + FTS5), blake3, - ulid, yaml-rust2. No vector extension — vectors are plain BLOB tables scored in-process (ADR-0006). -- **`b2-embed`** — the real candle-backed embedder (bge-base-en-v1.5, 768-dim). **All heavy ML deps - live only here.** `provision` (`b2 init`) downloads + verifies into a shared XDG cache; - `LocalEmbedder::load` fails fast with "run `b2 init`" if absent. `hf-hub` is taken - `default-features = false` (ADR-0011). -- **`b2-llm`** — the real chat provider: a hand-rolled **sync** OpenAI-compatible SSE client over - `ureq` behind the `LlmProvider` seam. One wire shape (`POST {base}/chat/completions`, - `stream: true`, frames until `[DONE]`), so the quirks it owns are its tests: keep-alive comments, - multi-line `data:`, CRLF, mid-stream error frames, a stream that stops without `[DONE]` (a - *truncated* answer, honestly marked cancelled — not an error), and a garbled frame (an error, so a - protocol mismatch can't pass as a short answer). `probe` is `b2 init`'s posture for chat — one - `GET /models` before a human waits — and it believes only what it can check: an HTTP refusal is a - refusal (`LlmError::Refused`), and tolerance is bounded to a 2xx whose body isn't a model list. - `setup` is the same question asked for a *card*: it never fails, and it is the one deliberately - **Ollama-native** corner (`GET /api/tags` + a memory-sized pull suggestion) in an otherwise generic - `/v1` crate. Tool calls (ADR-0022) ride the same wire: `tools` is omitted when empty, each - exchange replays as an assistant `tool_calls` message plus its `tool` result, and the SSE - reader assembles `delta.tool_calls` across the three shapes servers send (split by `index`, - whole per frame, no `id`). That asymmetry is by design — guided setup is a per-runtime feature — so it must not - be "generalized" against an abstraction that cannot serve it. -- **`b2-cli`** — the `b2` binary. A *dumb* adapter (ADR-0012): parse args, inject the embedder and - chat provider, call `Vault`, print (human-readable, streamed, or `--json` for agents). -- **`b2-desktop`** — the Tauri host, the GUI sibling of `b2-cli`. Each `#[tauri::command]` is - deserialize → one `Vault` call → serialize, reusing the CLI's `--json` view types as the IPC - contract; it owns host-only infrastructure (the cancellable reindex task, the fs-watch - `vault-changed` pulse, the OS folder dialog, the declared menu bar, the streaming cancellable - `ask`, the Keychain). **Has its own `CLAUDE.md` with the thin-adapter rules — read it before - touching this crate.** -- **`ui/`** (not a crate) — the frontend: Vite + vanilla TS + CodeMirror 6, talking to the host over - Tauri IPC (`ui/src/api.ts` is the seam). Rendering is a **trust boundary** (ADR-0016). Chords live - in one registry (ADR-0017). Fenced code is highlighted from CodeMirror's own grammar registry - through one resolver (`ui/src/highlight.ts`), so a fence looks the same read or edited; icons are - one vendored Bootstrap Icons subset generated into `icons.gen.ts` (`make icons`; `npm test` runs - the generator in `--check` mode first, so a stale generated file fails the gate). Layout, theme, - pane widths and rebound chords persist in `localStorage` — a viewing choice, never vault state — - and the loader re-judges what it reads. - -### The `Vault` façade (`b2-core/src/vault.rs`) - -The **one typed API**; the CLI and the desktop host are its only clients (every other `b2-core` -module is called directly only by integration tests). Surface: lifecycle + indexing (`open` / -`open_with_embedder` / `reindex` / `reindex_with_progress` / `plan_reindex` / `project` / `embed`), -reads (`read` / `list_notes` / `list_resources` / `list_dirs` / `neighbors` / `explain` / -`explain_resource` / `read_resource_bytes` / `search` / `search_evidence` / `similar` / -`ask` / `why_similar`), writes (`add_note` / `create_note` / `create_dir` / `import_file` / `import_path` / -`move_note` / `move_resource` / `move_dir` / `link` / `write` / `write_frontmatter` / -`delete_note` / `delete_resource` / `delete_dir`). **Add operations when a command needs them; do not pre-build a broad surface.** The -embedder is injected here: `open` defaults to the fake, `open_with_embedder` wires the real model. - -### Data flows - -- **Flow ① ingest/reindex** (`ingest.rs`) — parse → project notes, chunks (+FTS), embeddings and the - typed `edges` graph, **writing nothing to the vault**. Two-phase, so link resolution is independent - of file order, and **two separately-invokable passes**: model-free `project_vault` - (notes/chunks/FTS/edges) and `embed_vault` (fills the DB-derived missing-vector set). `reindex` - composes them; `search` falls back to BM25-only on a projected-but-unembedded vault. The - whole-vault pass owns every *reconciliation* — pruning rows for files the walk no longer met, - collecting vectors no chunk references — and single-note paths never prune. -- **Flow ② hybrid search** (`search.rs`) — BM25 (`chunks_fts`) ⊕ vector KNN fused with RRF, resolved - from chunks up to notes (ADR-0008). Raw NL queries are sanitized into a safe FTS5 `MATCH` - expression (punctuation is FTS5 syntax and would otherwise crash the parse). `hybrid_search` - returns a `Retrieval` carrying the per-hit and per-query evidence signals RRF discards; a query the - vault holds no evidence for answers **"no matches"** (ADR-0015). -- **Flow ③ connection discovery** — `discover::candidates` ranks the nearest *unlinked* notes in two - stages (centroid shortlist → exact max-sim over the shortlist's chunk vectors, minus the anchor's - 1-hop neighbours), and always serves the ranked top-N (ADR-0009, ADR-0014). `b2 link` appends a - typed `b2_relations:` entry (`note::add_relation` — frontmatter, **never the body**) and re-projects - it. The GUI's other authoring gesture is `ui/src/droplink.ts`: dragging a Similar card onto a line - types a `[[wikilink]]` there, landing in the editor's buffer exactly as `[[` completion does. -- **Flow ④ grounded chat** (`chat.rs` + `Vault::ask`) — condense (multi-turn only; degrades to the - raw question on failure, so that step can never break chat) → retrieve (`search_chunks` at - `chat::ASK_PASSAGES`) → assemble (grounded system prompt + numbered passages — prompt assembly is - core logic) → stream (tokens through the caller's callback; `Break` cancels at token granularity) - → cite (`[n]` markers resolve to `(path, excerpt)` in `AnswerView`; a hallucinated marker resolves - to nothing, and the answer text is **never rewritten**). Chat is a **reader**: nothing model-derived - is stored, history is session-only, model output is untrusted content. Every surface streams, which - is why `--json` is a JSONL *event* stream. Both adapters cancel the same way (Ctrl-C / Esc) and - render what already arrived — `Completion` marks a cut stream rather than failing it. - `Vault::why_similar` (`b2 why`, the Similar card's **Why?**) is the one **tool-using** turn - (ADR-0022): the model is offered B2's read-only tools (`chat::why_tools` — `b2_passage_pairs`, - `b2_similar`, `b2_neighbors`, `b2_read`, each one façade read) and the core runs what it calls, - bounded at `MAX_TOOL_ROUNDS`. Round 1's text is never streamed; a model that calls nothing, or - has no tool support, degrades to the same evidence in one plain request - (`chat::build_why_request`); a skipped pair lookup is appended as `seeded`. Tool calls are - untrusted model output — a bad one gets an `error:` result, never a failed turn — and - `AnswerView.tools` says what ran. `FakeLlm` scripts the loop off the request's structure. -- **`graph_filtered_search`** (`search.rs`) — the vector⨝graph join: nearest chunks whose note is - within *k* typed hops of an anchor. `discover::candidates` is its *complement* (nearest notes *not* - already connected). - -### The typed graph - -`edges` carries `origin` (`inline`/`frontmatter`) and a deterministic id from `(src, dst, type, -occurrence)`. **No `status` column** — every edge is authored and active. Body links are always -untyped `references`; frontmatter `b2_relations:` is the sole typed home; overlap dedups -frontmatter-wins. The vocabulary is a closed three-verb stance core (`references` / `supports` / -`contradicts`) plus a tolerated verbatim tail. Edges are directed and stored once; inverse labels are -display-only. Details and rationale: ADR-0010. - -### Embedding-space discipline - -`embeddings(text_hash, vector)` + `note_centroids(note_path, centroid)`, created at **embed time** so -their existence is the "this vault has an embedding space" signal the fallbacks key on; -content-addressed by blake3 of the chunk text (ADR-0006). `meta.(embed_model_id, embed_dim)` is the -one place a model swap is detectable, device included — a swap drops both tables and re-embeds on -`reindex`, `search` fails fast rather than mixing spaces, and `open` never mutates the vector space -(ADR-0007). - -## Conventions - -- **Determinism is a hard requirement of the core.** No wall-clock and no randomness inside - `b2-core`: timestamps are passed in (the `created` param on write ops), and nothing is minted at - all — a note's identity is its path, so the `IdGen` seam is gone. Tests assert against golden-vault - *paths* in `tests/common/mod.rs`. -- **Keep `cargo test` fast, deterministic, and model-free.** Real-model work belongs out of CI — - behind `b2 init`, an `--example`, or a manual run. Never add candle/tokenizers deps to `b2-core`. -- **Never `#[ignore]` a test, and a hard-to-write test is a signal, not a chore.** `#[ignore]` hides a - test while leaving it looking present — a silent gap. If a test is difficult to write, keep - faithful, or make pass, *stop and reflect*: is the test valuable; are we testing the right thing; is - the fault in the test or in the system? A test that fights you is usually coupled to an - implementation detail rather than a real invariant — re-anchor it, or fix the system. When the - resolution isn't obvious, **open a conversation with the user**; do not reach for `#[ignore]`, a - brittle fixture, or a weakened assertion. A check that genuinely needs the real model belongs in the - eval harness, where it actually runs (the batch ≡ single embedding check is the worked example). -- **A test's name is part of its contract.** If the name claims more than the body asserts, the suite - reads as covering ground it doesn't. `make coverage` finds the other half of that problem: a line - the suite never executes. -- **Shared test scaffolding lives in `crates/b2-core/tests/common/mod.rs`** — fixture setup - (`reindexed_vault` / `opened_vault` / `ingest_golden`) and the read-back shims (`index_conn`, - `count`). A helper wanted by more than one file goes there rather than being copied; what only one - file needs stays in that file. The one deliberate exception is the tracing `Capture` writer - duplicated by `tests/logging.rs` and `tests/discover_query_count.rs`: hoisting it would make every - test binary link `tracing-subscriber` to serve two, and those two already need their own binaries - (tracing's global callsite-interest cache races across parallel test threads). -- **User-facing errors are generic and actionable, never leaking internals** (sqlite/io/serde). The - CLI funnels everything through `user_message`; `B2_DEBUG` opts into detail. -- Integration tests copy `fixtures/golden-vault/` into a tempdir first, so no suite can mutate the - repo fixtures (CI's `git diff --exit-code` is the backstop). `fixtures/test-vault/` is a *separate*, - larger synthetic fixture (~200 notes) for **out-of-CI throughput/quality experiments** — see - `fixtures/README.md`. +Things the target list won't tell you: + +- **Offline by default.** `B2_EMBEDDER=fake` and `B2_LLM=fake` give deterministic stand-ins; + the test suite runs on them. The real model needs `make init` once; real chat needs + `ollama serve`. +- **Commands that write need an explicit vault** (`-C`, a path, or `B2_VAULT_PATH`), so a typo + can't touch the wrong folder. +- **Metal is a build switch, not a runtime one.** `make app` picks it on Apple Silicon. The + device is part of the embedding space's identity, so switching re-embeds the vault (ADR-0007). +- **Whole-workspace `cargo test`** embeds `ui/dist`, so run `make ui-build` once first. +- **Coverage** needs `cargo-llvm-cov` and `llvm-tools-preview`; `make doctor` checks both. + +### Tests + +- **Keep `cargo test` fast, deterministic and model-free.** Anything that needs the real model + belongs in the eval harness, where it actually runs. +- **Never `#[ignore]` a test.** A test that is hard to write is a signal: is it testing a real + invariant or an implementation detail? Is the fault in the test or the system? If the answer + isn't clear, ask me rather than weakening the assertion. +- **A test's name is part of its contract.** It must not claim more than the body asserts. +- Integration tests copy `fixtures/golden-vault/` into a temp dir first, so no suite can change + the repo's fixtures. Shared helpers live in `crates/b2-core/tests/common/mod.rs`; the one + deliberate copy is the tracing `Capture` writer in `logging.rs` and + `discover_query_count.rs`, which need their own test binaries. (`fixtures/test-vault/` is a + larger synthetic vault for out-of-CI experiments.) ## Idiomatic Rust -### Rust data modeling - -- Ownership forms a tree/DAG, never a cycle. One clear owner per value. -- For references between values (or any logical cycle): use `slotmap` keys, or `Vec` indices only if nothing is ever removed. Do NOT default to `Rc>`; treat `Rc` / `Arc>` as last resorts after trying ownership + keys. -- Prefer owned fields over borrowed (`&'a T`) fields. If a struct sprouts a lifetime parameter, reconsider — it usually wants owned data or a key. Legitimate exception: a short-lived, `Copy`, read-only *view* struct passed into one call and never stored (e.g. `NoteRow` in `db.rs`) — borrowing there avoids a needless clone; keep it, and say so in the doc-comment. -- Never silence the borrow checker with a reflexive `.clone()`. Diagnose ownership first: should this be a key instead of a reference? -- No self-referential structs in safe Rust; restructure with indices. -- No `.unwrap()` / `.expect()` in production paths; handle via `match`, `if let`, or `?`. This holds even for an invariant you believe can't fail (e.g. `strip_prefix` on a path you just walked) — degrade gracefully (skip it) rather than panic. -- When stuck, ask: "Who owns this, and can the relationship be an ID instead of a pointer?" - -### Rust style & structure - -- Errors: reach for `thiserror` typed enums wherever error *variants get matched on* — every library, **and any binary that does too**: the CLI maps variants to user-facing messages in `user_message`, so `CliError` is a `thiserror` enum, not `anyhow` (which erases the type and would force `downcast_ref`). Use `anyhow` only where errors are merely propagated and printed. Never hand-roll `From`/`Display` impls — `#[from]` and `#[error("…")]` generate them. -- Signatures: accept `&str` not `&String`, `&[T]` not `&Vec`. Return owned types and let callers borrow. -- Prefer iterator chains over manual index loops (`for x in &items`, not `for i in 0..items.len()`). -- Do NOT introduce `async`/`tokio`, generics, traits, or macros until there's a concrete need. No speculative abstraction. -- `unsafe` requires an explicit `// SAFETY:` comment stating the invariant that makes it sound (see `model.rs`'s weights mmap); otherwise disallowed. -- Derive `Debug` on public data types (and `Clone`/`PartialEq` where it makes sense). -- Keep modules small and domain-named; document public items with `///` comments stating intent, not mechanics. +### Data modeling + +- Ownership forms a tree or DAG, never a cycle. One clear owner per value. +- For references between values, use keys (`slotmap`, or `Vec` indices if nothing is removed). + `Rc>` and `Arc>` are last resorts. +- Prefer owned fields to borrowed ones. A lifetime on a struct usually wants owned data or a key. + The exception is a short-lived, `Copy`, read-only view passed into one call (like `NoteRow` in + `db.rs`); say so in its doc comment. +- Never silence the borrow checker with a reflexive `.clone()`. Ask who owns the value, and + whether the relationship should be an ID instead of a pointer. +- No self-referential structs; restructure with indices. +- No `.unwrap()` or `.expect()` in production paths, even for an invariant you believe can't + fail. Degrade gracefully instead of panicking. + +### Style and structure + +- Errors: `thiserror` enums wherever variants are matched, including the CLI's `CliError`. + `anyhow` only where errors are just propagated and printed. Never hand-roll `From`/`Display`. +- Accept `&str` and `&[T]`; return owned types. +- Prefer iterator chains to index loops. +- No `async`, generics, traits or macros until there is a concrete need. +- `unsafe` needs a `// SAFETY:` comment stating why it is sound. +- Derive `Debug` on public data types (`Clone`/`PartialEq` where sensible). +- Keep modules small and named for their domain. Doc comments state intent, not mechanics. diff --git a/README.md b/README.md index f1b40ca..9814bab 100644 --- a/README.md +++ b/README.md @@ -1,179 +1,115 @@ ---- -title: "B2 — Read me / map" -type: note -tags: [b2, readme, overview, map] -created: 2026-06-29 -status: draft ---- - -# B2 — "second brain" +# B2 [![CI](https://github.com/AlteredCraft/B2/actions/workflows/ci.yml/badge.svg)](https://github.com/AlteredCraft/B2/actions/workflows/ci.yml) -A personal, **local-first** knowledge vault — plain Markdown you fully own — with an AI layer that -**surfaces the semantically similar notes you haven't linked yet**, so you can commit the typed, -explained connections between them yourself. - -> **Status:** the design is **locked** and the **index engine is built** (`crates/b2-core`: steps 0→5 -> of the index engine). The **`b2` CLI over a typed core API** is -> live (`crates/b2-cli`): point B2 at a folder and `reindex` / `search` / `neighbors` / `explain` it -> from the terminal, with `--json` for agents. **Semantic search is real** (`crates/b2-embed`: a -> candle-backed local embedder behind the one seam; `b2 init` downloads the model into a shared cache; -> the fake stays the CI default). **Connection discovery** ships as **`b2 similar`** (surface the -> nearest *unlinked* notes — local, free, no model call) **+ `b2 link`** (you commit a typed relation -> to frontmatter) — the human is the precision gate; there is no LLM in the loop. A tour -> grounded in the test suite: [docs/architecture.md](docs/architecture.md). -> -> **Grounded chat is live in the CLI** — **`b2 ask "…"`** and **`b2 chat`** answer questions *from your -> notes*, streaming, with `[n]` citations back to the notes the answer came from -> ([#151](https://github.com/AlteredCraft/B2/issues/151)). It talks to any **OpenAI-compatible** model -> server (`crates/b2-llm`, a hand-rolled sync SSE client — nothing in B2 is async, and the `b2` binary -> links no tokio); **Ollama** is the guided default, a cloud model is explicit opt-in, and nothing -> about a chat is written to your notes or the index. The chat pane in the desktop app is next. -> **`b2 why NOTE CANDIDATE`** (the **Why?** on a *Similar & unlinked* card in the app) asks the same -> model to explain one suggestion, as a **tool-using agent**: it calls B2's read-only tools (the -> matched passages, the suggestion list, a note's links, reading a note), says what the two notes -> have in common, cites it, and lists the tools it used. -> -> **The desktop app has shipped** — a **Tauri app** (`crates/b2-desktop`, the *second dumb adapter over -> the façade*) + a **Vite + vanilla-TS** frontend (`ui/`), talking to the core over Tauri IPC. The -> **read → discover → link → edit → reconcile** arc is complete: read a note on the left, commit a typed -> link to its **similar-but-unlinked notes** on the right with a click, edit the body in place -> (CodeMirror 6 with live preview, autosave, and a revision-guarded conflict bar), and a native fs-watch -> reconciles external edits live. **Indexing is automatic in the app** — on open, and on every fs-watch pulse — -> so there is no `reindex` to remember; the manual one lives in Settings as a **cancellable background action** -> (live progress, a Cancel button, the UI usable throughout). Projection and embedding are decoupled, so -> a cold vault is browsable/keyword-searchable in seconds while embedding streams behind -> ([#15](https://github.com/AlteredCraft/B2/issues/15)). Run it with `make app` — pick a vault from the -> in-app switcher, or skip straight to one via `B2_VAULT_PATH`. **Next:** file-type support (resources) — -> slice 1, inventory & graph, is built; the wider backlog lives in -> [GitHub Issues](https://github.com/AlteredCraft/B2/issues). - -## What B2 is (the north star) - -Point B2 at a folder of Markdown notes and it becomes a second brain that thinks alongside you: it -reads everything, builds a *typed* graph, and keeps **surfacing the similar notes you haven't -connected yet** — so the structure of your knowledge grows as you link them, instead of rotting. -The files stay plain Markdown on your disk, yours forever; B2 is the **intelligence layer over them, -not a container around them**. Humans and AI agents are both first-class users. - -Full motivation, scope, and locked decisions: **[docs/invariants.md](docs/invariants.md)**. - -## How we build it - -Two architectural tenets shape every decision (full text: -[docs/invariants.md](docs/invariants.md)): - -- **A volatile vault over a disposable index.** Refactor fearlessly — move, split, merge, compress, - trim orphans. The index is a pure projection of your vault (drop it, rebuild it identical); - **nothing durable B2 derives lives outside your notes** (`index = projection of (the vault directory)`). - Idempotency is the mechanism; a vault you can rewrite without fear is the point. -- **Build for tomorrow's model (the Bitter Lesson).** Every AI part sits behind a swappable seam; - we orchestrate the minimum today's model needs and no more — so a more capable model is a drop-in, - not a redesign. - -…in service of five product non-negotiables — plain-Markdown source of truth · local-first · zero -lock-in · AI-native (not bolted-on) · single binary -([docs/invariants.md](docs/invariants.md)). - -## The docs - -Everything lives in [docs/](docs/README.md) — one page per topic, and that page is the map. -New here? Start with the **[Quick start](docs/quickstart.md)** — set up and work with a vault -in about ten minutes. Then go deeper: -[architecture](docs/architecture.md) · -[search & similarity, in plain language](docs/search-and-similarity.md). - -| Doc | What it owns | -|---|---| -| [docs/invariants.md](docs/invariants.md) | The **invariant register** — the one-page normative list of what must always be true, and the source of *why*, cited by id. On conflict with any other doc, it wins. | -| [docs/data-model.md](docs/data-model.md) | What a **note** and a **connection** are, in plain Markdown · the two storage tiers · the relation vocabulary · the invariant *definitions*. The canonical *what*. | -| [docs/index-engine.md](docs/index-engine.md) | How the derived index is *built* and queried — SQLite (FTS5 + an in-process vector scan) as a disposable projection, and the four flows over it. The canonical *how*. | -| [docs/quickstart.md](docs/quickstart.md) | Set up and use B2: the walkthrough, the command reference, config and every environment variable. | -| [docs/architecture.md](docs/architecture.md) | How the system is built: the crates, the flows, the seams, the tests. | -| [docs/search-and-similarity.md](docs/search-and-similarity.md) | What search and the related-notes panel do, in plain language, for everyone who uses B2. | -| [ADRs/](ADRs/README.md) | **Architecture Decision Records** — one terse record per key architectural choice: the context, the ruling, and what it costs. The *why* behind the register's entries. | -| [docs/evals.md](docs/evals.md) | The **eval suite guide** — every instrument and how to read it, corpora, labels, the exit gate, process rules, and the record of every measured verdict. | - -Planned work and the backlog live in [GitHub Issues](https://github.com/AlteredCraft/B2/issues); shipped build history lives in git. +**A notes app for research.** Plain Markdown in a folder you own, with an AI layer that finds +the connections you haven't made yet, explains them, and answers questions from your notes +with citations you can check. + +B2 is my daily notes app, built to replace Obsidian. Obsidian is a good place to keep notes +and a poor place to think with them. B2 keeps what Obsidian gets right (plain files, any +editor, no lock-in) and adds the part research needs: help seeing what your notes have in +common, how they bear on each other, and what they actually say. + +## What it does + +- **Shows the connections you haven't made.** Beside every note is a ranked list of related + notes it isn't linked to yet. Click **Why?** on one and a model explains what the two have + in common, citing the passages, using B2's own read-only tools to look things up. +- **Lets you say how ideas relate.** Link two notes with one click and type the link: + `references`, `supports` or `contradicts`. Similarity can tell that two notes are about the + same thing; only you know whether one backs the other up or argues against it. +- **Searches honestly.** Keyword and semantic search together. When the vault holds no + evidence for a query, B2 says "no matches" instead of showing its nearest guesses. +- **Answers from your notes, with sources.** Ask a question and get a streamed answer grounded + only in your notes, with each `[n]` pointing back to the passage it came from. Nothing about + a chat is stored. +- **Works with agents.** Every CLI command has a `--json` form, and the vault is plain + Markdown on disk, so an agent can search, read and walk the graph the way you do. +- **Is a real editor.** Live-preview Markdown, `[[wikilink]]` completion, images, a graph + view, a file tree over your real folders, and full keyboard control with rebindable keys. + Indexing is automatic: the app watches the folder and keeps up with edits made anywhere. + +## What it promises + +- **Your notes stay yours.** The vault is a folder of Markdown that reads fine in Obsidian or + any editor. B2's index lives in `.b2/` and can be deleted and rebuilt at any time. +- **No surprise writes.** Opening, reading and indexing a vault change nothing. B2 writes only + when you ask it to (a link, a move, a save), and it never writes into the body of a note on + its own. +- **Local first.** Embeddings run on your machine. Chat uses a local model through Ollama by + default; a cloud model is something you choose. +- **Refactor without fear.** Move or rename a note through B2 and every `[[wikilink]]` to it is + rewritten to match. + +## Status + +The build-out is done: the engine, the `b2` CLI, the desktop app and grounded chat all work end +to end. The next phase is using B2 every day and letting that use decide what comes next. New +work starts as an [`observed` issue](https://github.com/AlteredCraft/B2/issues?q=label%3Aobserved): +what I was doing, what I expected, what happened. B2 runs from source on macOS today (the +one tested platform); an installable build is [#23](https://github.com/AlteredCraft/B2/issues/23). + +## Get started +```bash +make doctor # checks Rust, Node, the Tauri CLI and the build toolchain +make init # one-time: download the embedding model +B2_VAULT_PATH=~/notes make app # open the desktop app on a folder of Markdown +``` -## Build and run +Point it at an existing Obsidian vault or any folder of `.md` files. Without `B2_VAULT_PATH`, +the app asks you to pick a folder and remembers it. For chat, install +[Ollama](https://ollama.com) and run `ollama pull llama3.2`. -**Stop 0 — check your setup.** On a fresh clone, run `make doctor` first: it checks Rust, -Node/npm, the Tauri CLI, and the platform build toolchain, and prints the fix for anything -missing (this is the fastest path to a working `make app` — see the desktop app section below). +The same engine runs from the terminal: ```bash -make doctor +make install # puts `b2` on your PATH +b2 -C ~/notes search "spaced repetition" +b2 -C ~/notes similar notes/memory +b2 -C ~/notes ask "what have I written about sleep and memory?" ``` -```bash -cargo install --path crates/b2-cli --locked # installs `b2` to ~/.cargo/bin (on PATH) -b2 --help -``` +The **[Quick start](docs/quickstart.md)** walks through the rest in about ten minutes, and has +every command, setting and environment variable. -This puts a real `b2` on your PATH. Re-run it (add `--force`) or `make install` to update after code changes. +## How it's built -For engine iteration where you don't want to reinstall each time, `cargo run -p b2-cli -- …` runs in place. -A `Makefile` wraps this and the other common commands — needs no separate install, `make` ships -with the platform build toolchain (Xcode Command Line Tools on macOS): +A Rust workspace. `b2-core` is the whole engine: it turns a folder of Markdown into a SQLite +index (full-text search, vectors and a typed link graph) that is a pure projection of the +files. The `b2` CLI and the Tauri desktop app are two thin front ends over the same typed API. +The real models sit behind small seams (`b2-embed` for embeddings, `b2-llm` for chat), so a +better model drops in without a redesign, and the engine is tested without them. -```bash -make doctor # sanity-check your local setup — run this first on a fresh clone -make install # build + install `b2` onto your PATH (~/.cargo/bin) -make test # fast, deterministic, model-free engine suite -make check # THE FAST GATE (~3s): fmt-check + clippy (-D warnings) + engine & frontend - # tests — the loop you run while working -make ci # THE COMPLETE GATE (~18s): the above over the whole workspace, plus the - # desktop crate, every test in the repo, and an `npm audit` of ui/. - # GitHub Actions runs this exact target, so green here is green there. -make init # download + verify the embedding model into the shared cache -make eval # semantic-retrieval quality eval (real model; never part of CI) -make eval-chat # grounded-chat quality eval (needs a model server; never part of CI) -make # list every target, grouped: setup / dev / gates / coverage / model -``` +Two ideas shape every decision: -There is no git hook and none is wanted (`.git/hooks` isn't cloneable) — CI is the enforcement, -and `make ci` is how you get the same answer before you push. +- **The vault is the truth; the index is disposable.** `index = projection of (the vault + directory)`. Drop the index, rebuild it, get the same thing back. +- **Build for tomorrow's model.** Do the least today's model needs, behind a seam, so the next + one is an upgrade rather than a rewrite. -### The desktop app (`crates/b2-desktop` + `ui/`) +## Docs + +| Doc | What it covers | +|---|---| +| [Quick start](docs/quickstart.md) | Set up and use B2: the walkthrough, commands, config, environment variables. | +| [Search and similarity](docs/search-and-similarity.md) | What search and the related-notes list do, in plain language. | +| [Architecture](docs/architecture.md) | The crates, the flows, the seams, and how the tests hold it up. | +| [Invariants](docs/invariants.md) | What must always be true, cited by id. Wins any conflict. | +| [Data model](docs/data-model.md) | What a note and a connection are, in plain Markdown. | +| [Index engine](docs/index-engine.md) | How the index is built and queried. | +| [Evals](docs/evals.md) | How search, discovery and chat quality are measured, outside CI. | +| [ADRs](ADRs/README.md) | Why each key decision reads the way it does. | -The desktop app. Prerequisites: **Node + npm** (for the `ui/` frontend — Node 18, 20, or 22+; -that floor comes from vite, [nvm](https://github.com/nvm-sh/nvm) is the easiest way to install -one: `nvm install --lts`) and the **Tauri CLI** (`cargo install tauri-cli --locked`). Run -`make doctor` first — it checks both (including the Node version, and whether nvm is -available if Node is missing), plus the platform build toolchain (Xcode Command Line Tools on -macOS), and tells you exactly what's missing and how to fix it (this is what catches e.g. -`make app` failing with `error: no such command: 'tauri'` before it happens). +## Develop ```bash -make doctor # confirm Node, npm, and the Tauri CLI are all in place -make app # dev run (Vite HMR + a live window); Metal GPU on Apple Silicon -make app-cpu # …same, but force the CPU embedder -make app-build # bundle a per-platform app -make ui-install # (rarely needed by hand — every recipe above depends on it) +make check # the fast loop: format, lint, engine and frontend tests +make ci # exactly what GitHub Actions runs; run it before pushing +make # every target, grouped ``` -On first launch (nothing remembered yet) the window opens with no vault selected — click the -vault switcher to pick one; from then on, `make app` reopens whatever vault you had open last. -`B2_VAULT_PATH` (or a launch argument) is an alternative for that first run, letting you skip -the picker and jump straight into a vault, e.g. `B2_VAULT_PATH=~/notes make app`. Search or use -the file tree to open a note, read or edit it on the left (live-preview Markdown, autosave), and -connect its similar-but-unlinked notes from the right pane. Set `B2_EMBEDDER=fake` for an -offline, non-semantic dev mode (no `b2 init` needed). - -**Embedder device — CPU or Metal GPU.** `make app` senses the platform and embeds on the **Metal GPU** -on Apple Silicon (measured **~7× faster** than CPU on the test vault — [GH #40](https://github.com/AlteredCraft/B2/issues/40)), -falling back to CPU automatically if the GPU can't initialize. `make app-cpu` forces the CPU embedder -(the A/B counterpart, or if you hit a GPU issue). The active device is shown as a subtle badge in -**Settings (⌘,)**. Metal is a **compile-time** choice, so the recipe you run picks it — and because CPU -and Metal produce distinct vectors, switching device re-embeds the vault on the next reindex (a one-time -model swap; `search` refuses to mix the two). See [`fixtures/README.md`](fixtures/README.md) and -`make compare-device` to benchmark the two on your own hardware. - -Point B2 at a vault with `-C ` (a.k.a. `--vault`) on any command, or set `B2_VAULT_PATH` once so -every command finds it without the flag (an explicit `-C` wins). Read-only commands (`search`, -`neighbors`, …) fall back to the current dir; commands that write (`reindex`, `add`, `mv`, `link`) require an -explicit vault and refuse otherwise, so they can't silently touch the wrong place. Full walkthrough: -**[Quick start](docs/quickstart.md)**. +There is no git hook; CI is the enforcement. Contributor and agent ground rules are in +[CLAUDE.md](CLAUDE.md), and the desktop crate has its own in +[crates/b2-desktop/CLAUDE.md](crates/b2-desktop/CLAUDE.md). diff --git a/crates/b2-desktop/CLAUDE.md b/crates/b2-desktop/CLAUDE.md index 4e4ca2e..71f37e2 100644 --- a/crates/b2-desktop/CLAUDE.md +++ b/crates/b2-desktop/CLAUDE.md @@ -1,453 +1,133 @@ -# CLAUDE.md — `b2-desktop` +# CLAUDE.md: `b2-desktop` and `ui/` -Guidance for Claude Code (and humans) working in this crate. It **inherits** the workspace rules in the -[root CLAUDE.md](../../CLAUDE.md) (idiomatic Rust, error policy, determinism, user-facing-error policy) and -**adds** the one rule that defines this crate's existence: **stay a dumb adapter.** The full rationale and -the read→discover→link→edit MVP shipped (its build history is in git); this file -is the enforceable in-crate rule. +Guidance for agents and humans working on the desktop app: this crate (the Tauri host) and +[`ui/`](../../ui) (the frontend). It adds to the [root CLAUDE.md](../../CLAUDE.md); everything +there still applies. -## What this crate is +## What the app is for -`b2-desktop` is the **Tauri host** for B2's desktop UI — the **GUI sibling of [`b2-cli`](../b2-cli)**. It is -a *second* dumb adapter over the [`Vault`](../b2-core/src/vault.rs) façade: it owns a window, wires the -embedder, exposes `#[tauri::command]` handlers, and hands the [`ui/`](../../ui) frontend a way to call the -core. That is all it is. +The desktop app is where B2 gets used every day. It is the notes app that replaces Obsidian, so +it has to be a good place to read and write first, and a research tool second: the related notes, +**Why?**, typed links and cited chat sit *beside* the note, never in the way of it. A citation +opens its evidence in the centre pane, next to the answer. -The frontend (HTML/JS/CSS + CodeMirror, under `ui/`) is a *separate toolchain*; the contract between it and -this crate is the **command set** plus the frontend's `ui/src/api.ts` seam. Keep that command set minimal. +Most `observed` issues will land here, because this is where friction is felt. Prefer making a +daily path smoother over adding a new surface. ## The one rule: hold no engine logic -**Every command is: deserialize args → call one `Vault` method → serialize the result.** Nothing else. - -- If a handler wants a branch, a loop, a computation, or a rule — that logic belongs in - [`b2-core`](../b2-core) **behind the façade**. Add a `Vault` op; do not add logic here. -- If the MVP needs a capability the façade lacks (e.g. reading a note's body for the left pane), the fix is - a **new façade method**, not a workaround in the host. Add ops when a command needs them; never pre-build - a broad surface. -- Reach for the façade's **existing `--json` view types** as command return values (`NeighborView`, - `ExplainView`, `ReindexReport`, …). Tauri serializes them straight to the webview. **Do not** define a - parallel set of DTOs. - -### Why thin — the argument, not just the edict - -The `Vault` façade is B2's **one typed contract**; every UI is a *client* of it. Keeping the host thin is -what makes that architecture pay off: - -- **No behavioral drift.** Two adapters (CLI + desktop) over one contract can't diverge — a fix in the core - fixes both. The moment logic leaks into this crate, the GUI and CLI become two implementations of the same - behavior, and they *will* drift. -- **Inherited tests.** A thin host means the façade's existing suite already covers the behavior; this crate - needs only a few per-command tests (args in → right façade call → view out). Logic here would need its own - parallel tests that the CLI already has. -- **The promise stays true.** [invariants.md](../../docs/invariants.md) (E3) says the GUI is "a - second dumb adapter over the same contract, inheriting every test the CLI bought." That is only true while - this crate stays dumb. Thinness is not tidiness; it's the load-bearing property. - -**Smell test:** if a `#[tauri::command]` body is longer than "parse, call, return," or if you're reaching -for a `b2-core` internal that isn't on `Vault`, stop — the missing piece is a façade method. - -## Dependency direction (one-way, always) - -`b2-desktop` → depends on → `b2-core` (and `b2-embed`). **Never the reverse.** `b2-core` must never learn -about Tauri, webviews, or the UI. This is what keeps the fast core suite (`cargo test -p b2-core`) free of -Tauri/webview deps — the same way `b2-embed`'s candle deps stay out of it. If you find yourself wanting to -add a UI concern to `b2-core`, that's the signal you're putting logic in the wrong layer. - -## Wiring conventions (mirror the CLI) - -- **Embedder injection like [`b2-cli`](../b2-cli):** pure reads open with the fake - ([`Vault::open`](../b2-core/src/vault.rs)); anything that embeds a query or writes vectors (`search`, - `link`'s re-projection, `embed`) opens the real model - ([`Vault::open_with_embedder`](../b2-core/src/vault.rs)) and fails fast with the "run `b2 init`" - message if it's absent. Four write-side ops are deliberately **model-free** and open the fake: - `project` — the model-free half of a reindex - ([#15](https://github.com/AlteredCraft/B2/issues/15)), - so the first tree paint never waits on a model load — `write_note` — the save path - ([#13](https://github.com/AlteredCraft/B2/issues/13)), so editing works with no - model provisioned and saved chunks are healed by the trailing background embed — - `create_note` — the tree's New-note action, the same posture as the save path (the new - note is projected immediately; its vectors fill on the next embed pass) — and - `write_frontmatter` — the drawer's frontmatter save - ([#79](https://github.com/AlteredCraft/B2/issues/79)): an unchanged body keeps its - chunks and vectors, so nothing ever re-embeds on that path. -- **Errors stay generic to the webview.** Map façade errors to user-facing, actionable messages exactly as - the CLI funnels through `user_message` in [`b2-cli/src/main.rs`](../b2-cli/src/main.rs) — **never** leak - sqlite/io/serde internals into the UI. Use a `thiserror` enum for this crate's errors (matched → mapped), - never `anyhow` for anything the UI presents. `B2_DEBUG` opts into developer detail. The **full** internal - detail is *always* logged server-side to stderr (`log_internal`, called from `CmdError`'s `Serialize` - impl — the one boundary every command error crosses to the webview), so a failed command is diagnosable - under `tauri dev` without `B2_DEBUG`: the log carries everything, the webview only the generic string. - (Root CLAUDE.md error policy + parent `Projects/CLAUDE.md` logging policy.) -- **Determinism unchanged.** Push no wall-clock or randomness into `b2-core`; timestamps come from the - façade clock (`now()` / `today()`), same as the CLI. -- **Chat provider injection, the embedder's sibling** (`src/chat.rs`, GH #155). `ask` picks a provider the - way the CLI's `open_llm` does — `B2_LLM=fake` forces the deterministic `FakeLlm`, else the configured - OpenAI-compatible endpoint — and resolution itself stays in `b2_llm::LlmConfig::from_env`, with this - host's Settings laid over it exactly as a CLI flag would be. Two differences from the embedder are - deliberate. **Chat carries no index identity** (contrast M2): nothing it produces is stored, so changing - the chat model costs no reindex, and the endpoint + model persist beside the remembered vault rather than - in the vault. And **the API key lives in the Keychain, under an env override** (GH #176, `src/keychain.rs`): - a **Cloud models** configuration (M5) needs a bearer token, and this host remembers one in the macOS - Keychain — encrypted at rest, ACL'd per application — rather than in a file. A secret B2 stores is a - secret B2 is responsible for; a plaintext file in Application Support is not where that responsibility - gets taken, and neither is `security add-generic-password -w`, which would put the token in `ps`. Three - rules follow, and each has a test. `B2_LLM_API_KEY` **wins** over the stored key, so a shell can point one - launch at another provider or decline to have B2 keep a secret at all. A store that refuses **must not - break chat**: the key stays in force for the run and the configuration reads `ApiKeySource::Session`, which - is the pre-#176 behavior as a fallback rather than a failure. And the key still never crosses back to the - webview: the status view carries `api_key_source`, never the key. - The **tool-call cap** (`LlmConfig::max_tool_calls`, ADR-0022) is the panel's fourth field, *Tool - calls per reply*. It persists in `chat.json` beside the endpoint and model and layers the way they - do — Settings beats `B2_LLM_MAX_TOOL_CALLS` beats the default — but it is **saved** under the key's - three-state rule (`chat::apply_tool_cap`: `None` untouched, blank clear, a value set), because most - callers of `set_chat_config` never mention it and must not reset it. This crate judges nothing: - the value is parsed by `b2_llm::parse_max_tool_calls`, the variable's own parser, and the range - the panel quotes and validates against (`toolCapInput`, `ui/src/chat.ts`) arrives in - `ChatSetup.tool_calls` rather than being mirrored in the frontend. -- **Structured logging installed here, like the CLI.** `logging::init_logging` (called first in `main`) - is the desktop's opt-in `B2_LOG`/`B2_DEBUG`/`B2_LOG_FILE` subscriber — the GUI sibling of the CLI's, same - JSONL shape (b2-core only emits; the subscriber + clock live in the adapter, keeping the core - wall-clock-free). One host-driven difference, documented in `logging.rs`: a `tracing-appender` - **non-blocking** writer (this process is long-lived + multi-threaded, so log I/O must not block the - GUI/reindex threads). The *implied* default filter — `b2=debug`, not bare `debug`, so foreign - `tracing`/`log` records stay out of the file — is now the rule in **both** adapters (GH #154). - `main` must hold the returned `WorkerGuard` for the whole run. - -## The keyboard contract (invariant K1) - -[invariants.md](../../docs/invariants.md) **K1** — *B2 is fully operable from the keyboard; the -mouse is an accelerator, never a requirement* — names this file as its elaboration home. This is it. -K1 governs the **GUI**: the `b2` CLI satisfies it by nature, so everything below is about -`b2-desktop` + [`ui/`](../../ui) ([#78](https://github.com/AlteredCraft/B2/issues/78)). - -**The rule, in one line: no action reachable only by pointer.** If a gesture exists only as a click, -a right-click, or a drag, it is a bug — not a missing nicety. - -### The four obligations - -Every new surface owes all four. They are cheap while you're building it and expensive to retrofit. - -1. **Reachable.** A focusable control in a sensible tab order, or a documented chord. A `
` you - attach a click handler to is a mouse-only control; make it a `