From 68f74febaf82ed45bd81d32a8dde4832895cc56a Mon Sep 17 00:00:00 2001 From: Sam Keen Date: Wed, 23 Sep 2026 08:38:55 -0700 Subject: [PATCH 1/3] docs: CLAUDE.md leads with the vision (a research-grade Obsidian replacement) and points to the docs for the rest CLAUDE.md read like the build-out phase: long on mechanics, silent on where B2 is going. It now opens with what B2 is for (my daily notes app, replacing Obsidian, built to be a better research tool), the two ideas to protect, and how work starts now (from `observed` issues after the 2026-09-23 backlog reset). The detail that duplicated the docs is replaced by pointers. The stale `Vault` API list is gone (vault.rs is the list). What only CLAUDE.md said moved into the docs first, so nothing is lost: - quickstart: `B2_LLM_MAX_TOOL_CALLS`; `B2_LOG_FILE` wants an absolute path under `make app`; `B2_DEBUG`/`B2_LOG_FILE` imply `b2=debug`, not `debug` (the code's actual default). - architecture: the Why? tool-using turn in flow 4; "Not yet built" (which still named the reranker as next) becomes "What comes next". 276 lines to 160. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 402 +++++++++++++++---------------------------- docs/architecture.md | 21 +-- docs/quickstart.md | 5 +- 3 files changed, 157 insertions(+), 271 deletions(-) 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/docs/architecture.md b/docs/architecture.md index 29e9327..fda0499 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -96,8 +96,9 @@ it, or a `[[link]]` you write in the body yourself. Details: chat pane stream an answer grounded in your notes: condense, retrieve (flow ② at 10 passages), assemble the grounded prompt, stream (cancellable at token granularity), cite (`[n]` markers resolve to path + excerpt). Chat is a reader: nothing model-derived is stored, -and swapping chat models is a config change, never a reindex. Details: -[index-engine.md §6](index-engine.md). +and swapping chat models is a config change, never a reindex. `b2 why` (the **Why?** on a +Similar card) is the one tool-using turn: the model calls B2's read-only tools, bounded in +rounds and in calls per reply (ADR-0022). Details: [index-engine.md §6](index-engine.md). ## The write discipline @@ -158,18 +159,18 @@ ADR-0018). Nothing is `#[ignore]`d: a check that genuinely needs the real model eval harness, which runs on demand and therefore actually runs. Model quality never flakes CI (ADR-0013); how it *is* measured is [evals.md](evals.md). -## Not yet built +## What comes next -What remains is tuning, scale, and packaging, tracked in -[GitHub Issues](https://github.com/AlteredCraft/B2/issues): +B2 is past its build-out: the engine, both adapters, and chat work end to end. What comes +next is using it every day, so new work starts from what that use shows, not from a roadmap. +An issue labelled `observed` in [GitHub Issues](https://github.com/AlteredCraft/B2/issues) +says what I was doing, what I expected, and what happened. Ideas that were designed but never needed (a reranker, resource +content search, scaling levers) stay closed, with their write-ups, until real use asks for +them. Two things stay true whatever comes next: - Semantic quality in CI: never. The engine suite proves plumbing on the fake embedder; the real model is measured by the out-of-CI harness ([evals.md](evals.md)). -- A cross-encoder reranker is the likely next seam: post-fusion, it changes ordering, not the - store, gated on the eval like everything else. Query expansion sits behind it in priority. -- Resource content search: resources are inventoried and are graph targets today; chunking - and embedding them is designed, not shipped ([data-model.md §10](data-model.md)). -- Packaging and distribution: B2 ships as source today. +- Any new AI part arrives behind a seam, measured before it ships. Source of truth for every claim: `crates/*/src/` and the test suite under each crate's `tests/`. The spec is this folder; the *why* is [ADRs/](../ADRs/README.md); the backlog is diff --git a/docs/quickstart.md b/docs/quickstart.md index a0f3ba0..c16fa37 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -456,9 +456,10 @@ they are, `search` refuses rather than mixing embedding spaces. | `B2_LLM_URL` / `B2_LLM_MODEL` | The OpenAI-compatible chat endpoint + model for `ask`/`chat` (defaults: `http://localhost:11434/v1`, Ollama's, and `llama3.2`). The `--llm-url`/`--llm-model` flags beat the env, which beats the default | | `B2_LLM_API_KEY` | Bearer token for a cloud chat endpoint. An env var, never a flag, because a key in a flag is a key in `ps`. The desktop stores its key in the macOS Keychain instead | | `B2_LLM=fake` | The deterministic chat provider: `B2_EMBEDDER=fake`'s sibling for `ask`/`chat` | +| `B2_LLM_MAX_TOOL_CALLS` | The most tool calls one model reply may carry in `b2 why` (default 64, allowed 1 to 4096). A reply over the cap fails with its own message rather than being cut short. An invalid value keeps the default and logs a warning. The desktop's Settings → Chat field overrides it | | `B2_DEBUG` | Print internal error detail after the generic user-facing message | -| `B2_LOG` | Structured debug logging: JSON Lines on stderr (stdout stays pure data), ready for jq/DuckDB/pandas. Takes a tracing filter (`debug`, `b2::sqlite=debug`, `warn`). Includes per-statement SQLite timings; `B2_DEBUG` or `B2_LOG_FILE` alone implies `B2_LOG=debug` | -| `B2_LOG_FILE` | Write the structured log to this file instead of stderr (append mode, so runs accumulate into one dataset) | +| `B2_LOG` | Structured debug logging: JSON Lines on stderr (stdout stays pure data), ready for jq/DuckDB/pandas. Takes a tracing filter (`debug`, `b2::sqlite=debug`, `warn`). Includes per-statement SQLite timings; `B2_DEBUG` or `B2_LOG_FILE` alone implies `b2=debug` (B2's own targets only, so Tauri and HTTP records stay out) | +| `B2_LOG_FILE` | Write the structured log to this file instead of stderr (append mode, so runs accumulate into one dataset). Use an absolute path with `make app`: it runs from `crates/b2-desktop/`, so a relative path lands there | | `B2_SLOW_QUERY_MS` | Slow-query threshold in milliseconds (default 100): statements at or over it log at WARN with `slow=true` | Honest about limits: search snippets and scores come from the index, so reindex first. From 26ac0bc7f7510e9f0514409ccae643476ffe15c0 Mon Sep 17 00:00:00 2001 From: Sam Keen Date: Wed, 23 Sep 2026 08:45:06 -0700 Subject: [PATCH 2/3] docs: README leads with what B2 is for (a notes app for research) and what it does today The README opened with a 35-line status block from the build-out (steps 0 to 5, "the chat pane is next", "next: file-type support") and Obsidian-style frontmatter that GitHub renders as a table. It now reads top to bottom as: what B2 is for, what it does, what it promises, status (use-driven, #23 for an installable build), get started, how it's built, docs, develop. Setup detail it dropped moved to the quick start first: the Node version floor and Tauri CLI install, and the Metal-vs-CPU note for `make app`. Co-Authored-By: Claude Opus 5.5 --- README.md | 254 +++++++++++++++++---------------------------- docs/quickstart.md | 12 ++- 2 files changed, 105 insertions(+), 161 deletions(-) diff --git a/README.md b/README.md index f1b40ca..6df0de0 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 today (developed on +macOS); 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/docs/quickstart.md b/docs/quickstart.md index c16fa37..b328788 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -13,8 +13,10 @@ commit a typed link). Indexing writes nothing to your notes. Your prose is never You need a Rust toolchain ([rustup.rs](https://rustup.rs)) to build the `b2` binary. B2 ships as source today, as one static binary. macOS and Linux are the tested platforms. For the -desktop app (its own section near the end), also install Node + npm and the Tauri CLI. Run -`make doctor` in the checkout: it checks all of this and prints the fix for anything missing. +desktop app (its own section near the end), also install Node + npm (Node 18, 20, or 22+; +`nvm install --lts` is the easy way) and the Tauri CLI (`cargo install tauri-cli --locked`). +Run `make doctor` in the checkout: it checks all of this and prints the fix for anything +missing. B2 needs no account and no API key. It touches the network only when you ask it to: the one-time model download below, and chat against a remote endpoint if you configure one (the @@ -374,6 +376,12 @@ On first launch with nothing remembered, the window opens with no vault selected vault switcher and pick a folder. After that it reopens whatever you had open last, and `B2_VAULT_PATH` is just a way to skip that first pick. +On Apple Silicon, `make app` embeds on the Metal GPU (about 7× faster than CPU on the test +vault, [GH #40](https://github.com/AlteredCraft/B2/issues/40)) and falls back to CPU if the +GPU can't start; `make app-cpu` forces CPU. Metal is chosen when the app is built, and CPU and +Metal produce different vectors, so switching re-embeds the vault once on the next reindex. +`make compare-device` benchmarks the two on your hardware. + What the window adds over the terminal: - **You never run reindex.** It indexes the vault on open, and a native fs-watch keeps up From e364ff2f4f238f4917cc6832c1a1be4e99c8faa6 Mon Sep 17 00:00:00 2001 From: Sam Keen Date: Wed, 23 Sep 2026 08:50:43 -0700 Subject: [PATCH 3/3] docs: the desktop CLAUDE.md gets the same pass; docs intros and platform wording align MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crates/b2-desktop/CLAUDE.md (453 to 133 lines) now opens with what the app is for: the daily place B2 is used, a good editor first and a research tool beside it, where most `observed` issues will land. The rules stay (dumb adapter, K1's four obligations, E5's single sanitizing path, drag and drop), stated once each. Per-module reasoning is left to each module's header comment, which already carries it. The traps that are not written down anywhere else stay here: repaint destroys focus, `:focus` vs `:focus-visible`, WebKit not focusing a clicked button, menu accelerators never reaching the webview (and ⌘⇧= not zooming), and the memoized overlay layer. The pointer to the closed `serve` issue (#24) is gone. docs/README.md and docs/architecture.md open with the research framing the root README now uses. The quick start claimed macOS and Linux are tested; CI runs only on macOS, so it and the README now say macOS is the one tested platform. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- crates/b2-desktop/CLAUDE.md | 566 ++++++++---------------------------- docs/README.md | 7 +- docs/architecture.md | 11 +- docs/quickstart.md | 3 +- 5 files changed, 137 insertions(+), 454 deletions(-) diff --git a/README.md b/README.md index 6df0de0..9814bab 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ common, how they bear on each other, and what they actually say. 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 today (developed on -macOS); an installable build is [#23](https://github.com/AlteredCraft/B2/issues/23). +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 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 `