From 80fb218adfd892ba163a0dc126c029c4d5532fd8 Mon Sep 17 00:00:00 2001 From: Sam Keen Date: Sun, 8 Nov 2026 12:41:13 -0800 Subject: [PATCH 1/3] Chat knows which note is open: a focus note for ask and chat "Clean up this doc" got "I don't find that in your notes" because ask retrieved by the question text alone. Vault::ask now takes an optional focus note. Its first FOCUS_PASSAGES (8) passages are numbered first, retrieval at ASK_PASSAGES fills in behind them minus repeats, and the grounded and condense prompts both say the user is viewing that path. A note longer than the cap adds one line saying how many of its passages were included. With no focus the request is unchanged, pinned by a recording-provider test against the request spelled out field by field. - CLI: `b2 ask --note PATH` and `b2 chat --note PATH`; --json unchanged. - Desktop: the ask command takes `focus`; the pane sends the open note's path and shows an "about: " chip by the composer. - Docs: quickstart documents --note; index-engine flow 4 and the constants table gain the focus step and FOCUS_PASSAGES. Fixes #253 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- crates/b2-cli/src/args.rs | 7 + crates/b2-cli/src/chat.rs | 22 +- crates/b2-cli/src/main.rs | 8 +- crates/b2-cli/tests/cli.rs | 29 +++ crates/b2-core/src/chat.rs | 59 ++++- crates/b2-core/src/vault/chat.rs | 57 ++++- crates/b2-core/tests/ask.rs | 284 ++++++++++++++++++++++++- crates/b2-desktop/src/commands.rs | 51 ++++- crates/b2-llm/examples/groundedness.rs | 2 +- crates/b2-llm/src/provider.rs | 3 +- crates/b2-llm/tests/wire.rs | 3 +- docs/index-engine.md | 6 +- docs/quickstart.md | 17 +- ui/src/api.ts | 6 +- ui/src/chat.test.ts | 15 ++ ui/src/chat.ts | 24 +++ ui/src/chatview.ts | 10 + ui/src/main.ts | 4 +- ui/style.css | 13 ++ 19 files changed, 575 insertions(+), 45 deletions(-) diff --git a/crates/b2-cli/src/args.rs b/crates/b2-cli/src/args.rs index ed04f5b..11cf305 100644 --- a/crates/b2-cli/src/args.rs +++ b/crates/b2-cli/src/args.rs @@ -186,6 +186,10 @@ pub enum Command { Ask { /// The question, in plain language. question: String, + /// The note you are asking about: a vault-relative path. Its passages are + /// numbered first and the model is told "this note" means it. + #[arg(long, value_name = "PATH")] + note: Option<String>, #[command(flatten)] llm: LlmArgs, }, @@ -211,6 +215,9 @@ pub enum Command { /// 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 { + /// The note the conversation is about, as `ask --note`, for every turn. + #[arg(long, value_name = "PATH")] + note: Option<String>, #[command(flatten)] llm: LlmArgs, }, diff --git a/crates/b2-cli/src/chat.rs b/crates/b2-cli/src/chat.rs index 225b203..fed4419 100644 --- a/crates/b2-cli/src/chat.rs +++ b/crates/b2-cli/src/chat.rs @@ -10,14 +10,19 @@ use b2_core::vault::{AnswerView, Vault}; use std::io::{IsTerminal, Write}; use std::ops::ControlFlow; -pub fn cmd_ask(cli: &Cli, question: &str, llm_args: &LlmArgs) -> Result<(), CliError> { +pub fn cmd_ask( + cli: &Cli, + question: &str, + focus: Option<&str>, + llm_args: &LlmArgs, +) -> Result<(), CliError> { // The provider first: a stopped server should cost a round trip, not a model load. let llm = open_llm(llm_args)?; // Retrieval embeds the question, so the real model. let vault = open_vault(cli.vault_or_cwd(), true)?; // Ctrl-C cancels at the next token; what streamed stays, reported as partial. install_cancel_on_sigint(false); - ask_streamed(&vault, llm.as_ref(), question, &[], cli.json)?; + ask_streamed(&vault, llm.as_ref(), question, &[], focus, cli.json)?; if !cli.json { note_fake_llm(); } @@ -46,7 +51,7 @@ pub fn cmd_why( Ok(()) } -pub fn cmd_chat(cli: &Cli, llm_args: &LlmArgs) -> Result<(), CliError> { +pub fn cmd_chat(cli: &Cli, focus: Option<&str>, llm_args: &LlmArgs) -> Result<(), CliError> { let llm = open_llm(llm_args)?; let vault = open_vault(cli.vault_or_cwd(), true)?; // Mid-answer Ctrl-C cancels the stream; at an idle prompt it leaves. @@ -63,6 +68,9 @@ pub fn cmd_chat(cli: &Cli, llm_args: &LlmArgs) -> Result<(), CliError> { Nothing here is saved.", llm.model_id() ); + if let Some(note) = focus { + eprintln!("About: {note}. \"This note\" in a question means it."); + } } if !cli.json { note_fake_llm(); @@ -86,8 +94,9 @@ pub fn cmd_chat(cli: &Cli, llm_args: &LlmArgs) -> Result<(), CliError> { if matches!(question, "/exit" | "/quit") { break; } - let turn = - while_answering(|| ask_streamed(&vault, llm.as_ref(), question, &history, cli.json)); + let turn = while_answering(|| { + ask_streamed(&vault, llm.as_ref(), question, &history, focus, cli.json) + }); match turn { Ok(answer) => { // A cancelled answer is kept too: a follow-up refers to what was shown. @@ -108,9 +117,10 @@ fn ask_streamed( llm: &dyn LlmProvider, question: &str, history: &[ChatTurn], + focus: Option<&str>, json: bool, ) -> Result<AnswerView, CliError> { - let answer = vault.ask(llm, question, history, &mut |token| { + let answer = vault.ask(llm, question, history, focus, &mut |token| { stream_token(token, json) })?; finish_answer(&answer, json); diff --git a/crates/b2-cli/src/main.rs b/crates/b2-cli/src/main.rs index 180eb9d..6d711f9 100644 --- a/crates/b2-cli/src/main.rs +++ b/crates/b2-cli/src/main.rs @@ -78,14 +78,18 @@ fn dispatch(cli: &Cli) -> Result<(), CliError> { edge_type, explanation, } => cmd_link(cli, src, dst, edge_type, explanation.as_deref()), - Command::Ask { question, llm } => cmd_ask(cli, question, llm), + Command::Ask { + question, + note, + llm, + } => cmd_ask(cli, question, note.as_deref(), llm), Command::Why { note, candidate, limit, llm, } => cmd_why(cli, note, candidate, *limit, llm), - Command::Chat { llm } => cmd_chat(cli, llm), + Command::Chat { note, llm } => cmd_chat(cli, note.as_deref(), llm), } } diff --git a/crates/b2-cli/tests/cli.rs b/crates/b2-cli/tests/cli.rs index 672977b..2cf74c7 100644 --- a/crates/b2-cli/tests/cli.rs +++ b/crates/b2-cli/tests/cli.rs @@ -1473,6 +1473,35 @@ fn ask_json_streams_tokens_then_the_resolved_answer() { } } +/// `ask --note` leads with the named note: the fake cites every passage in order, so the +/// first citation is passage 1. The question alone ranks spaced-repetition.md first. +#[test] +fn ask_note_json_cites_the_focus_note_first() { + let (_g, root) = reindexed(); + let focus = "concepts/memory.md"; + + let out = run_chat( + &root, + &["--json", "ask", "--note", focus, "what is memory?"], + None, + ); + assert!(out.status.success(), "{}", stderr(&out)); + let events = events(&out); + let last = events.last().expect("the answer event"); + assert_eq!(last["event"], "answer"); + let first = &last["citations"][0]; + assert_eq!(first["marker"], 1); + assert_eq!(first["path"], focus, "{last}"); + + let out = run_chat( + &root, + &["ask", "--note", "nope.md", "what is memory?"], + None, + ); + assert!(!out.status.success()); + assert!(stderr(&out).contains("nope.md"), "{}", stderr(&out)); +} + /// `why` streams like `ask`, with citations confined to the two notes. #[test] fn why_json_streams_an_explanation_cited_to_the_two_notes() { diff --git a/crates/b2-core/src/chat.rs b/crates/b2-core/src/chat.rs index 215538e..690985e 100644 --- a/crates/b2-core/src/chat.rs +++ b/crates/b2-core/src/chat.rs @@ -17,6 +17,10 @@ use std::ops::ControlFlow; /// so chat adds no new candidate width (GH #141/#142). pub const ASK_PASSAGES: usize = 10; +/// How many of the focus note's own passages lead an ask, in order (GH #253). Retrieval at +/// [`ASK_PASSAGES`] follows them, so a long note can't crowd out the rest of the vault. +pub const FOCUS_PASSAGES: usize = 8; + /// The grounded system prompt (flow ④ step 2). Its no-evidence sentence must stay /// [`crate::llm::NO_EVIDENCE_ANSWER`] verbatim; the suite pins it. pub const GROUNDED_SYSTEM_PROMPT: &str = "You are B2, answering questions about the user's own \ @@ -30,13 +34,52 @@ pub const CONDENSE_SYSTEM_PROMPT: &str = "Rewrite the user's latest message as o search query that names its subject explicitly, using the earlier conversation to fill in \ any pronouns or references. Reply with the query text only — no preamble, no quotes."; +/// The note the user is viewing when they ask (GH #253), and how much of it the request +/// carries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Focus { + /// Vault-relative path, the note's identity (L1). + pub path: String, + /// How many of its passages lead the request: at most [`FOCUS_PASSAGES`]. + pub included: usize, + /// How many passages the note has. + pub total: usize, +} + +/// The sentence that gives "this note" a referent, shared by condense and answer. +fn focus_sentence(path: &str) -> String { + format!("The user is viewing {path}; \"this note\" or \"this doc\" means it.") +} + +/// The focus lines of the grounded prompt; a trimmed note says so (limits are loud). +fn focus_lines(focus: &Focus) -> String { + let sentence = focus_sentence(&focus.path); + if focus.included < focus.total { + format!( + "{sentence}\nOnly the first {} of its {} passages are included.", + focus.included, focus.total + ) + } else { + sentence + } +} + /// Step 0: condense a follow-up into a standalone retrieval query (multi-turn only). /// Never breaks chat: any failure or blank reply degrades to the raw question. Nothing -/// streams up. -pub fn condense_query(llm: &dyn LlmProvider, question: &str, history: &[ChatTurn]) -> String { +/// streams up. `focus` is the open note's path, so "this doc" condenses to its subject. +pub fn condense_query( + llm: &dyn LlmProvider, + question: &str, + history: &[ChatTurn], + focus: Option<&str>, +) -> String { + let system = match focus { + Some(path) => format!("{CONDENSE_SYSTEM_PROMPT}\n\n{}", focus_sentence(path)), + None => CONDENSE_SYSTEM_PROMPT.to_string(), + }; let req = ChatRequest { kind: RequestKind::Condense, - system: CONDENSE_SYSTEM_PROMPT.to_string(), + system, turns: turns_with_question(history, question), passages: Vec::new(), tools: Vec::new(), @@ -62,15 +105,21 @@ pub fn condense_query(llm: &dyn LlmProvider, question: &str, history: &[ChatTurn } } -/// Step 2: assemble the grounded chat request. +/// Step 2: assemble the grounded chat request. With a `focus`, its passages are expected +/// to lead `passages`. pub fn build_request( question: &str, history: &[ChatTurn], passages: Vec<ContextPassage>, + focus: Option<&Focus>, ) -> ChatRequest { + let system = match focus { + Some(f) => format!("{GROUNDED_SYSTEM_PROMPT}\n\n{}", focus_lines(f)), + None => GROUNDED_SYSTEM_PROMPT.to_string(), + }; ChatRequest { kind: RequestKind::Chat, - system: GROUNDED_SYSTEM_PROMPT.to_string(), + system, turns: turns_with_question(history, question), passages, tools: Vec::new(), diff --git a/crates/b2-core/src/vault/chat.rs b/crates/b2-core/src/vault/chat.rs index b9cbc73..67f6139 100644 --- a/crates/b2-core/src/vault/chat.rs +++ b/crates/b2-core/src/vault/chat.rs @@ -18,26 +18,45 @@ impl Vault { /// A failed condense falls back to the raw question. `on_token` returning `Break` /// cancels. A hallucinated `[n]` resolves to nothing; the text is never rewritten. /// A failed answer call is [`Error::Llm`](crate::Error::Llm). + /// + /// `focus` is the note the user is viewing (GH #253): its first + /// [`chat::FOCUS_PASSAGES`] passages are numbered first and the prompt names it, so + /// "this doc" has a referent. An unknown ref is [`Error::NoteNotFound`], before any + /// model call. `None` sends exactly the unfocused request. pub fn ask( &self, llm: &dyn LlmProvider, question: &str, history: &[ChatTurn], + focus: Option<&str>, on_token: &mut dyn FnMut(&str) -> ControlFlow<()>, ) -> Result<AnswerView> { let _op = tracing::debug_span!( target: "b2::vault", "ask", question, - multi_turn = !history.is_empty() + multi_turn = !history.is_empty(), + focus ) .entered(); + let (focus, lead) = match focus { + Some(note_ref) => { + let (f, lead) = self.focus_passages(note_ref)?; + (Some(f), lead) + } + None => (None, Vec::new()), + }; let query = if history.is_empty() { question.to_string() } else { - chat::condense_query(llm, question, history) + chat::condense_query( + llm, + question, + history, + focus.as_ref().map(|f| f.path.as_str()), + ) }; - let passages: Vec<ContextPassage> = self + let retrieved: Vec<ContextPassage> = self .search_chunks(&query, chat::ASK_PASSAGES)? .into_iter() .map(|c| ContextPassage { @@ -45,16 +64,46 @@ impl Vault { heading_path: c.heading_path, text: c.text, }) + // Only a repeat of the lead is dropped, so no focus means no change. + .filter(|p| !lead.contains(p)) .collect(); + let passages = [lead, retrieved].concat(); tracing::debug!( target: "b2::chat", passages = passages.len(), + focus_passages = focus.as_ref().map(|f| f.included), "retrieved grounding passages" ); - let req = chat::build_request(question, history, passages); + let req = chat::build_request(question, history, passages, focus.as_ref()); self.stream_answer(llm, &req, on_token) } + /// The focus note's first [`chat::FOCUS_PASSAGES`] passages in order, as `b2_read` + /// reads them, and how many it has in all. + fn focus_passages(&self, note_ref: &str) -> Result<(chat::Focus, Vec<ContextPassage>)> { + let path = self.resolve_ref(note_ref)?; + let ids = db::note_chunk_ids(&self.conn, &path)?; + // A missing row is a torn read (C1); the passage is skipped, not an error. + let passages = ids + .iter() + .take(chat::FOCUS_PASSAGES) + .filter_map(|id| db::chunk_detail(&self.conn, *id).transpose()) + .map(|detail| { + detail.map(|(heading_path, text)| ContextPassage { + path: path.clone(), + heading_path, + text, + }) + }) + .collect::<Result<Vec<_>>>()?; + let focus = chat::Focus { + included: passages.len(), + total: ids.len(), + path, + }; + Ok((focus, passages)) + } + /// Why? on a Similar & unlinked row: a grounded, cited explanation of why /// `candidate_ref` surfaced for `anchor_ref`. A tool-using turn (ADR-0022) over /// [`chat::why_tools`]; every argument defaults to this pair, so `{}` is valid. diff --git a/crates/b2-core/tests/ask.rs b/crates/b2-core/tests/ask.rs index b1123c4..2e48874 100644 --- a/crates/b2-core/tests/ask.rs +++ b/crates/b2-core/tests/ask.rs @@ -10,8 +10,8 @@ use b2_core::llm::{ }; use b2_core::vault::Vault; use b2_core::Error; -use common::{keep_streaming, opened_vault, reindexed_vault, stream_into}; -use std::cell::Cell; +use common::{keep_streaming, opened_vault, reindexed_vault, stream_into, SRS_PATH}; +use std::cell::{Cell, RefCell}; use std::fs; use std::ops::ControlFlow; use std::path::PathBuf; @@ -75,6 +75,7 @@ fn ask_grounds_the_answer_and_resolves_citations_end_to_end() { &FakeLlm, "forgetting curve", &[], + None, &mut stream_into(&mut streamed), ) .unwrap(); @@ -111,7 +112,7 @@ fn breaking_mid_stream_reports_a_truncated_answer_honestly() { // Break on the third token: "Grounded", " in", " [1]" ← here. let mut seen = 0usize; let view = vault - .ask(&FakeLlm, "forgetting curve", &[], &mut |_tok| { + .ask(&FakeLlm, "forgetting curve", &[], None, &mut |_tok| { seen += 1; if seen == 3 { ControlFlow::Break(()) @@ -132,7 +133,7 @@ fn breaking_mid_stream_reports_a_truncated_answer_honestly() { let mut first = true; let early = vault - .ask(&FakeLlm, "forgetting curve", &[], &mut |_tok| { + .ask(&FakeLlm, "forgetting curve", &[], None, &mut |_tok| { if first { first = false; ControlFlow::Continue(()) @@ -158,6 +159,7 @@ fn a_single_turn_ask_skips_condensation_and_a_follow_up_pays_for_it() { &counting, "forgetting curve", &[], + None, &mut stream_into(&mut sink), ) .unwrap(); @@ -174,6 +176,7 @@ fn a_single_turn_ask_skips_condensation_and_a_follow_up_pays_for_it() { &counting, "tell me more about that", &history, + None, &mut stream_into(&mut sink), ) .unwrap(); @@ -224,6 +227,7 @@ fn condensation_failure_degrades_to_the_raw_question() { }, "quokka", &history, + None, &mut stream_into(&mut sink), ) .unwrap(); @@ -242,6 +246,7 @@ fn condensation_failure_degrades_to_the_raw_question() { }, "quokka", &history, + None, &mut stream_into(&mut sink), ) .unwrap(); @@ -260,7 +265,13 @@ fn ask_answers_bm25_only_on_a_projected_but_unembedded_vault() { // No embedding space: retrieval is keyword-only (M4). let mut streamed = String::new(); let view = vault - .ask(&FakeLlm, "forgetting", &[], &mut stream_into(&mut streamed)) + .ask( + &FakeLlm, + "forgetting", + &[], + None, + &mut stream_into(&mut streamed), + ) .unwrap(); assert!(!view.cancelled); assert!(!view.citations.is_empty(), "chat keeps working unembedded"); @@ -314,6 +325,7 @@ fn a_hallucinated_marker_resolves_to_nothing_and_stays_in_the_text() { &Hallucinating, "forgetting curve", &[], + None, &mut stream_into(&mut sink), ) .unwrap(); @@ -342,6 +354,7 @@ fn empty_retrieval_answers_no_evidence_rather_than_echoing() { &FakeLlm, "anything at all", &[], + None, &mut stream_into(&mut streamed), ) .unwrap(); @@ -385,6 +398,7 @@ fn a_failed_answer_call_surfaces_as_an_llm_error() { }, "forgetting curve", &[], + None, &mut keep_streaming(), ) .unwrap_err(); @@ -397,6 +411,7 @@ fn a_failed_answer_call_surfaces_as_an_llm_error() { }, "forgetting curve", &[], + None, &mut keep_streaming(), ) .unwrap_err(); @@ -420,7 +435,7 @@ fn ask_shares_searchs_model_mismatch_fail_fast() { let swapped = Vault::open_with_embedder(&root, Box::new(FakeEmbedder::new(128))).unwrap(); let err = swapped - .ask(&FakeLlm, "forgetting", &[], &mut keep_streaming()) + .ask(&FakeLlm, "forgetting", &[], None, &mut keep_streaming()) .unwrap_err(); assert!(matches!(err, Error::ModelMismatch { .. })); } @@ -445,7 +460,7 @@ fn the_grounded_request_numbers_passages_and_ends_on_the_question() { ChatTurn::user("first question"), ChatTurn::assistant("first answer"), ]; - let req = chat::build_request("second question", &history, passages); + let req = chat::build_request("second question", &history, passages, None); assert_eq!(req.turns.len(), 3); assert_eq!(req.turns[0].content, "first question"); @@ -502,3 +517,258 @@ fn cited_markers_keeps_real_markers_and_drops_everything_else() { assert_eq!(chat::cited_markers("[1]", 0), Vec::<usize>::new()); } + +// --- the focus note (GH #253) ----------------------------------------------------- + +/// Records every request it is handed, then answers as [`FakeLlm`]. +struct Recording { + requests: RefCell<Vec<ChatRequest>>, +} + +impl Recording { + fn new() -> Self { + Self { + requests: RefCell::new(Vec::new()), + } + } + + fn take(&self) -> Vec<ChatRequest> { + self.requests.take() + } +} + +impl LlmProvider for Recording { + fn model_id(&self) -> &str { + "recording" + } + fn complete( + &self, + req: &ChatRequest, + on_token: &mut dyn FnMut(&str) -> ControlFlow<()>, + ) -> b2_core::Result<Completion> { + self.requests.borrow_mut().push(req.clone()); + FakeLlm.complete(req, on_token) + } +} + +/// What retrieval hands the model for `query`, as passages. +fn retrieved(vault: &Vault, query: &str) -> Vec<ContextPassage> { + vault + .search_chunks(query, chat::ASK_PASSAGES) + .unwrap() + .into_iter() + .map(|c| ContextPassage { + path: c.path, + heading_path: c.heading_path, + text: c.text, + }) + .collect() +} + +/// How many passages the index holds for `path`. +fn chunk_count(root: &std::path::Path, path: &str) -> usize { + let conn = common::index_conn(root); + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM chunks WHERE note_path = ?1", + [path], + |r| r.get(0), + ) + .unwrap(); + usize::try_from(n).unwrap() +} + +/// The pre-focus requests, spelled out field by field rather than through `build_request`, +/// so a change to assembly can't move the expectation along with it. +#[test] +fn without_a_focus_the_requests_are_the_plain_grounded_and_condense_requests() { + let tmp = tempfile::TempDir::new().unwrap(); + let (vault, _root) = reindexed_vault(tmp.path()); + + let rec = Recording::new(); + vault + .ask(&rec, "forgetting curve", &[], None, &mut keep_streaming()) + .unwrap(); + let expected = ChatRequest { + kind: RequestKind::Chat, + system: GROUNDED_SYSTEM_PROMPT.to_string(), + turns: vec![ChatTurn::user("forgetting curve")], + passages: retrieved(&vault, "forgetting curve"), + tools: Vec::new(), + exchanges: Vec::new(), + }; + assert_eq!(rec.take(), vec![expected]); + + let history = [ + ChatTurn::user("what is the forgetting curve?"), + ChatTurn::assistant("It describes memory decay over time [1]."), + ]; + let rec = Recording::new(); + vault + .ask(&rec, "tell me more", &history, None, &mut keep_streaming()) + .unwrap(); + let mut turns = history.to_vec(); + turns.push(ChatTurn::user("tell me more")); + let condense = ChatRequest { + kind: RequestKind::Condense, + system: CONDENSE_SYSTEM_PROMPT.to_string(), + turns: turns.clone(), + passages: Vec::new(), + tools: Vec::new(), + exchanges: Vec::new(), + }; + // FakeLlm's condense echoes the question, so retrieval ran on it. + let answer = ChatRequest { + kind: RequestKind::Chat, + system: GROUNDED_SYSTEM_PROMPT.to_string(), + turns, + passages: retrieved(&vault, "tell me more"), + tools: Vec::new(), + exchanges: Vec::new(), + }; + assert_eq!(rec.take(), vec![condense, answer]); +} + +#[test] +fn a_focus_note_leads_the_passages_and_both_prompts_name_its_path() { + let tmp = tempfile::TempDir::new().unwrap(); + let (vault, root) = reindexed_vault(tmp.path()); + let focus_chunks = chunk_count(&root, SRS_PATH); + assert!(focus_chunks > 0 && focus_chunks <= chat::FOCUS_PASSAGES); + + // A question that names nothing: only the focus gives it a subject. + let history = [ChatTurn::user("earlier"), ChatTurn::assistant("answer")]; + let rec = Recording::new(); + let view = vault + .ask( + &rec, + "clean up this doc", + &history, + Some(SRS_PATH), + &mut keep_streaming(), + ) + .unwrap(); + let requests = rec.take(); + let [condense, answer] = requests.as_slice() else { + panic!("expected a condense and an answer request: {requests:?}"); + }; + + let sentence = + format!("The user is viewing {SRS_PATH}; \"this note\" or \"this doc\" means it."); + assert!(condense.system.starts_with(CONDENSE_SYSTEM_PROMPT)); + assert!(condense.system.contains(&sentence), "{}", condense.system); + assert!(answer.system.starts_with(GROUNDED_SYSTEM_PROMPT)); + assert!(answer.system.contains(&sentence), "{}", answer.system); + assert!( + !answer.system.contains("passages are included"), + "a note under the cap is not called trimmed: {}", + answer.system + ); + + // Every passage of the note, in order, then retrieval's minus anything repeated. + let (lead, rest) = answer.passages.split_at(focus_chunks); + assert!(lead.iter().all(|p| p.path == SRS_PATH), "{lead:?}"); + let fill: Vec<ContextPassage> = retrieved(&vault, "clean up this doc") + .into_iter() + .filter(|p| !lead.contains(p)) + .collect(); + assert_eq!(rest, fill.as_slice()); + + assert_eq!(view.citations[0].marker, 1); + assert_eq!(view.citations[0].path, SRS_PATH); +} + +#[test] +fn a_focus_passage_that_retrieval_also_finds_is_numbered_once() { + let tmp = tempfile::TempDir::new().unwrap(); + let (vault, _root) = reindexed_vault(tmp.path()); + // 'forgetting' lives only in spaced-repetition.md, so retrieval returns its passages too. + assert!(retrieved(&vault, "forgetting") + .iter() + .any(|p| p.path == SRS_PATH)); + + let rec = Recording::new(); + vault + .ask( + &rec, + "forgetting", + &[], + Some(SRS_PATH), + &mut keep_streaming(), + ) + .unwrap(); + let requests = rec.take(); + let passages = &requests[0].passages; + for (i, p) in passages.iter().enumerate() { + assert!( + !passages[i + 1..].contains(p), + "passage {} is repeated: {p:?}", + i + 1 + ); + } +} + +#[test] +fn a_focus_note_longer_than_the_cap_says_how_much_of_it_was_included() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path().join("vault"); + fs::create_dir_all(&root).unwrap(); + let sections: String = (1..=chat::FOCUS_PASSAGES + 4) + .map(|i| { + let filler = format!("section{i} words for the long note, padded out. ").repeat(30); + format!("## Part {i}\n\n{filler}\n\n") + }) + .collect(); + common::write_note(&root, "long.md", §ions); + common::write_note(&root, "other.md", "An unrelated note about tides."); + let vault = Vault::open(&root).unwrap(); + vault.reindex().unwrap(); + let total = chunk_count(&root, "long.md"); + assert!( + total > chat::FOCUS_PASSAGES, + "the fixture must overflow the cap" + ); + + let rec = Recording::new(); + vault + .ask( + &rec, + "summarise this", + &[], + Some("long.md"), + &mut keep_streaming(), + ) + .unwrap(); + let requests = rec.take(); + let req = &requests[0]; + assert!(req.passages[..chat::FOCUS_PASSAGES] + .iter() + .all(|p| p.path == "long.md")); + let line = format!( + "Only the first {} of its {total} passages are included.", + chat::FOCUS_PASSAGES + ); + assert!(req.system.contains(&line), "{}", req.system); +} + +#[test] +fn an_unknown_focus_note_is_note_not_found_and_asks_the_model_nothing() { + let tmp = tempfile::TempDir::new().unwrap(); + let (vault, _root) = reindexed_vault(tmp.path()); + + let rec = Recording::new(); + let err = vault + .ask( + &rec, + "clean up this doc", + &[ChatTurn::user("earlier")], + Some("no/such-note.md"), + &mut keep_streaming(), + ) + .unwrap_err(); + assert!( + matches!(&err, Error::NoteNotFound(r) if r == "no/such-note.md"), + "{err:?}" + ); + assert!(rec.take().is_empty(), "nothing is asked of the model"); +} diff --git a/crates/b2-desktop/src/commands.rs b/crates/b2-desktop/src/commands.rs index 878d427..d033467 100644 --- a/crates/b2-desktop/src/commands.rs +++ b/crates/b2-desktop/src/commands.rs @@ -440,12 +440,14 @@ pub fn set_zoom(window: tauri::WebviewWindow, factor: f64) -> Result<(), CmdErro /// Flow ④, one grounded answer behind `Vault::ask`: tokens stream over a [`Channel`] and /// the [`AnswerView`] is the return. `history` is the pane's; nothing about a chat is -/// stored. Opens the real model, since retrieval embeds the question. +/// stored. `focus` is the open note's path, so "this doc" means it (GH #253). Opens the +/// real model, since retrieval embeds the question. #[tauri::command(async)] pub fn ask( state: State<'_, AppState>, question: String, history: Vec<ChatTurn>, + focus: Option<String>, on_event: Channel<String>, ) -> Result<AnswerView, CmdError> { let state = state.inner(); @@ -453,10 +455,18 @@ pub fn ask( // `FakeLlm` without a Tauri runtime. let llm = crate::chat::provider(&state.chat_prefs()); let vault = open_semantic(state)?; - ask_impl(state, &vault, llm.as_ref(), &question, &history, &|token| { - // A send error means the window closed; the cancel flag is what stops the stream. - let _ = on_event.send(token.to_string()); - }) + ask_impl( + state, + &vault, + llm.as_ref(), + &question, + &history, + focus.as_deref(), + &|token| { + // A send error means the window closed; the cancel flag is what stops the stream. + let _ = on_event.send(token.to_string()); + }, + ) } /// **Why?** for a Similar card: a grounded, cited explanation from `Vault::why_similar`, @@ -574,10 +584,11 @@ fn ask_impl( llm: &dyn LlmProvider, question: &str, history: &[ChatTurn], + focus: Option<&str>, sink: &dyn Fn(&str), ) -> Result<AnswerView, CmdError> { stream_answer(state, sink, |on_token| { - vault.ask(llm, question, history, on_token) + vault.ask(llm, question, history, focus, on_token) }) } @@ -1446,7 +1457,7 @@ mod tests { let (state, vault) = ask_state(&tmp); let streamed = std::cell::RefCell::new(Vec::<String>::new()); - let answer = ask_impl(&state, &vault, &FakeLlm, "memory", &[], &|t| { + let answer = ask_impl(&state, &vault, &FakeLlm, "memory", &[], None, &|t| { streamed.borrow_mut().push(t.to_string()) }) .unwrap(); @@ -1461,6 +1472,26 @@ mod tests { assert!(state.ask.try_claim().is_some()); } + #[test] + fn ask_hands_the_focus_note_to_the_engine() { + let tmp = tempfile::TempDir::new().unwrap(); + let (state, vault) = ask_state(&tmp); + // "memory" alone ranks spaced-repetition.md first; the focus must lead instead. + let focus = "concepts/memory.md"; + + let answer = ask_impl( + &state, + &vault, + &FakeLlm, + "memory", + &[], + Some(focus), + &|_| {}, + ) + .unwrap(); + assert_eq!(answer.citations[0].path, focus, "{answer:?}"); + } + #[test] fn esc_mid_answer_keeps_the_partial_text_and_says_it_was_stopped() { let tmp = tempfile::TempDir::new().unwrap(); @@ -1468,7 +1499,7 @@ mod tests { let streamed = std::cell::RefCell::new(Vec::<String>::new()); // Esc after the first token: the `cancel_ask` race, made deterministic. - let answer = ask_impl(&state, &vault, &FakeLlm, "memory", &[], &|t| { + let answer = ask_impl(&state, &vault, &FakeLlm, "memory", &[], None, &|t| { streamed.borrow_mut().push(t.to_string()); state.ask.cancel(); }) @@ -1491,7 +1522,7 @@ mod tests { let streaming = state.ask.try_claim(); assert!(streaming.is_some()); - let err = ask_impl(&state, &vault, &FakeLlm, "memory", &[], &|_| {}).unwrap_err(); + let err = ask_impl(&state, &vault, &FakeLlm, "memory", &[], None, &|_| {}).unwrap_err(); assert!(matches!(err, CmdError::AskInFlight)); assert_eq!( user_message(&err), @@ -1539,7 +1570,7 @@ mod tests { let tmp = tempfile::TempDir::new().unwrap(); let (state, vault) = ask_state(&tmp); state.ask.cancel(); // from a turn that already ended - let answer = ask_impl(&state, &vault, &FakeLlm, "memory", &[], &|_| {}).unwrap(); + let answer = ask_impl(&state, &vault, &FakeLlm, "memory", &[], None, &|_| {}).unwrap(); assert!(!answer.cancelled); } diff --git a/crates/b2-llm/examples/groundedness.rs b/crates/b2-llm/examples/groundedness.rs index b243042..91f9f7a 100644 --- a/crates/b2-llm/examples/groundedness.rs +++ b/crates/b2-llm/examples/groundedness.rs @@ -151,7 +151,7 @@ fn ask_one(vault: &Vault, llm: &dyn LlmProvider, q: &Question) -> Result<Scored, let started = Instant::now(); let mut tokens = 0usize; let mut first_token: Option<u128> = None; - let answer = vault.ask(llm, &q.question, &[], &mut |_| { + let answer = vault.ask(llm, &q.question, &[], None, &mut |_| { tokens += 1; first_token.get_or_insert_with(|| started.elapsed().as_millis()); ControlFlow::Continue(()) diff --git a/crates/b2-llm/src/provider.rs b/crates/b2-llm/src/provider.rs index ee140c3..f44954d 100644 --- a/crates/b2-llm/src/provider.rs +++ b/crates/b2-llm/src/provider.rs @@ -448,6 +448,7 @@ mod tests { heading_path: None, text: "the passage".into(), }], + None, ); let wire = WireRequest::from_chat("llama3.2", &req); assert!(wire.stream, "streaming is the contract"); @@ -463,7 +464,7 @@ mod tests { #[test] fn a_plain_ask_sends_no_tools_key_at_all() { - let req = b2_core::chat::build_request("q", &[], Vec::new()); + let req = b2_core::chat::build_request("q", &[], Vec::new(), None); let body = serde_json::to_string(&WireRequest::from_chat("llama3.2", &req)).unwrap(); assert!(!body.contains("tools"), "{body}"); assert!(!body.contains("tool_call"), "{body}"); diff --git a/crates/b2-llm/tests/wire.rs b/crates/b2-llm/tests/wire.rs index a60ae89..bdb3de0 100644 --- a/crates/b2-llm/tests/wire.rs +++ b/crates/b2-llm/tests/wire.rs @@ -88,6 +88,7 @@ fn complete(p: &OpenAiCompatProvider, tokens: &mut Vec<String>) -> b2_core::Resu heading_path: None, text: "The brain encodes, stores, and retrieves information.".into(), }], + None, ); let completion = p.complete(&req, &mut |t| { tokens.push(t.to_string()); @@ -288,7 +289,7 @@ fn a_reply_past_the_configured_tool_call_cap_fails_with_a_typed_error() { ..LlmConfig::default() }) }; - let req = build_request("q", &[], Vec::new()); + let req = build_request("q", &[], Vec::new(), None); let run = |p: &OpenAiCompatProvider| p.complete(&req, &mut |_| ControlFlow::Continue(())); let err = run(&capped(2)).unwrap_err(); diff --git a/docs/index-engine.md b/docs/index-engine.md index 5d45cd0..19f7def 100644 --- a/docs/index-engine.md +++ b/docs/index-engine.md @@ -435,6 +435,7 @@ and their justification is re-derived on every `make eval` run (ADR-0013). | note hit pool | 3 × limit | note-view headroom (dedup + torn reads) | `vault.rs` | | chunk hit pool | limit + 2 | passage-view headroom (torn reads only) | `vault.rs` | | `ASK_PASSAGES` | 10 | chat's retrieve depth (flow ④ reads this flow) | `chat.rs` | +| `FOCUS_PASSAGES` | 8 | the focus note's passages that lead an ask, before retrieval's | `chat.rs` | | `SHORTLIST_PER_RESULT` | 20 | discovery stage-1 width per asked result | `discover.rs` | | `SHORTLIST_MIN` | 200 | discovery stage-1 floor | `discover.rs` | | `EXCLUDE_HOPS` | 1 | discovery's already-connected radius | `discover.rs` | @@ -533,7 +534,10 @@ swapping chat models never touches the index, so a provider swap is a URL or con 1. **Condense** (multi-turn only). A provider call rewrites the follow-up into a standalone query. On failure it degrades to the raw question, so this step can never break chat. 2. **Retrieve.** `search_chunks` at `ASK_PASSAGES = 10`: the §4 pipeline unchanged, BM25-only - fallback included. + fallback included. With a **focus** note (the one being viewed, GH #253), its first + `FOCUS_PASSAGES = 8` passages (`db::note_chunk_ids` + `chunk_detail`) are numbered first and + retrieval's passages follow, minus repeats; the prompt, and the condense prompt, name the + note, and a note cut short by the cap says so in one line. No focus, no change. 3. **Assemble.** The grounded system prompt plus numbered passages. Prompt assembly is core logic, not an adapter's. 4. **Stream.** Tokens flow up through the caller's callback, whose return value cancels at diff --git a/docs/quickstart.md b/docs/quickstart.md index dde8242..2b06600 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -345,9 +345,18 @@ Sources: [2] concepts/forgetting-curve.md ``` +`--note PATH` says which note the question is about, so "this note" and "this doc" have a +referent. That note's passages come first (up to 8, in order; the model is told when a long +note was cut short), then the usual retrieval fills in from the rest of the vault: + +```console +$ b2 ask --note notes/spaced-repetition.md "what is this note missing?" +``` + `b2 chat` is the interactive form: follow-up questions remember the conversation, Ctrl-C -stops an answer mid-stream (the partial text stands), `/exit` leaves. The desktop app has the -same thing as a chat pane (⌘J). +stops an answer mid-stream (the partial text stands), `/exit` leaves. `b2 chat --note PATH` +keeps one note in focus for every turn. The desktop app has the same thing as a chat pane +(⌘J), where the focus is the note you have open, shown as an "about:" chip by the composer. A reader, not a writer: chat stores nothing. No transcript, no cache, session-only history. Nothing about a chat is ever written to your notes or the index, and swapping chat models is @@ -452,8 +461,8 @@ your notes. Nothing locks you in. | `b2 write <note>` | Replace a note's body from stdin: the scripting/agent editing surface. Frontmatter is left alone | no | | `b2 mv <from> <to>` | Move/rename a note, file, or folder and repair every inbound link | re-embeds touched | | `b2 rm <target>` | Delete a note, file, or folder from the vault and disk (`-r` for a folder). Inbound links dangle and are surfaced, never rewritten | no | -| `b2 ask <question>` | One grounded, cited, streamed answer from your notes. `--llm-url`, `--llm-model`; `--json` is a JSONL event stream | embeds query + model server | -| `b2 chat` | Interactive grounded chat; session-only history, nothing stored. Ctrl-C stops an answer; `/exit` leaves | embeds query + model server | +| `b2 ask <question>` | One grounded, cited, streamed answer from your notes. `--note PATH` makes it about one note; `--llm-url`, `--llm-model`; `--json` is a JSONL event stream | embeds query + model server | +| `b2 chat` | Interactive grounded chat; session-only history, nothing stored. `--note PATH` as `ask`. Ctrl-C stops an answer; `/exit` leaves | embeds query + model server | ## Config and environment diff --git a/ui/src/api.ts b/ui/src/api.ts index 3eabd67..4573850 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -222,16 +222,18 @@ export const api = { /** * One grounded answer (flow ④, `Vault::ask`), streamed per token. `history` is the - * caller's and session-only (S4): nothing about a chat is stored. + * caller's and session-only (S4): nothing about a chat is stored. `focus` is the open + * note's path, or null (GH #253). */ ask: ( question: string, history: ChatTurn[], + focus: string | null, onToken: (text: string) => void, ): Promise<AnswerView> => { const channel = new Channel<string>(); channel.onmessage = onToken; - return invoke("ask", { question, history, onEvent: channel }); + return invoke("ask", { question, history, focus, onEvent: channel }); }, /** diff --git a/ui/src/chat.test.ts b/ui/src/chat.test.ts index dce09cf..bd68dcb 100644 --- a/ui/src/chat.test.ts +++ b/ui/src/chat.test.ts @@ -5,12 +5,14 @@ import { STREAMING_ROW_KEY, answerMessage, chatEmptyState, + chatFocus, chatReady, chatStateOf, chatHistory, chatRows, citationRowKey, errorMessage, + focusChipLabel, formatModelSize, modelDetail, pullCommand, @@ -251,3 +253,16 @@ test("an installed model's detail is its parameters and size, whichever it has", assert.equal(modelDetail({ name: "m", size: 0, parameters: "3.2B" }), "3.2B"); assert.equal(modelDetail({ name: "m", size: 0, parameters: null }), ""); }); + +test("the open note is chat's focus; a resource or an empty pane is none", () => { + const note = { path: "Toy/ForgeOS.md", title: "ForgeOS" }; + assert.deepEqual(chatFocus({ current: note, currentResource: null }), note); + assert.equal(chatFocus({ current: null, currentResource: { path: "img/a.png" } }), null); + assert.equal(chatFocus({ current: null, currentResource: null }), null); +}); + +test("the focus chip names the note by title, else by path", () => { + assert.equal(focusChipLabel({ path: "Toy/ForgeOS.md", title: "ForgeOS" }), "about: ForgeOS"); + assert.equal(focusChipLabel({ path: "Toy/ForgeOS.md", title: null }), "about: Toy/ForgeOS.md"); + assert.equal(focusChipLabel({ path: "Toy/ForgeOS.md", title: "" }), "about: Toy/ForgeOS.md"); +}); diff --git a/ui/src/chat.ts b/ui/src/chat.ts index a0a66d1..13385e7 100644 --- a/ui/src/chat.ts +++ b/ui/src/chat.ts @@ -91,6 +91,30 @@ export function toolCapInput( return { send: String(n) }; } +/** The note a question is about, by path (its identity) and title. */ +export interface ChatFocus { + path: string; + title: string | null; +} + +/** + * The note chat is about (GH #253): the open note, sent as `ask`'s `focus` so "this doc" + * has a referent. A resource is never one: the engine has no passages of it to ground on. + */ +export function chatFocus(s: { + current: ChatFocus | null; + currentResource: unknown; +}): ChatFocus | null { + if (s.currentResource !== null || s.current === null) return null; + return { path: s.current.path, title: s.current.title }; +} + +/** The composer chip's text, so what the model is told is visible. Not escaped here: a + * title is note content, and the paint escapes it (E5). */ +export function focusChipLabel(f: ChatFocus): string { + return `about: ${f.title || f.path}`; +} + /** A turn that failed. */ export function errorMessage(error: string): ChatMessage { return { role: "assistant", text: "", citations: [], cancelled: false, tools: [], error }; diff --git a/ui/src/chatview.ts b/ui/src/chatview.ts index 181a73c..4e63b19 100644 --- a/ui/src/chatview.ts +++ b/ui/src/chatview.ts @@ -8,9 +8,11 @@ import { type ChatMessage, OLLAMA_QUICKSTART_URL, STREAMING_ROW_KEY, + chatFocus, chatReady, chatStateOf, citationRowKey, + focusChipLabel, modelDetail, pullCommand, retrievalNote, @@ -160,6 +162,13 @@ function chatTurnHtml(m: ChatMessage, index: number, roving: string | null): str function chatComposerHtml(state: AppState): string { const streaming = state.chatStreaming !== null; const note = retrievalNote(state); + // Display only (no control, so nothing new for K1): what `ask` sends as its focus. + const focus = chatFocus(state); + const chip = focus + ? `<span class="chat-focus" title="${escapeHtml( + `Questions are about ${focus.path}: “this note” means it`, + )}">${escapeHtml(focusChipLabel(focus))}</span>` + : ""; return `<form class="chat-composer" id="chat-composer"> <textarea id="chat-input" class="chat-input" rows="2" placeholder="Ask your notes…" aria-label="Ask your notes"></textarea> @@ -171,6 +180,7 @@ function chatComposerHtml(state: AppState): string { )})">Stop</button>` : `<button type="submit" class="btn small primary" id="chat-send">Ask</button>` } + ${chip} ${note ? `<span class="chat-note muted">${escapeHtml(note)}</span>` : ""} </div> </form>`; diff --git a/ui/src/main.ts b/ui/src/main.ts index 3c1988a..cb24814 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -55,6 +55,7 @@ import { import { cardRowKey, sideArrowMove, sideNavFor, sideRowIndex, sideRows } from "./sidenav"; import { answerMessage, + chatFocus, chatHistory, chatReady, errorMessage, @@ -1907,8 +1908,9 @@ async function sendChat(question: string): Promise<void> { if (!q || state.chatStreaming !== null) return; // History is taken before this question joins the transcript. const history = chatHistory(state.chatMessages); + const focus = chatFocus(state)?.path ?? null; await runChatTurn(q, true, "Searching your notes…", (onToken) => - api.ask(q, history, onToken), + api.ask(q, history, focus, onToken), ); } diff --git a/ui/style.css b/ui/style.css index 00d68dc..6f8c694 100644 --- a/ui/style.css +++ b/ui/style.css @@ -3268,6 +3268,19 @@ button.move-dest:hover { font-size: 11.5px; line-height: 1.35; } +.chat-focus { + min-width: 0; + max-width: 60%; + padding: 1px 7px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + font-size: 11.5px; + line-height: 1.5; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} /* The setup card — the pane's empty state, and the same block inside Settings → Chat. */ .chat-setup { From a91acfa5670b58b8b3a548597d1650863320edc9 Mon Sep 17 00:00:00 2001 From: Sam Keen <sam.sjk@gmail.com> Date: Sun, 8 Nov 2026 12:57:09 -0800 Subject: [PATCH 2/3] Make the focus cap configurable: B2_LLM_FOCUS_PASSAGES and Settings A cap is configurable and loud, so the focus note's passage cap gets the same treatment as the tool-call cap. Vault::ask now takes a chat::FocusRequest { note, max_passages } instead of a bare path, and FOCUS_PASSAGES stays as the default. A test proves a smaller cap takes the same opening passages and the trim line follows it. - b2-llm: LlmConfig::focus_passages, B2_LLM_FOCUS_PASSAGES (1 to 64, default 8), parse_focus_passages, with_focus_passages. ToolCallCap becomes CapSetting so both caps share one view, and ChatSetup carries focus_passages for Settings. - CLI: ask and chat read the cap from LlmConfig (env only). - Desktop: ChatPrefs::focus_passages with the three-state apply rule shared with the tool-call cap; ask passes the cap in force. - UI: a "Focus passages" field beside "Tool calls per reply", judged by focusCapInput. - quickstart documents B2_LLM_FOCUS_PASSAGES. Part of #253 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- crates/b2-cli/src/chat.rs | 27 ++++-- crates/b2-cli/src/wiring.rs | 13 +-- crates/b2-core/src/chat.rs | 17 +++- crates/b2-core/src/vault/chat.rs | 30 ++++--- crates/b2-core/tests/ask.rs | 75 ++++++++++++++--- crates/b2-desktop/src/chat.rs | 74 ++++++++++++++++- crates/b2-desktop/src/commands.rs | 66 +++++++++++++-- crates/b2-llm/src/lib.rs | 132 +++++++++++++++++++++++++++--- crates/b2-llm/src/setup.rs | 42 +++++++--- docs/index-engine.md | 3 +- docs/quickstart.md | 6 +- ui/src/api.ts | 9 +- ui/src/chat.test.ts | 17 ++++ ui/src/chat.ts | 28 +++++-- ui/src/main.ts | 43 +++++++--- ui/src/render.test.ts | 15 ++++ ui/src/settingsview.ts | 19 +++++ ui/src/types.ts | 10 ++- 18 files changed, 519 insertions(+), 107 deletions(-) diff --git a/crates/b2-cli/src/chat.rs b/crates/b2-cli/src/chat.rs index fed4419..952242c 100644 --- a/crates/b2-cli/src/chat.rs +++ b/crates/b2-cli/src/chat.rs @@ -4,7 +4,8 @@ use crate::args::{Cli, LlmArgs}; use crate::cancel::{cancel_flow, install_cancel_on_sigint, while_answering}; use crate::error::{user_message, CliError}; -use crate::wiring::{open_llm, open_vault}; +use crate::wiring::{llm_config, open_llm, open_vault}; +use b2_core::chat::FocusRequest; use b2_core::llm::{ChatTurn, LlmProvider}; use b2_core::vault::{AnswerView, Vault}; use std::io::{IsTerminal, Write}; @@ -13,11 +14,16 @@ use std::ops::ControlFlow; pub fn cmd_ask( cli: &Cli, question: &str, - focus: Option<&str>, + note: Option<&str>, llm_args: &LlmArgs, ) -> Result<(), CliError> { + let config = llm_config(llm_args); + let focus = note.map(|note| FocusRequest { + note, + max_passages: config.focus_passages, + }); // The provider first: a stopped server should cost a round trip, not a model load. - let llm = open_llm(llm_args)?; + let llm = open_llm(config)?; // Retrieval embeds the question, so the real model. let vault = open_vault(cli.vault_or_cwd(), true)?; // Ctrl-C cancels at the next token; what streamed stays, reported as partial. @@ -36,7 +42,7 @@ pub fn cmd_why( limit: usize, llm_args: &LlmArgs, ) -> Result<(), CliError> { - let llm = open_llm(llm_args)?; + let llm = open_llm(llm_config(llm_args))?; // Stored vectors only, like `similar`: no model load. let vault = open_vault(cli.vault_or_cwd(), false)?; install_cancel_on_sigint(false); @@ -51,8 +57,13 @@ pub fn cmd_why( Ok(()) } -pub fn cmd_chat(cli: &Cli, focus: Option<&str>, llm_args: &LlmArgs) -> Result<(), CliError> { - let llm = open_llm(llm_args)?; +pub fn cmd_chat(cli: &Cli, note: Option<&str>, llm_args: &LlmArgs) -> Result<(), CliError> { + let config = llm_config(llm_args); + let focus = note.map(|note| FocusRequest { + note, + max_passages: config.focus_passages, + }); + let llm = open_llm(config)?; let vault = open_vault(cli.vault_or_cwd(), true)?; // Mid-answer Ctrl-C cancels the stream; at an idle prompt it leaves. install_cancel_on_sigint(true); @@ -68,7 +79,7 @@ pub fn cmd_chat(cli: &Cli, focus: Option<&str>, llm_args: &LlmArgs) -> Result<() Nothing here is saved.", llm.model_id() ); - if let Some(note) = focus { + if let Some(note) = note { eprintln!("About: {note}. \"This note\" in a question means it."); } } @@ -117,7 +128,7 @@ fn ask_streamed( llm: &dyn LlmProvider, question: &str, history: &[ChatTurn], - focus: Option<&str>, + focus: Option<FocusRequest<'_>>, json: bool, ) -> Result<AnswerView, CliError> { let answer = vault.ask(llm, question, history, focus, &mut |token| { diff --git a/crates/b2-cli/src/wiring.rs b/crates/b2-cli/src/wiring.rs index 5696b4d..c51b241 100644 --- a/crates/b2-cli/src/wiring.rs +++ b/crates/b2-cli/src/wiring.rs @@ -9,11 +9,14 @@ use b2_core::vault::Vault; use b2_llm::LlmConfig; use std::path::Path; -/// The chat provider: the fake under `B2_LLM=fake`, else the real client from the -/// environment plus flags, probed first so a stopped daemon fails before the question. -pub fn open_llm(args: &LlmArgs) -> Result<Box<dyn LlmProvider>, CliError> { - let config = - LlmConfig::from_env().with_overrides(args.llm_url.as_deref(), args.llm_model.as_deref()); +/// The chat configuration: the environment plus flags. +pub fn llm_config(args: &LlmArgs) -> LlmConfig { + LlmConfig::from_env().with_overrides(args.llm_url.as_deref(), args.llm_model.as_deref()) +} + +/// The chat provider: the fake under `B2_LLM=fake`, else the real client for `config`, +/// probed first so a stopped daemon fails before the question. +pub fn open_llm(config: LlmConfig) -> Result<Box<dyn LlmProvider>, CliError> { Ok(b2_llm::probed_provider(config)?) } diff --git a/crates/b2-core/src/chat.rs b/crates/b2-core/src/chat.rs index 690985e..4ba192a 100644 --- a/crates/b2-core/src/chat.rs +++ b/crates/b2-core/src/chat.rs @@ -17,10 +17,21 @@ use std::ops::ControlFlow; /// so chat adds no new candidate width (GH #141/#142). pub const ASK_PASSAGES: usize = 10; -/// How many of the focus note's own passages lead an ask, in order (GH #253). Retrieval at -/// [`ASK_PASSAGES`] follows them, so a long note can't crowd out the rest of the vault. +/// The default for how many of the focus note's own passages lead an ask, in order (GH +/// #253). Retrieval at [`ASK_PASSAGES`] follows them, so a long note can't crowd out the +/// rest of the vault. The adapters pass the cap in force ([`FocusRequest::max_passages`]). pub const FOCUS_PASSAGES: usize = 8; +/// What an adapter asks to focus on: the note being viewed and the cap in force. A +/// short-lived, `Copy`, read-only view passed into one `Vault::ask` call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FocusRequest<'a> { + /// A note reference, `.md` optional, resolved as every façade ref is. + pub note: &'a str, + /// How many of its passages lead the request; [`FOCUS_PASSAGES`] by default. + pub max_passages: usize, +} + /// The grounded system prompt (flow ④ step 2). Its no-evidence sentence must stay /// [`crate::llm::NO_EVIDENCE_ANSWER`] verbatim; the suite pins it. pub const GROUNDED_SYSTEM_PROMPT: &str = "You are B2, answering questions about the user's own \ @@ -40,7 +51,7 @@ any pronouns or references. Reply with the query text only — no preamble, no q pub struct Focus { /// Vault-relative path, the note's identity (L1). pub path: String, - /// How many of its passages lead the request: at most [`FOCUS_PASSAGES`]. + /// How many of its passages lead the request: at most [`FocusRequest::max_passages`]. pub included: usize, /// How many passages the note has. pub total: usize, diff --git a/crates/b2-core/src/vault/chat.rs b/crates/b2-core/src/vault/chat.rs index 67f6139..bc49de5 100644 --- a/crates/b2-core/src/vault/chat.rs +++ b/crates/b2-core/src/vault/chat.rs @@ -19,16 +19,16 @@ impl Vault { /// cancels. A hallucinated `[n]` resolves to nothing; the text is never rewritten. /// A failed answer call is [`Error::Llm`](crate::Error::Llm). /// - /// `focus` is the note the user is viewing (GH #253): its first - /// [`chat::FOCUS_PASSAGES`] passages are numbered first and the prompt names it, so - /// "this doc" has a referent. An unknown ref is [`Error::NoteNotFound`], before any - /// model call. `None` sends exactly the unfocused request. + /// `focus` is the note the user is viewing (GH #253): its first `max_passages` + /// passages are numbered first and the prompt names it, so "this doc" has a referent. + /// An unknown ref is [`Error::NoteNotFound`], before any model call. `None` sends + /// exactly the unfocused request. pub fn ask( &self, llm: &dyn LlmProvider, question: &str, history: &[ChatTurn], - focus: Option<&str>, + focus: Option<chat::FocusRequest<'_>>, on_token: &mut dyn FnMut(&str) -> ControlFlow<()>, ) -> Result<AnswerView> { let _op = tracing::debug_span!( @@ -36,12 +36,13 @@ impl Vault { "ask", question, multi_turn = !history.is_empty(), - focus + focus = focus.map(|f| f.note), + focus_cap = focus.map(|f| f.max_passages) ) .entered(); let (focus, lead) = match focus { - Some(note_ref) => { - let (f, lead) = self.focus_passages(note_ref)?; + Some(request) => { + let (f, lead) = self.focus_passages(request)?; (Some(f), lead) } None => (None, Vec::new()), @@ -78,15 +79,18 @@ impl Vault { self.stream_answer(llm, &req, on_token) } - /// The focus note's first [`chat::FOCUS_PASSAGES`] passages in order, as `b2_read` - /// reads them, and how many it has in all. - fn focus_passages(&self, note_ref: &str) -> Result<(chat::Focus, Vec<ContextPassage>)> { - let path = self.resolve_ref(note_ref)?; + /// The focus note's first `max_passages` passages in order, as `b2_read` reads them, + /// and how many it has in all. + fn focus_passages( + &self, + request: chat::FocusRequest<'_>, + ) -> Result<(chat::Focus, Vec<ContextPassage>)> { + let path = self.resolve_ref(request.note)?; let ids = db::note_chunk_ids(&self.conn, &path)?; // A missing row is a torn read (C1); the passage is skipped, not an error. let passages = ids .iter() - .take(chat::FOCUS_PASSAGES) + .take(request.max_passages) .filter_map(|id| db::chunk_detail(&self.conn, *id).transpose()) .map(|detail| { detail.map(|(heading_path, text)| ContextPassage { diff --git a/crates/b2-core/tests/ask.rs b/crates/b2-core/tests/ask.rs index 2e48874..6afc2dd 100644 --- a/crates/b2-core/tests/ask.rs +++ b/crates/b2-core/tests/ask.rs @@ -3,7 +3,7 @@ mod common; -use b2_core::chat::{self, CONDENSE_SYSTEM_PROMPT, GROUNDED_SYSTEM_PROMPT}; +use b2_core::chat::{self, FocusRequest, CONDENSE_SYSTEM_PROMPT, GROUNDED_SYSTEM_PROMPT}; use b2_core::llm::{ ChatRequest, ChatTurn, Completion, ContextPassage, FakeLlm, LlmProvider, RequestKind, Role, NO_EVIDENCE_ANSWER, @@ -551,6 +551,14 @@ impl LlmProvider for Recording { } } +/// A focus on `note` at the default cap. +fn focus(note: &str) -> Option<FocusRequest<'_>> { + Some(FocusRequest { + note, + max_passages: chat::FOCUS_PASSAGES, + }) +} + /// What retrieval hands the model for `query`, as passages. fn retrieved(vault: &Vault, query: &str) -> Vec<ContextPassage> { vault @@ -644,7 +652,7 @@ fn a_focus_note_leads_the_passages_and_both_prompts_name_its_path() { &rec, "clean up this doc", &history, - Some(SRS_PATH), + focus(SRS_PATH), &mut keep_streaming(), ) .unwrap(); @@ -693,7 +701,7 @@ fn a_focus_passage_that_retrieval_also_finds_is_numbered_once() { &rec, "forgetting", &[], - Some(SRS_PATH), + focus(SRS_PATH), &mut keep_streaming(), ) .unwrap(); @@ -708,10 +716,9 @@ fn a_focus_passage_that_retrieval_also_finds_is_numbered_once() { } } -#[test] -fn a_focus_note_longer_than_the_cap_says_how_much_of_it_was_included() { - let tmp = tempfile::TempDir::new().unwrap(); - let root = tmp.path().join("vault"); +/// A vault whose `long.md` has more passages than the default focus cap, and how many. +fn long_note_vault(dir: &std::path::Path) -> (Vault, usize) { + let root = dir.join("vault"); fs::create_dir_all(&root).unwrap(); let sections: String = (1..=chat::FOCUS_PASSAGES + 4) .map(|i| { @@ -728,19 +735,36 @@ fn a_focus_note_longer_than_the_cap_says_how_much_of_it_was_included() { total > chat::FOCUS_PASSAGES, "the fixture must overflow the cap" ); + (vault, total) +} +/// The one request an ask about `long.md` sends, at `max_passages`. +fn long_note_request(vault: &Vault, max_passages: usize) -> ChatRequest { let rec = Recording::new(); + let focus = FocusRequest { + note: "long.md", + max_passages, + }; vault .ask( &rec, "summarise this", &[], - Some("long.md"), + Some(focus), &mut keep_streaming(), ) .unwrap(); - let requests = rec.take(); - let req = &requests[0]; + let mut requests = rec.take(); + assert_eq!(requests.len(), 1); + requests.remove(0) +} + +#[test] +fn a_focus_note_longer_than_the_cap_says_how_much_of_it_was_included() { + let tmp = tempfile::TempDir::new().unwrap(); + let (vault, total) = long_note_vault(tmp.path()); + + let req = long_note_request(&vault, chat::FOCUS_PASSAGES); assert!(req.passages[..chat::FOCUS_PASSAGES] .iter() .all(|p| p.path == "long.md")); @@ -751,6 +775,35 @@ fn a_focus_note_longer_than_the_cap_says_how_much_of_it_was_included() { assert!(req.system.contains(&line), "{}", req.system); } +#[test] +fn the_callers_cap_decides_how_many_focus_passages_lead_and_the_trim_line_follows_it() { + let tmp = tempfile::TempDir::new().unwrap(); + let (vault, total) = long_note_vault(tmp.path()); + + let wide = long_note_request(&vault, chat::FOCUS_PASSAGES); + let narrow = long_note_request(&vault, 3); + assert_eq!( + narrow.passages[..3], + wide.passages[..3], + "a smaller cap takes the same opening passages, in order" + ); + assert!( + narrow.system.contains(&format!( + "Only the first 3 of its {total} passages are included." + )), + "{}", + narrow.system + ); + + let whole = long_note_request(&vault, total); + assert!( + !whole.system.contains("passages are included"), + "a cap the note fits under trims nothing: {}", + whole.system + ); + assert!(whole.passages[..total].iter().all(|p| p.path == "long.md")); +} + #[test] fn an_unknown_focus_note_is_note_not_found_and_asks_the_model_nothing() { let tmp = tempfile::TempDir::new().unwrap(); @@ -762,7 +815,7 @@ fn an_unknown_focus_note_is_note_not_found_and_asks_the_model_nothing() { &rec, "clean up this doc", &[ChatTurn::user("earlier")], - Some("no/such-note.md"), + focus("no/such-note.md"), &mut keep_streaming(), ) .unwrap_err(); diff --git a/crates/b2-desktop/src/chat.rs b/crates/b2-desktop/src/chat.rs index 10f9817..3cdb2af 100644 --- a/crates/b2-desktop/src/chat.rs +++ b/crates/b2-desktop/src/chat.rs @@ -28,6 +28,9 @@ pub struct ChatPrefs { /// The tool-call cap the user set, if any (`LlmConfig::max_tool_calls`). #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tool_calls: Option<usize>, + /// The focus-passage cap the user set, if any (`LlmConfig::focus_passages`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focus_passages: Option<usize>, /// The bearer token in force for this run. `skip` both ways: it can never reach the /// file, and a file naming it can't install one. #[serde(skip)] @@ -46,6 +49,7 @@ impl std::fmt::Debug for ChatPrefs { .field("base_url", &self.base_url) .field("model", &self.model) .field("max_tool_calls", &self.max_tool_calls) + .field("focus_passages", &self.focus_passages) .field( "api_key", &match self.api_key { @@ -65,6 +69,7 @@ impl ChatPrefs { LlmConfig::from_env() .with_overrides(self.base_url.as_deref(), self.model.as_deref()) .with_max_tool_calls(self.max_tool_calls) + .with_focus_passages(self.focus_passages) .with_api_key(self.api_key.as_deref(), self.api_key_source()) } @@ -142,10 +147,26 @@ pub fn apply_key( /// that doesn't mention it can't reset it. A value `b2_llm::parse_max_tool_calls` refuses /// leaves the cap as it stood. pub fn apply_tool_cap(prev: &ChatPrefs, typed: Option<&str>) -> Option<usize> { + apply_cap(prev.max_tool_calls, typed, b2_llm::parse_max_tool_calls) +} + +/// Apply a save's focus-cap field by [`apply_tool_cap`]'s rule, judged by +/// `b2_llm::parse_focus_passages`. +pub fn apply_focus_cap(prev: &ChatPrefs, typed: Option<&str>) -> Option<usize> { + apply_cap(prev.focus_passages, typed, b2_llm::parse_focus_passages) +} + +/// The three-state rule both cap fields share: untouched keeps, blank clears, a refused +/// value keeps. +fn apply_cap( + prev: Option<usize>, + typed: Option<&str>, + parse: fn(&str) -> Option<usize>, +) -> Option<usize> { match typed.map(str::trim) { - None => prev.max_tool_calls, + None => prev, Some("") => None, - Some(raw) => b2_llm::parse_max_tool_calls(raw).or(prev.max_tool_calls), + Some(raw) => parse(raw).or(prev), } } @@ -244,6 +265,55 @@ mod tests { } } + #[test] + fn the_focus_cap_persists_and_layers_over_the_shared_resolution() { + let tmp = tempfile::TempDir::new().unwrap(); + let file = tmp.path().join("chat.json"); + let prefs = ChatPrefs { + focus_passages: Some(3), + ..ChatPrefs::default() + }; + write_prefs_to(&file, &prefs).unwrap(); + let back = read_prefs_from(Some(&file), &MemoryStore::empty()); + assert_eq!(back.focus_passages, Some(3)); + assert_eq!(back.config().focus_passages, 3); + + write_prefs_to(&file, &ChatPrefs::default()).unwrap(); + assert!(!std::fs::read_to_string(&file) + .unwrap() + .contains("focus_passages")); + assert_eq!( + ChatPrefs::default().config().focus_passages, + LlmConfig::from_env().focus_passages + ); + + std::fs::write(&file, r#"{"focus_passages": 1000000}"#).unwrap(); + let edited = read_prefs_from(Some(&file), &MemoryStore::empty()); + assert_eq!( + edited.config().focus_passages, + LlmConfig::from_env().focus_passages, + "a hand-edited file can't lift the ceiling" + ); + } + + #[test] + fn the_focus_cap_field_is_untouched_cleared_or_set() { + let held = ChatPrefs { + focus_passages: Some(3), + ..ChatPrefs::default() + }; + assert_eq!(apply_focus_cap(&held, None), Some(3), "untouched"); + assert_eq!(apply_focus_cap(&held, Some(" ")), None, "cleared"); + assert_eq!(apply_focus_cap(&held, Some(" 16 ")), Some(16), "set"); + for refused in ["0", "lots", "-3", "65"] { + assert_eq!( + apply_focus_cap(&held, Some(refused)), + Some(3), + "{refused:?}" + ); + } + } + #[test] fn the_key_comes_back_from_the_store() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/b2-desktop/src/commands.rs b/crates/b2-desktop/src/commands.rs index d033467..2c42a63 100644 --- a/crates/b2-desktop/src/commands.rs +++ b/crates/b2-desktop/src/commands.rs @@ -11,6 +11,7 @@ use crate::error::CmdError; use crate::watch::VaultWatcher; use crate::{open_read, open_semantic, open_vault, AppState}; use b2_core::add::AddReport; +use b2_core::chat::FocusRequest; use b2_core::ingest::ReindexProgress; use b2_core::llm::{ChatTurn, LlmProvider}; use b2_core::vault::{ @@ -520,6 +521,7 @@ pub fn set_chat_config( model: Option<String>, api_key: Option<String>, max_tool_calls: Option<String>, + focus_passages: Option<String>, ) -> ChatSetup { { // One save at a time (Tauri doesn't serialize `(async)` commands). Not held over @@ -531,6 +533,7 @@ pub fn set_chat_config( model, api_key, max_tool_calls, + focus_passages, &crate::keychain::Keychain, ); // Persisted here so tests never write the real data dir. The Keychain is passed @@ -548,18 +551,20 @@ fn set_chat_config_impl( model: Option<String>, api_key: Option<String>, max_tool_calls: Option<String>, + focus_passages: Option<String>, keys: &dyn crate::keychain::KeyStore, ) -> ChatPrefs { let clean = |v: Option<String>| { v.map(|s| s.trim().to_string()) .filter(|s: &String| !s.is_empty()) }; - let (api_key, key_remembered) = - crate::chat::apply_key(&state.chat_prefs(), api_key.as_deref(), keys); + let prev = state.chat_prefs(); + let (api_key, key_remembered) = crate::chat::apply_key(&prev, api_key.as_deref(), keys); let prefs = ChatPrefs { base_url: clean(base_url), model: clean(model), - max_tool_calls: crate::chat::apply_tool_cap(&state.chat_prefs(), max_tool_calls.as_deref()), + max_tool_calls: crate::chat::apply_tool_cap(&prev, max_tool_calls.as_deref()), + focus_passages: crate::chat::apply_focus_cap(&prev, focus_passages.as_deref()), api_key, key_remembered, }; @@ -577,7 +582,8 @@ fn chat_setup_impl(state: &AppState) -> ChatSetup { } } -/// The testable core of `ask`. `sink` receives every token, in order, as it arrives. +/// The testable core of `ask`. `sink` receives every token, in order, as it arrives. The +/// focus cap is the one in force (Settings over the environment). fn ask_impl( state: &AppState, vault: &Vault, @@ -587,6 +593,8 @@ fn ask_impl( focus: Option<&str>, sink: &dyn Fn(&str), ) -> Result<AnswerView, CmdError> { + let max_passages = state.chat_prefs().config().focus_passages; + let focus = focus.map(|note| FocusRequest { note, max_passages }); stream_answer(state, sink, |on_token| { vault.ask(llm, question, history, focus, on_token) }) @@ -1579,7 +1587,15 @@ mod tests { let state = AppState::new(None); let keys = MemoryStore::empty(); let save = |cap: Option<&str>| { - set_chat_config_impl(&state, None, None, None, cap.map(str::to_string), &keys); + set_chat_config_impl( + &state, + None, + None, + None, + cap.map(str::to_string), + None, + &keys, + ); state.chat_prefs().config().max_tool_calls }; let default = b2_llm::LlmConfig::from_env().max_tool_calls; @@ -1596,6 +1612,36 @@ mod tests { assert_eq!(setup.tool_calls.in_force, 32); } + #[test] + fn the_focus_cap_is_set_kept_and_cleared_by_a_save() { + let state = AppState::new(None); + let keys = MemoryStore::empty(); + let save = |cap: Option<&str>| { + set_chat_config_impl( + &state, + None, + None, + None, + None, + cap.map(str::to_string), + &keys, + ); + state.chat_prefs().config().focus_passages + }; + let default = b2_llm::LlmConfig::from_env().focus_passages; + assert_eq!(save(Some("3")), 3); + assert_eq!(save(None), 3, "a save that doesn't mention it keeps it"); + assert_eq!(save(Some("65")), 3, "a refused value changes nothing"); + assert_eq!( + save(Some("")), + default, + "blank returns to the shared resolution" + ); + save(Some("12")); + let setup = b2_llm::ChatSetup::fake(&state.chat_prefs().config()); + assert_eq!(setup.focus_passages.in_force, 12); + } + #[test] fn chat_config_layers_over_the_shared_resolution() { let state = AppState::new(None); @@ -1606,6 +1652,7 @@ mod tests { Some("qwen2.5".into()), Some("sk-a-cloud-key".into()), None, + None, &keys, ); let prefs = state.chat_prefs(); @@ -1620,6 +1667,7 @@ mod tests { Some(" ".into()), None, None, + None, &keys, ); let prefs = state.chat_prefs(); @@ -1638,6 +1686,7 @@ mod tests { None, Some("sk-remember-me".into()), None, + None, &keys, ); assert_eq!(keys.peek().as_deref(), Some("sk-remember-me")); @@ -1657,6 +1706,7 @@ mod tests { None, Some("sk-a-cloud-key".into()), None, + None, &keys, ); assert_eq!( @@ -1665,7 +1715,7 @@ mod tests { ); // Absent: untouched. - set_chat_config_impl(&state, None, None, None, None, &keys); + set_chat_config_impl(&state, None, None, None, None, None, &keys); assert_eq!( state.chat_prefs().api_key.as_deref(), Some("sk-a-cloud-key") @@ -1673,7 +1723,7 @@ mod tests { assert_eq!(keys.peek().as_deref(), Some("sk-a-cloud-key")); // Blank (the Remove button): gone from both. `B2_LLM_API_KEY` is beyond its reach. - set_chat_config_impl(&state, None, None, Some(" ".into()), None, &keys); + set_chat_config_impl(&state, None, None, Some(" ".into()), None, None, &keys); assert_eq!(state.chat_prefs().api_key, None); assert_eq!( keys.peek(), @@ -1693,6 +1743,7 @@ mod tests { Some("llama3.2".into()), Some("sk-live-must-not-cross".into()), None, + None, &MemoryStore::empty(), ); let setup = chat_setup_impl(&state); @@ -1713,6 +1764,7 @@ mod tests { None, Some("sk-typed-just-now".into()), None, + None, &keys, ); let prefs = state.chat_prefs(); diff --git a/crates/b2-llm/src/lib.rs b/crates/b2-llm/src/lib.rs index fc80292..b0fc9be 100644 --- a/crates/b2-llm/src/lib.rs +++ b/crates/b2-llm/src/lib.rs @@ -16,7 +16,7 @@ use serde::Serialize; pub use provider::OpenAiCompatProvider; pub use setup::{ model_missing_message, probe_setup, pull_command, refusal_message, unreachable_message, - ChatSetup, ChatState, ModelTier, OllamaModel, OllamaSetup, ToolCallCap, MODEL_TIERS, + CapSetting, ChatSetup, ChatState, ModelTier, OllamaModel, OllamaSetup, MODEL_TIERS, OLLAMA_INSTALL_URL, }; @@ -92,6 +92,30 @@ fn is_valid_tool_call_cap(n: usize) -> bool { (1..=MAX_TOOL_CALLS_CEILING).contains(&n) } +/// Environment variable for [`LlmConfig::focus_passages`]. +pub const ENV_FOCUS_PASSAGES: &str = "B2_LLM_FOCUS_PASSAGES"; + +/// The default for [`LlmConfig::focus_passages`]: the engine's own default. +pub const DEFAULT_FOCUS_PASSAGES: usize = b2_core::chat::FOCUS_PASSAGES; + +/// The highest [`LlmConfig::focus_passages`] from any source: past it, one note would fill +/// a small model's context before retrieval had a say. +pub const FOCUS_PASSAGES_CEILING: usize = 64; + +/// Parse a focus-passage cap, for both [`ENV_FOCUS_PASSAGES`] and the Settings field: a +/// whole number from 1 to [`FOCUS_PASSAGES_CEILING`]. +pub fn parse_focus_passages(raw: &str) -> Option<usize> { + raw.trim() + .parse::<usize>() + .ok() + .filter(|&n| is_valid_focus_cap(n)) +} + +/// 1 (zero would focus on nothing) to [`FOCUS_PASSAGES_CEILING`]. +fn is_valid_focus_cap(n: usize) -> bool { + (1..=FOCUS_PASSAGES_CEILING).contains(&n) +} + /// Where the bearer token in force came from: a fact about the key, safe to show and log /// (GH #176). [`Environment`](Self::Environment) outranks a remembered key /// ([`LlmConfig::with_api_key`]). @@ -124,6 +148,9 @@ pub struct LlmConfig { /// The most tool calls (and highest call `index`) one reply may make; past it, /// [`LlmError::TooManyToolCalls`]. A memory bound, not a tuning knob. pub max_tool_calls: usize, + /// How many of the open note's passages lead a focused ask (GH #253); a note longer + /// than this is trimmed, and the prompt says so. + pub focus_passages: usize, } /// Hand-written so the key can never be logged; only its presence is printed. @@ -141,6 +168,7 @@ impl std::fmt::Debug for LlmConfig { ) .field("api_key_source", &self.api_key_source) .field("max_tool_calls", &self.max_tool_calls) + .field("focus_passages", &self.focus_passages) .finish() } } @@ -154,13 +182,15 @@ impl Default for LlmConfig { api_key: None, api_key_source: ApiKeySource::None, max_tool_calls: DEFAULT_MAX_TOOL_CALLS, + focus_passages: DEFAULT_FOCUS_PASSAGES, } } } impl LlmConfig { /// The defaults overlaid with [`ENV_URL`] / [`ENV_MODEL`] / [`ENV_API_KEY`] / - /// [`ENV_MAX_TOOL_CALLS`], the one base both adapters layer over. Blank reads as unset. + /// [`ENV_MAX_TOOL_CALLS`] / [`ENV_FOCUS_PASSAGES`], the one base both adapters layer + /// over. Blank reads as unset. pub fn from_env() -> Self { Self::resolve(|key| std::env::var(key).ok()) } @@ -176,18 +206,32 @@ impl LlmConfig { let base = Self::default(); let api_key = read(ENV_API_KEY); // An invalid cap is a typo: keep the default, and say so. - let max_tool_calls = match read(ENV_MAX_TOOL_CALLS) { - None => base.max_tool_calls, - Some(raw) => parse_max_tool_calls(&raw).unwrap_or_else(|| { - tracing::warn!( - target: "b2::llm", - value = raw, - default = base.max_tool_calls, - "{ENV_MAX_TOOL_CALLS} is not a whole number from 1 to {MAX_TOOL_CALLS_CEILING}; using the default" - ); - base.max_tool_calls - }), + let cap = |env: &str, ceiling: usize, default: usize, parse: fn(&str) -> Option<usize>| { + match read(env) { + None => default, + Some(raw) => parse(&raw).unwrap_or_else(|| { + tracing::warn!( + target: "b2::llm", + value = raw, + default, + "{env} is not a whole number from 1 to {ceiling}; using the default" + ); + default + }), + } }; + let max_tool_calls = cap( + ENV_MAX_TOOL_CALLS, + MAX_TOOL_CALLS_CEILING, + base.max_tool_calls, + parse_max_tool_calls, + ); + let focus_passages = cap( + ENV_FOCUS_PASSAGES, + FOCUS_PASSAGES_CEILING, + base.focus_passages, + parse_focus_passages, + ); Self { base_url: read(ENV_URL).unwrap_or(base.base_url), model: read(ENV_MODEL).unwrap_or(base.model), @@ -197,6 +241,7 @@ impl LlmConfig { }, api_key, max_tool_calls, + focus_passages, } } @@ -224,6 +269,16 @@ impl LlmConfig { self } + /// Lay an adapter's focus-passage cap over this config, as + /// [`with_max_tool_calls`](Self::with_max_tool_calls) does. An invalid value is ignored. + #[must_use] + pub fn with_focus_passages(mut self, focus_passages: Option<usize>) -> Self { + if let Some(n) = focus_passages.filter(|&n| is_valid_focus_cap(n)) { + self.focus_passages = n; + } + self + } + /// Lay an adapter's bearer token over this config. Unlike /// [`with_overrides`](Self::with_overrides), the environment wins: `B2_LLM_API_KEY` is a /// per-run statement a stored key must not outrank (GH #176). `None` never clears a key. @@ -354,6 +409,56 @@ mod tests { ); } + #[test] + fn the_focus_cap_comes_from_the_environment_and_a_bad_value_keeps_the_default() { + let with = |value: Option<&str>| { + LlmConfig::resolve(|key| { + (key == ENV_FOCUS_PASSAGES) + .then(|| value.map(str::to_string)) + .flatten() + }) + .focus_passages + }; + assert_eq!(with(None), DEFAULT_FOCUS_PASSAGES); + assert_eq!(DEFAULT_FOCUS_PASSAGES, b2_core::chat::FOCUS_PASSAGES); + assert_eq!(with(Some("3")), 3); + assert_eq!(with(Some(" 16 ")), 16); + for bad in ["0", "-1", "lots", "6.4", "", "65"] { + assert_eq!(with(Some(bad)), DEFAULT_FOCUS_PASSAGES, "{bad:?}"); + } + } + + #[test] + fn one_parser_judges_the_focus_cap_for_the_environment_and_for_settings() { + assert_eq!(parse_focus_passages("3"), Some(3)); + assert_eq!(parse_focus_passages(" 16 "), Some(16)); + assert_eq!( + parse_focus_passages(&FOCUS_PASSAGES_CEILING.to_string()), + Some(FOCUS_PASSAGES_CEILING) + ); + assert_eq!( + parse_focus_passages(&(FOCUS_PASSAGES_CEILING + 1).to_string()), + None + ); + for bad in ["0", "-1", "lots", "6.4", ""] { + assert_eq!(parse_focus_passages(bad), None, "{bad:?}"); + } + } + + #[test] + fn an_adapters_focus_cap_beats_the_environments_and_none_keeps_it() { + let env = LlmConfig::resolve(|key| (key == ENV_FOCUS_PASSAGES).then(|| "3".to_string())); + assert_eq!(env.focus_passages, 3); + assert_eq!(env.clone().with_focus_passages(Some(12)).focus_passages, 12); + assert_eq!(env.clone().with_focus_passages(None).focus_passages, 3); + assert_eq!(env.clone().with_focus_passages(Some(0)).focus_passages, 3); + assert_eq!( + env.with_focus_passages(Some(FOCUS_PASSAGES_CEILING + 1)) + .focus_passages, + 3 + ); + } + #[test] fn debug_never_prints_the_api_key() { let config = LlmConfig { @@ -372,6 +477,7 @@ mod tests { assert!(rendered.contains("redacted"), "{rendered}"); assert!(rendered.contains("api.example.com"), "{rendered}"); assert!(rendered.contains("Stored"), "{rendered}"); + assert!(rendered.contains("focus_passages: 8"), "{rendered}"); assert!( format!("{:?}", LlmConfig::default()).contains("api_key: \"None\""), "the local configuration says so plainly" diff --git a/crates/b2-llm/src/setup.rs b/crates/b2-llm/src/setup.rs index a256d37..d61f08d 100644 --- a/crates/b2-llm/src/setup.rs +++ b/crates/b2-llm/src/setup.rs @@ -189,30 +189,41 @@ pub struct ChatSetup { /// The Ollama-native onboarding half; `None` for any other runtime. pub ollama: Option<OllamaSetup>, /// The tool-call cap, as a Settings field needs it. - pub tool_calls: ToolCallCap, + pub tool_calls: CapSetting, + /// The focus-passage cap (GH #253), as a Settings field needs it. + pub focus_passages: CapSetting, } -/// [`LlmConfig::max_tool_calls`] for a surface that edits it, with its default and ceiling -/// so the frontend never advertises a range the parser would refuse. +/// One numeric chat cap for a surface that edits it, with its default and ceiling so the +/// frontend never advertises a range the parser would refuse. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub struct ToolCallCap { +pub struct CapSetting { /// The cap in force: Settings over the environment over the default. pub in_force: usize, - /// [`crate::DEFAULT_MAX_TOOL_CALLS`]. + /// What clearing the field returns to, absent an environment override. pub default: usize, - /// [`crate::MAX_TOOL_CALLS_CEILING`]. + /// The highest value any source may set. pub ceiling: usize, } -impl ToolCallCap { - /// The cap as `config` resolves it. - pub fn of(config: &LlmConfig) -> Self { +impl CapSetting { + /// [`LlmConfig::max_tool_calls`] as `config` resolves it. + pub fn tool_calls(config: &LlmConfig) -> Self { Self { in_force: config.max_tool_calls, default: crate::DEFAULT_MAX_TOOL_CALLS, ceiling: crate::MAX_TOOL_CALLS_CEILING, } } + + /// [`LlmConfig::focus_passages`] as `config` resolves it. + pub fn focus_passages(config: &LlmConfig) -> Self { + Self { + in_force: config.focus_passages, + default: crate::DEFAULT_FOCUS_PASSAGES, + ceiling: crate::FOCUS_PASSAGES_CEILING, + } + } } impl ChatSetup { @@ -227,7 +238,8 @@ impl ChatSetup { message: Some(FAKE_NOTICE.to_string()), available: Vec::new(), ollama: None, - tool_calls: ToolCallCap::of(config), + tool_calls: CapSetting::tool_calls(config), + focus_passages: CapSetting::focus_passages(config), } } } @@ -277,7 +289,8 @@ pub fn probe_setup(config: &LlmConfig) -> ChatSetup { message, available, ollama, - tool_calls: ToolCallCap::of(config), + tool_calls: CapSetting::tool_calls(config), + focus_passages: CapSetting::focus_passages(config), } } @@ -633,7 +646,8 @@ mod tests { message: None, available: Vec::new(), ollama: None, - tool_calls: ToolCallCap::of(&config), + tool_calls: CapSetting::tool_calls(&config), + focus_passages: CapSetting::focus_passages(&config), }; let json = serde_json::to_string(&setup).unwrap(); assert!(!json.contains("sk-live-do-not-serialize-me"), "{json}"); @@ -641,6 +655,10 @@ mod tests { json.contains("\"tool_calls\":{\"in_force\":64,\"default\":64,\"ceiling\":4096}"), "{json}" ); + assert!( + json.contains("\"focus_passages\":{\"in_force\":8,\"default\":8,\"ceiling\":64}"), + "{json}" + ); assert!(json.contains("\"api_key_source\":\"stored\""), "{json}"); assert!(json.contains("\"cloud\":true"), "{json}"); } diff --git a/docs/index-engine.md b/docs/index-engine.md index 19f7def..a4011c7 100644 --- a/docs/index-engine.md +++ b/docs/index-engine.md @@ -535,7 +535,8 @@ swapping chat models never touches the index, so a provider swap is a URL or con query. On failure it degrades to the raw question, so this step can never break chat. 2. **Retrieve.** `search_chunks` at `ASK_PASSAGES = 10`: the §4 pipeline unchanged, BM25-only fallback included. With a **focus** note (the one being viewed, GH #253), its first - `FOCUS_PASSAGES = 8` passages (`db::note_chunk_ids` + `chunk_detail`) are numbered first and + `max_passages` passages (`db::note_chunk_ids` + `chunk_detail`; the adapter passes + `LlmConfig::focus_passages`, default `FOCUS_PASSAGES = 8`) are numbered first and retrieval's passages follow, minus repeats; the prompt, and the condense prompt, name the note, and a note cut short by the cap says so in one line. No focus, no change. 3. **Assemble.** The grounded system prompt plus numbered passages. Prompt assembly is core diff --git a/docs/quickstart.md b/docs/quickstart.md index 2b06600..7a19d63 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -346,8 +346,9 @@ Sources: ``` `--note PATH` says which note the question is about, so "this note" and "this doc" have a -referent. That note's passages come first (up to 8, in order; the model is told when a long -note was cut short), then the usual retrieval fills in from the rest of the vault: +referent. That note's passages come first (up to 8, in order, set by +`B2_LLM_FOCUS_PASSAGES`; the model is told when a long note was cut short), then the usual +retrieval fills in from the rest of the vault: ```console $ b2 ask --note notes/spaced-repetition.md "what is this note missing?" @@ -498,6 +499,7 @@ 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 `gemma4:e2b`). 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_FOCUS_PASSAGES` | How many of the focus note's passages lead `ask --note`, `chat --note` and the desktop's chat about the open note (default 8, allowed 1 to 64). A longer note is cut there, and the model is told how many it got. An invalid value keeps the default and logs a warning. The desktop's Settings → Chat field overrides it | | `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=debug` (B2's own targets only, so Tauri and HTTP records stay out) | diff --git a/ui/src/api.ts b/ui/src/api.ts index 4573850..113dbdf 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -265,17 +265,18 @@ export const api = { /** * Save the chat configuration and re-probe it (adapter state; no reindex, contrast M2). - * `apiKey` and `maxToolCalls` are three-state: `null` keeps what is in force, `""` clears, - * a value sets. The key never comes back; the setup carries `api_key_source` - * (`b2-desktop/src/keychain.rs`). + * `apiKey`, `maxToolCalls` and `focusPassages` are three-state: `null` keeps what is in + * force, `""` clears, a value sets. The key never comes back; the setup carries + * `api_key_source` (`b2-desktop/src/keychain.rs`). */ setChatConfig: ( baseUrl: string | null, model: string | null, apiKey: string | null, maxToolCalls: string | null = null, + focusPassages: string | null = null, ): Promise<ChatSetup> => - invoke("set_chat_config", { baseUrl, model, apiKey, maxToolCalls }), + invoke("set_chat_config", { baseUrl, model, apiKey, maxToolCalls, focusPassages }), /** The embedding models B2 offers, flagged current + installed (Settings picker). */ listModels: (): Promise<ModelChoice[]> => invoke("list_models"), diff --git a/ui/src/chat.test.ts b/ui/src/chat.test.ts index bd68dcb..e694496 100644 --- a/ui/src/chat.test.ts +++ b/ui/src/chat.test.ts @@ -12,6 +12,7 @@ import { chatRows, citationRowKey, errorMessage, + focusCapInput, focusChipLabel, formatModelSize, modelDetail, @@ -36,6 +37,7 @@ const setup = (over: Partial<ChatSetup> = {}): ChatSetup => ({ available: [], ollama: null, tool_calls: { in_force: 64, default: 64, ceiling: 4096 }, + focus_passages: { in_force: 8, default: 8, ceiling: 64 }, ...over, }); @@ -236,6 +238,21 @@ test("the tool-call cap field is untouched, cleared, set, or refused — before assert.ok("error" in toolCapInput("200", { in_force: 64, default: 64, ceiling: 100 })); }); +test("the focus cap field follows the same three states, and its refusal names it", () => { + const cap = { in_force: 8, default: 8, ceiling: 64 }; + assert.deepEqual(focusCapInput("8", cap), { send: null }); + assert.deepEqual(focusCapInput("", cap), { send: "" }); + assert.deepEqual(focusCapInput(" 3 ", cap), { send: "3" }); + assert.deepEqual(focusCapInput("64", cap), { send: "64" }); + for (const bad of ["0", "65", "-1", "2.5", "lots"]) { + const verdict = focusCapInput(bad, cap); + assert.ok("error" in verdict, `${bad} should be refused`); + assert.match(verdict.error, /^Focus passages must be a whole number from 1 to 64\.$/); + } + const toolVerdict = toolCapInput("0", { in_force: 64, default: 64, ceiling: 4096 }); + assert.ok("error" in toolVerdict && toolVerdict.error.startsWith("Tool calls per reply")); +}); + test("the pull command is spelled in one place", () => { assert.equal(pullCommand("llama3.1:8b"), "ollama pull llama3.1:8b"); }); diff --git a/ui/src/chat.ts b/ui/src/chat.ts index 13385e7..b71cebe 100644 --- a/ui/src/chat.ts +++ b/ui/src/chat.ts @@ -12,8 +12,8 @@ import type { ChatSetup, ChatTurn, Citation, + CapSetting, OllamaModel, - ToolCallCap, ToolUse, } from "./types"; @@ -72,25 +72,35 @@ export function toolsLine(tools: readonly ToolUse[] = []): string { return names.length === 0 ? "" : `Looked up with B2 tools: ${names.join(", ")}`; } +/** What a cap field sends with a save, or why it can't. */ +export type CapVerdict = { send: string | null } | { error: string }; + /** - * What the **Tool calls per reply** field sends with a save, per the host's three-state - * rule (`apply_tool_cap`): `null` when untouched (storing the shown value would pin today's - * default over a later env var), `""` when cleared, the number when set, or an `error`. + * What a cap field sends with a save, per the host's three-state rule (`apply_cap`): + * `null` when untouched (storing the shown value would pin today's default over a later + * env var), `""` when cleared, the number when set, or an `error` naming the field. */ -export function toolCapInput( - raw: string, - cap: ToolCallCap, -): { send: string | null } | { error: string } { +function capInput(raw: string, cap: CapSetting, label: string): CapVerdict { const typed = raw.trim(); if (typed === "") return { send: "" }; if (typed === String(cap.in_force)) return { send: null }; // Digits only: `Number()` would wave through "1e3", " 12 " and "0x10". const n = /^\d+$/.test(typed) ? Number(typed) : Number.NaN; if (!Number.isSafeInteger(n) || n < 1 || n > cap.ceiling) - return { error: `Tool calls per reply must be a whole number from 1 to ${cap.ceiling}.` }; + return { error: `${label} must be a whole number from 1 to ${cap.ceiling}.` }; return { send: String(n) }; } +/** The **Tool calls per reply** field (`apply_tool_cap`). */ +export function toolCapInput(raw: string, cap: CapSetting): CapVerdict { + return capInput(raw, cap, "Tool calls per reply"); +} + +/** The **Focus passages** field (`apply_focus_cap`, GH #253). */ +export function focusCapInput(raw: string, cap: CapSetting): CapVerdict { + return capInput(raw, cap, "Focus passages"); +} + /** The note a question is about, by path (its identity) and title. */ export interface ChatFocus { path: string; diff --git a/ui/src/main.ts b/ui/src/main.ts index cb24814..97bec4f 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -60,6 +60,8 @@ import { chatReady, errorMessage, LOCAL_CHAT_ENDPOINT, + type CapVerdict, + focusCapInput, toolCapInput, userMessage, whyQuestion, @@ -2092,24 +2094,36 @@ async function saveChatConfig(): Promise<void> { // Empty is `null` (keep): the field paints empty even when a key is set. Removal is // `clearChatKey`. const key = value("settings-chat-key"); - // Validate the tool-call cap before sending anything. - const capField = document.getElementById("settings-chat-tool-cap") as HTMLInputElement | null; - const cap = - capField && state.chatSetup - ? toolCapInput(capField.value, state.chatSetup.tool_calls) - : { send: null }; - if ("error" in cap) { - flash(cap.error); - capField?.focus(); - return; - } - await applyChatConfig(url, model, key, cap.send, (setup) => + // Validate the caps before sending anything. + const setup = state.chatSetup; + const toolCap = capField("settings-chat-tool-cap", (raw) => + setup ? toolCapInput(raw, setup.tool_calls) : { send: null }, + ); + if (toolCap === undefined) return; + const focusCap = capField("settings-chat-focus-cap", (raw) => + setup ? focusCapInput(raw, setup.focus_passages) : { send: null }, + ); + if (focusCap === undefined) return; + await applyChatConfig(url, model, key, toolCap, focusCap, (setup) => setup.state === "ready" ? `Chat model saved — connected to ${setup.model}.` : (setup.message ?? "Chat settings saved."), ); } +/** A cap field's value to send, or `undefined` after flashing its refusal and focusing it. */ +function capField(id: string, judge: (raw: string) => CapVerdict): string | null | undefined { + const field = document.getElementById(id) as HTMLInputElement | null; + if (!field) return null; + const verdict = judge(field.value); + if ("error" in verdict) { + flash(verdict.error); + field.focus(); + return undefined; + } + return verdict.send; +} + /** The setup card's installed-model list: pick one to configure it. */ async function useChatModel(model: string): Promise<void> { // Send the endpoint explicitly: `null` would reset it to the default. The key's `null` @@ -2119,6 +2133,7 @@ async function useChatModel(model: string): Promise<void> { model, null, null, + null, () => `Chat model set to ${model}.`, ); } @@ -2133,6 +2148,7 @@ async function clearChatKey(): Promise<void> { state.chatSetup?.model ?? null, "", null, + null, // The returned source is the outcome: still stored/session means the Keychain // refused, and the key would return at next launch. (setup) => @@ -2148,10 +2164,11 @@ async function applyChatConfig( model: string | null, apiKey: string | null, maxToolCalls: string | null, + focusPassages: string | null, said: (setup: ChatSetup) => string, ): Promise<void> { try { - const setup = await api.setChatConfig(baseUrl, model, apiKey, maxToolCalls); + const setup = await api.setChatConfig(baseUrl, model, apiKey, maxToolCalls, focusPassages); adoptChatSetup(setup); flash(said(setup)); } catch (e) { diff --git a/ui/src/render.test.ts b/ui/src/render.test.ts index 98c2edf..a12deca 100644 --- a/ui/src/render.test.ts +++ b/ui/src/render.test.ts @@ -124,6 +124,7 @@ function chatSetup(over: Partial<ChatSetup> = {}): ChatSetup { available: [], ollama: null, tool_calls: { in_force: 64, default: 64, ceiling: 4096 }, + focus_passages: { in_force: 8, default: 8, ceiling: 64 }, ...over, }; } @@ -897,6 +898,20 @@ check("Settings → Chat offers the tool-call cap, painted from the host's own n assert(html.includes("B2_LLM_MAX_TOOL_CALLS"), "and names the variable Settings overrides"); }); +check("Settings → Chat offers the focus cap beside it, painted from the host's own numbers", () => { + const html = chatTab({ focus_passages: { in_force: 12, default: 8, ceiling: 64 } }); + const field = tagWith(html, 'id="settings-chat-focus-cap"'); + assert(field.startsWith("<input"), `a field: ${field}`); + assert(field.includes('value="12"'), `it shows the cap in force: ${field}`); + assert(field.includes('type="text"') && field.includes('inputmode="numeric"'), field); + assert(html.includes("Default 8") && html.includes("up to 64"), "the copy quotes the host's range"); + assert(html.includes("B2_LLM_FOCUS_PASSAGES"), "and names the variable Settings overrides"); + assert( + html.indexOf('id="settings-chat-tool-cap"') < html.indexOf('id="settings-chat-focus-cap"'), + "next to Tool calls per reply, in the form's tab order", + ); +}); + check("an answer built with tools says so, escaped like everything a model names", () => { const html = sidePaneHtml( app({ diff --git a/ui/src/settingsview.ts b/ui/src/settingsview.ts index 7e8a740..b127229 100644 --- a/ui/src/settingsview.ts +++ b/ui/src/settingsview.ts @@ -125,6 +125,7 @@ function chatPanelHtml(state: AppState): string { ${chatModelFieldHtml(state, setup)} ${key} ${toolCapFieldHtml(setup)} + ${focusCapFieldHtml(setup)} <div class="settings-action"> <button class="btn small primary" id="settings-chat-save">Save and test</button> </div> @@ -149,6 +150,24 @@ function toolCapFieldHtml(setup: ChatSetup | null): string { field to go back to the default. Overrides <code>B2_LLM_MAX_TOOL_CALLS</code>.</p>`; } +/** + * Focus passages (`LlmConfig::focus_passages`, GH #253): how much of the open note leads a + * question about it, validated by chat.ts's `focusCapInput`. A text input for the same + * reason as `toolCapFieldHtml`. + */ +function focusCapFieldHtml(setup: ChatSetup | null): string { + if (!setup) return ""; + const cap = setup.focus_passages; + return `<label class="field">Focus passages + <input id="settings-chat-focus-cap" type="text" inputmode="numeric" autocomplete="off" + spellcheck="false" value="${cap.in_force}" placeholder="${cap.default}" /> + </label> + <p class="settings-detail muted">How many passages of the open note chat reads first, + in order, before searching the rest of the vault. A longer note is cut there, and the + model is told so. Default ${cap.default}, up to ${cap.ceiling}; clear the field to go + back to the default. Overrides <code>B2_LLM_FOCUS_PASSAGES</code>.</p>`; +} + /** * The Model field: a picker over what the daemon has installed, or a text box when there is * no inventory or the user is naming a model still being pulled (`chatModelTyped`). Both diff --git a/ui/src/types.ts b/ui/src/types.ts index c95b055..746d549 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -139,12 +139,14 @@ export interface ChatSetup { ollama: OllamaSetup | null; /** The tool-call cap and its bounds, from the host so Settings can't advertise a range * the parser refuses. */ - tool_calls: ToolCallCap; + tool_calls: CapSetting; + /** How many of the open note's passages lead a focused ask (GH #253), with its bounds. */ + focus_passages: CapSetting; } -/** `b2-llm`'s `ToolCallCap`: the most tool calls one model reply may make. */ -export interface ToolCallCap { - /** The cap in force — Settings over `B2_LLM_MAX_TOOL_CALLS` over the default. */ +/** `b2-llm`'s `CapSetting`: one numeric chat cap as a Settings field needs it. */ +export interface CapSetting { + /** The cap in force — Settings over its environment variable over the default. */ in_force: number; /** What clearing the field returns to, absent an environment override. */ default: number; From 6b050e85a78948e0de326d51392d09768cae4ac7 Mon Sep 17 00:00:00 2001 From: Sam Keen <sam.sjk@gmail.com> Date: Sun, 8 Nov 2026 13:00:10 -0800 Subject: [PATCH 3/3] Tell the model work on the focus note is a task, not a question to refuse Ported from PR #254's 821fd33, which recorded the finding on the user's vault: with every passage of the open note present, gemma4 still answered "I don't find that in your notes" to "clean up this note", because the grounded prompt frames everything as a question the passages must answer. When the focus note has text, the grounded prompt now adds one sentence saying a request to summarise, clean up, rewrite, outline or shorten the note is a task to do with its text, and to keep the no-evidence reply for questions the passages don't cover. The condense prompt and the no-focus request are unchanged. Part of #253 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- crates/b2-core/src/chat.rs | 16 ++++++++++++++- crates/b2-core/tests/ask.rs | 40 +++++++++++++++++++++++++++++++++++++ docs/index-engine.md | 4 +++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/b2-core/src/chat.rs b/crates/b2-core/src/chat.rs index 4ba192a..a8f4a0a 100644 --- a/crates/b2-core/src/chat.rs +++ b/crates/b2-core/src/chat.rs @@ -62,9 +62,23 @@ fn focus_sentence(path: &str) -> String { format!("The user is viewing {path}; \"this note\" or \"this doc\" means it.") } +/// What the grounded prompt adds when the focus note has text: a request about the open +/// note is usually work on its text, not a question the text answers. Without it, gemma4 +/// read "clean up this note" as unsupported and refused, with every passage present (GH +/// #253). +const FOCUS_TASK_SENTENCE: &str = "A request to summarise, clean up, rewrite, outline, \ +shorten or otherwise work on this note is a task to do with its text: do it, using only \ +what the passages say, and cite the passages you drew on. Reserve the no-evidence reply for \ +a question these passages genuinely do not cover."; + /// The focus lines of the grounded prompt; a trimmed note says so (limits are loud). fn focus_lines(focus: &Focus) -> String { - let sentence = focus_sentence(&focus.path); + let named = focus_sentence(&focus.path); + let sentence = if focus.included > 0 { + format!("{named} {FOCUS_TASK_SENTENCE}") + } else { + named + }; if focus.included < focus.total { format!( "{sentence}\nOnly the first {} of its {} passages are included.", diff --git a/crates/b2-core/tests/ask.rs b/crates/b2-core/tests/ask.rs index 6afc2dd..e45e339 100644 --- a/crates/b2-core/tests/ask.rs +++ b/crates/b2-core/tests/ask.rs @@ -825,3 +825,43 @@ fn an_unknown_focus_note_is_note_not_found_and_asks_the_model_nothing() { ); assert!(rec.take().is_empty(), "nothing is asked of the model"); } + +/// "Clean up this note" is work on the text, not a question the text answers: with every +/// passage present, gemma4 still gave the no-evidence reply without this sentence (GH #253, +/// PR #254's 821fd33). +#[test] +fn a_focused_prompt_treats_work_on_the_note_as_a_task_only_when_it_has_text() { + let task = "is a task to do with its text"; + let passage = ContextPassage { + path: "notes/a.md".into(), + heading_path: None, + text: "the passage".into(), + }; + let with_text = chat::Focus { + path: "notes/a.md".into(), + included: 1, + total: 1, + }; + let req = chat::build_request("clean up this note", &[], vec![passage], Some(&with_text)); + assert!(req.system.contains(task), "{}", req.system); + assert!( + req.system.contains("Reserve the no-evidence reply"), + "{}", + req.system + ); + + // An unindexed or empty note has no text to work on. + let no_text = chat::Focus { + path: "notes/a.md".into(), + included: 0, + total: 0, + }; + let req = chat::build_request("clean up this note", &[], Vec::new(), Some(&no_text)); + assert!(!req.system.contains(task), "{}", req.system); + + // The condense prompt rewrites a query; it does no work on the note. + let rec = Recording::new(); + chat::condense_query(&rec, "tidy it", &[ChatTurn::user("x")], Some("notes/a.md")); + let condense = rec.take(); + assert!(!condense[0].system.contains(task), "{}", condense[0].system); +} diff --git a/docs/index-engine.md b/docs/index-engine.md index a4011c7..b2fabed 100644 --- a/docs/index-engine.md +++ b/docs/index-engine.md @@ -538,7 +538,9 @@ swapping chat models never touches the index, so a provider swap is a URL or con `max_passages` passages (`db::note_chunk_ids` + `chunk_detail`; the adapter passes `LlmConfig::focus_passages`, default `FOCUS_PASSAGES = 8`) are numbered first and retrieval's passages follow, minus repeats; the prompt, and the condense prompt, name the - note, and a note cut short by the cap says so in one line. No focus, no change. + note, and a note cut short by the cap says so in one line. The grounded prompt also says + that work on the note (summarise, clean up, rewrite) is a task to do with its text, since a + small model otherwise refuses it as unsupported. No focus, no change. 3. **Assemble.** The grounded system prompt plus numbered passages. Prompt assembly is core logic, not an adapter's. 4. **Stream.** Tokens flow up through the caller's callback, whose return value cancels at