Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f56854b
fix(ui): chord hints follow a rebind
claude Sep 26, 2026
ec77833
refactor(ui): drop dead code
claude Sep 26, 2026
a423f59
refactor(ui): one helper per repeated state transition in main.ts
claude Sep 26, 2026
e30455d
refactor(ui): one tree/path helper per rule
claude Sep 26, 2026
ac26f5d
refactor(ui): one coverage classifier behind the five honest surfaces
claude Sep 26, 2026
21f9021
refactor(llm,embed): own the fake switches, the wiring rule and the s…
claude Sep 26, 2026
6442735
refactor(core): split the Vault façade into views, flow ④ and snippets
claude Sep 26, 2026
7f0b078
refactor(ui): split wireEvents, tabulate the context menu, pull find …
claude Sep 26, 2026
1e24a76
refactor(cli): split main.rs into domain modules
claude Sep 26, 2026
d7a5785
refactor(examples): share the eval harness's helpers in examples/common
claude Sep 26, 2026
240c877
fix(eval): refuse unknown flags instead of silently ignoring them
claude Sep 26, 2026
d4b1960
refactor(ui): split render.ts by surface, and share its repeated markup
claude Sep 26, 2026
ffd865a
refactor(cli): wire through the shared rules, say every error precise…
claude Sep 26, 2026
3e41f45
refactor(core): drop ingest results nothing reads, share discovery's …
claude Sep 26, 2026
03da739
refactor(core): one link grammar, one move pipeline, one delete pipeline
claude Sep 26, 2026
12d531c
refactor(core): drop fields nothing reads
claude Sep 26, 2026
f636575
fix(core): add refuses an occupied path in the create itself
claude Sep 26, 2026
d6f6d5e
refactor(core): one passage layout, a non-panicking fake embedder, cu…
claude Sep 26, 2026
e948b99
refactor(ui): name main.ts's small repeated steps; theme and reminder…
claude Sep 26, 2026
848e962
docs(ui): fix stale comments in state.ts and api.ts
claude Sep 26, 2026
61259c5
Merge branch 'ui cleanup' into claude/tech-debt-cleanup-y3s69c
claude Sep 26, 2026
d43544b
docs(desktop): renderMarkdown now lives in ui/src/markdown.ts
claude Sep 26, 2026
3d7f2dd
refactor(desktop): one Slot for both long ops, one-line commands, sha…
claude Sep 26, 2026
e9e1265
refactor(eval): split eval.rs into eval/ modules along its report blocks
claude Sep 26, 2026
16a71c7
test(core): shared fixtures and callbacks come from tests/common
claude Sep 26, 2026
76be7f1
refactor(core): a folder move sorts its note set rather than trust th…
claude Sep 26, 2026
f161c11
Merge branch 'adapters, llm and embed cleanup' into claude/tech-debt-…
claude Sep 26, 2026
989ee67
Merge branch 'core move/delete/links cleanup' into claude/tech-debt-c…
claude Sep 26, 2026
4eb86a1
refactor(core): wire the shared link/path helpers through the façade;…
claude Sep 26, 2026
50d805c
refactor(core)!: project only the note columns something reads (schem…
claude Sep 26, 2026
8a5b232
fix(ui): a moved image re-points state before its bytes load; comment…
claude Sep 26, 2026
cc5a67c
fix: restore the Metal-fallback notice; mv's docs and tests say what …
claude Sep 26, 2026
e51a12b
refactor(eval): load the model once per run
claude Sep 26, 2026
9f4e25f
Merge branch 'eval harness cleanup' into claude/tech-debt-cleanup-y3s69c
claude Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 257 additions & 0 deletions crates/b2-cli/src/args.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
//! The command line itself: the clap definitions, the agent help text, and the rule
//! for which vault root a command runs against.

use crate::error::CliError;
use clap::{Args, Parser, Subcommand};
use std::path::{Path, PathBuf};

/// The retrieval loop, stated where an agent will actually read it (`b2 --help`) —
/// the toolset carrying its own usage instructions, the way an MCP server ships its
/// tool descriptions. The loop itself lives in the *caller*: B2's part is dumb,
/// composable reads (ADR-0012), and the vault being plain Markdown on disk is what
/// makes "open the file yourself" a step no B2 command needs to exist for.
pub const AGENT_HELP: &str = "\
For agents (--json):
Every command takes --json, and the vault is plain Markdown on disk — a result's
`path` is a real file, readable and greppable directly. The retrieval loop:

1. b2 search \"<query>\" --json Hybrid keyword+semantic search: the ranked rows
plus an evidence verdict. `\"vouched\": false`
means the vault holds no evidence for this query
— report \"no matches\" instead of using the rows.
2. Read the files it names Paths are vault-relative; open them for the full
note, not just the matched snippet.
3. b2 neighbors/explain <note> Follow the typed graph around a promising note;
b2 similar <note> ranks unlinked semantic
neighbors.
4. Refine and search again Pass --exclude <path> (repeatable) for notes
already inspected, so follow-ups surface fresh
material. Fewer results than asked = the head of
the ranking is spent; refine the query.";

#[derive(Parser)]
#[command(
name = "b2",
version,
about = "B2 — explore a Markdown vault's typed graph and search from the terminal",
after_long_help = AGENT_HELP
)]
pub struct Cli {
/// Vault root (the folder of Markdown). The index lives in `<vault>/.b2/`.
/// Set it with `-C <path>` or `$B2_VAULT_PATH` (the flag wins). Read-only commands
/// fall back to the current dir; commands that write (`reindex`/`add`/`write`/`mv`/
/// `rm`/`link`) require it explicitly, so they can never silently touch the wrong
/// directory.
#[arg(short = 'C', long = "vault", global = true, env = "B2_VAULT_PATH")]
pub vault: Option<PathBuf>,

/// Emit machine-readable JSON instead of human-readable text.
#[arg(long, global = true)]
pub json: bool,

#[command(subcommand)]
pub command: Command,
}

#[derive(Subcommand)]
pub enum Command {
/// Download + verify the embedding model into the shared cache (one-time setup).
Init,
/// Re-project every note under the vault into the index. Reads your vault and
/// writes only to `.b2/`. Incremental by default: notes whose content is
/// unchanged keep their vectors.
Reindex {
/// Vault root (overrides --vault / $B2_VAULT_PATH). Required — no cwd default.
vault: Option<PathBuf>,
/// Re-embed every note, even unchanged ones (a full rebuild in place).
#[arg(long)]
force: bool,
/// Preview what a reindex would do without touching the index — how many
/// notes it would project and embed.
#[arg(long)]
dry_run: bool,
/// Stop a reindex already running on this vault — the way to reach one
/// backgrounded with `b2 reindex &`, which has no controlling terminal for
/// Ctrl-C. It stops after the current batch, leaving the same consistent,
/// re-runnable partial index a foreground Ctrl-C does.
#[arg(long, conflicts_with_all = ["force", "dry_run"])]
cancel: bool,
},
/// Report embedding coverage — how many notes are embedded (so semantic ranking
/// is live vs. keyword-only) and whether a reindex is currently running, with the
/// process id to stop it by. A pure, model-free read; handy after kicking off a
/// slow reindex in the background with `b2 reindex &`.
Status,
/// Create a new note and project it into the index (it's immediately in the
/// graph + searchable). PATH is vault-relative (the `.md` extension is optional).
Add {
/// Where the new note goes: a vault-relative path (`.md` optional).
path: String,
/// The note's human title (frontmatter `title:`).
#[arg(long)]
title: Option<String>,
/// Initial body content (Markdown). Omit for an empty note to fill in later.
#[arg(long)]
content: Option<String>,
},
/// Replace a note's **body** with Markdown piped on stdin — the CLI/agent editing
/// surface (the counterpart to the desktop editor's save). NOTE is a vault-relative
/// path; the note must already exist (`b2 add` creates one). Pipe the body
/// **alone** — the frontmatter (including B2's managed `b2_relations:`) is
/// left untouched, so no `---` block. The note is re-projected (chunks + FTS + graph)
/// so the index stays live; a later `b2 reindex` fills the changed body's vectors.
Write {
/// The note whose body to overwrite: a vault-relative path.
note: String,
},
/// Show a note's typed neighbors. NOTE is a vault-relative path.
Neighbors { note: String },
/// Explain a note's connections — every typed edge and its "why". NOTE is a
/// vault-relative path.
Explain { note: String },
/// Move/rename a note, file, or folder and rewrite every inbound link to
/// point at the new path. An existing directory moves as a whole folder
/// (everything under it, one rename).
Mv {
/// What to move: a vault-relative path (note, file, or folder).
from: String,
/// The new vault-relative path (for a note, the `.md` extension is optional).
to: String,
},
/// Delete a note, file, or folder from the vault *and* the disk. Inbound links
/// are never rewritten — they dangle and surface as unresolved (`b2 explain`).
/// An existing directory deletes as a whole folder and requires --recursive.
Rm {
/// What to delete: a vault-relative path (note, file, or folder).
target: String,
/// Required to delete a folder (and everything inside it, unindexed files too).
#[arg(short = 'r', long)]
recursive: bool,
},
/// Hybrid keyword+semantic search across the vault. A query the vault
/// holds no evidence for answers "no matches" rather than serving its nearest
/// rows (invariants.md D2); `--json` is an **object** — the rows plus that
/// verdict, which has nowhere to live in a bare list.
Search {
query: String,
/// Maximum number of notes to return.
#[arg(long, default_value_t = 10)]
limit: usize,
/// Leave a note out of the results: a vault-relative path exactly as a
/// previous search served it (repeatable). The follow-up-search flag for
/// agent loops — pass the notes already inspected so a re-query surfaces
/// fresh material instead of the same head. It subtracts rows only: the
/// evidence verdict still reads the whole vault, and a heavily-excluded
/// query may serve fewer than LIMIT notes — the cue to refine the query
/// rather than page deeper.
#[arg(long, value_name = "PATH")]
exclude: Vec<String>,
},
/// Surface the notes most semantically similar to NOTE that you haven't linked
/// yet — connection discovery, ranked nearest first. NOTE is a vault-relative
/// path. A local read over stored vectors (run `b2 reindex` with the real
/// model first). Similarity is relative to your vault: the list is always
/// the ranked nearest, and you are the judge of which are worth a link.
Similar {
/// The note to find similar notes for: a vault-relative path.
note: String,
/// Maximum number of similar notes to return.
#[arg(long, default_value_t = 10)]
limit: usize,
/// Explain one note against NOTE's list instead of printing the list: where it
/// stands (and why, if it is not a card), and the passage pairs behind it. No
/// model call. Read at `--limit`, so the rank is the one the list shows.
#[arg(long, value_name = "OTHER")]
explain: Option<String>,
},
/// Commit a typed connection SRC → DST into SRC's frontmatter `b2_relations:`.
/// SRC and DST are each a vault-relative path.
Link {
/// The source note (the edge points *from* it): a vault-relative path.
src: String,
/// The target note (the edge points *to* it): a vault-relative path.
dst: String,
/// The relation verb (a core verb: references/supports/contradicts).
#[arg(long = "type", default_value = "references")]
edge_type: String,
/// Optional explanation — trailing text shown after the link.
#[arg(long)]
explanation: Option<String>,
},
/// Ask one question about your vault and stream a grounded answer. The model
/// answers **only** from passages retrieved out of your notes and cites them by
/// `[n]`; `--json` emits the answer as a JSON Lines event stream (one token
/// event per token, then the final answer with its citations resolved).
/// Needs a model server — Ollama by default (see `--llm-url`).
Ask {
/// The question, in plain language.
question: String,
#[command(flatten)]
llm: LlmArgs,
},
/// Explain why CANDIDATE shows up in `b2 similar NOTE` — a grounded, cited answer
/// streamed from the chat model, which is given B2's read-only tools (the matched
/// passages, the similar list, a note's links, reading a note) and looks up what it
/// needs. The answer lists the tools that ran. A model with no tool support is handed
/// the matched passages instead. `--json` streams events as `ask` does.
/// Needs a model server — Ollama by default (see `--llm-url`).
Why {
/// The note the list was for: a vault-relative path.
note: String,
/// The suggested note to explain: a vault-relative path.
candidate: String,
/// The length of the `similar` list the rank is quoted against.
#[arg(long, default_value_t = 10)]
limit: usize,
#[command(flatten)]
llm: LlmArgs,
},
/// Interactive grounded chat about your vault — the same answers as `ask`, with
/// follow-up questions that remember the conversation. Ctrl-C stops an answer
/// mid-stream (the partial text stands); `/exit` or Ctrl-D leaves. History is
/// session-only: nothing about a chat is ever written to your notes or the index.
Chat {
#[command(flatten)]
llm: LlmArgs,
},
}

/// Which model to chat with, and where it lives — shared by `ask` and `chat`. Precedence
/// is the `B2_VAULT_PATH` convention exactly: an explicit flag beats the environment beats
/// the default. Resolution lives once in `b2_llm::LlmConfig`, so the desktop's settings
/// layer over the same base — which is why these are plain options, not clap `env` args.
#[derive(Args, Debug)]
pub struct LlmArgs {
/// The OpenAI-compatible base URL of your model server
/// [env: B2_LLM_URL, default: http://localhost:11434/v1 — Ollama's].
/// Any compatible endpoint works: LM Studio, llama.cpp, vLLM, or a cloud
/// provider's (which sends your question and the retrieved passages to them —
/// pair it with B2_LLM_API_KEY).
#[arg(long = "llm-url", value_name = "URL")]
pub llm_url: Option<String>,
/// The chat model id, as the server names it
/// [env: B2_LLM_MODEL, default: llama3.2]. Pull it first, e.g. `ollama pull
/// llama3.2`.
#[arg(long = "llm-model", value_name = "MODEL")]
pub llm_model: Option<String>,
}

impl Cli {
/// The vault root for **read-only** commands (`search`, `neighbors`, `explain`,
/// `similar`): the `-C`/`$B2_VAULT_PATH` value if given, else the current directory.
/// A pure read can't pollute anything, so the cwd convenience is safe here.
pub fn vault_or_cwd(&self) -> &Path {
self.vault.as_deref().unwrap_or_else(|| Path::new("."))
}

/// The vault root for commands that **write** (`reindex`, `add`, `write`, `mv`, `rm`,
/// `link`): `positional` wins, then `-C`/`$B2_VAULT_PATH`; with none, error rather than
/// silently mutating the current directory, where a stale binary or mistyped var would
/// otherwise leave a stray `.b2/`. The write-side counterpart to
/// [`vault_or_cwd`](Self::vault_or_cwd).
pub fn require_vault<'a>(&'a self, positional: Option<&'a Path>) -> Result<&'a Path, CliError> {
positional
.or(self.vault.as_deref())
.ok_or(CliError::VaultRequired)
}
}
65 changes: 65 additions & 0 deletions crates/b2-cli/src/cancel.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//! Ctrl-C as a cooperative cancel: the flags a SIGINT handler flips, read by the two long
//! loops a command can stop part-way — the reindex embed loop and the chat token stream.

use std::ops::ControlFlow;
use std::sync::atomic::{AtomicBool, Ordering};

/// Set by the SIGINT handler installed for a long-running command — by Ctrl-C in the
/// foreground, or (for a `reindex`) by another process's `b2 reindex --cancel` (GH #55),
/// which raises the *same* signal so both reach here.
///
/// Two loops read it through the same [`ControlFlow`] seam: the reindex embed loop at
/// every batch boundary, stopping *after* the current batch so a cancel leaves a
/// consistent, re-runnable index; and the chat surfaces' token callback at every token,
/// where stopping renders the partial answer honestly. `chat` clears it before each turn.
static CANCEL: AtomicBool = AtomicBool::new(false);

/// Whether `b2 chat` is streaming an answer right now — which is what decides
/// what Ctrl-C *means* in the REPL: cancel this answer, or leave. Only `chat`
/// maintains it ([`while_answering`]); every other command's SIGINT meaning is
/// unconditional.
static ANSWERING: AtomicBool = AtomicBool::new(false);

/// Route Ctrl-C to [`CANCEL`] instead of killing the process, so a long loop stops at its
/// next checkpoint and what it already did stands.
///
/// `exit_when_idle` is for a REPL, where Ctrl-C means two things and a handler that only
/// ever meant one would trap the user: mid-answer it cancels the stream, but at an idle
/// prompt it must still be the way out, since swallowing it would leave `/exit` and
/// Ctrl-D as the only exits. `ctrlc` runs the handler on its own thread.
///
/// Best-effort: if the handler can't be installed, Ctrl-C keeps its default (terminate),
/// which is still safe — a reindex writes edges and FTS before any vectors, and a chat
/// stores nothing.
pub fn install_cancel_on_sigint(exit_when_idle: bool) {
let _ = ctrlc::set_handler(move || {
if exit_when_idle && !ANSWERING.load(Ordering::SeqCst) {
// 128 + SIGINT, the shell's own convention for "interrupted".
std::process::exit(130);
}
CANCEL.store(true, Ordering::SeqCst);
});
}

/// Run one REPL answer with Ctrl-C meaning "stop this answer". A fresh cancel budget per
/// turn: the Ctrl-C that stopped the *last* answer must not cancel this one before its
/// first token.
pub fn while_answering<T>(answer: impl FnOnce() -> T) -> T {
CANCEL.store(false, Ordering::SeqCst);
ANSWERING.store(true, Ordering::SeqCst);
let result = answer();
ANSWERING.store(false, Ordering::SeqCst);
result
}

/// Map the Ctrl-C flag onto the cooperative-cancel signal the engine's loops read —
/// [`ControlFlow::Break`] once a cancel has been requested, else
/// [`ControlFlow::Continue`]. Shared by `reindex`'s two progress closures and by the
/// chat surfaces' token callback.
pub fn cancel_flow() -> ControlFlow<()> {
if CANCEL.load(Ordering::SeqCst) {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
}
Loading