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
---
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,
#[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,
#[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 {
- 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,
+ 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 {
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 = self
+ let retrieved: Vec = 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)> {
+ 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::>>()?;
+ 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::::new());
}
+
+// --- the focus note (GH #253) -----------------------------------------------------
+
+/// Records every request it is handed, then answers as [`FakeLlm`].
+struct Recording {
+ requests: RefCell>,
+}
+
+impl Recording {
+ fn new() -> Self {
+ Self {
+ requests: RefCell::new(Vec::new()),
+ }
+ }
+
+ fn take(&self) -> Vec {
+ 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 {
+ 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 {
+ 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 = 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,
+ focus: Option,
on_event: Channel,
) -> Result {
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 {
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::::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::::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 = 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) -> 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 ` | Replace a note's body from stdin: the scripting/agent editing surface. Frontmatter is left alone | no |
| `b2 mv ` | Move/rename a note, file, or folder and repair every inbound link | re-embeds touched |
| `b2 rm ` | 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 ` | 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 ` | 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 => {
const channel = new Channel();
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
+ ? `${escapeHtml(focusChipLabel(focus))}`
+ : "";
return ``;
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 {
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
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
---
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>,
json: bool,
) -> Result {
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, 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, 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>,
on_token: &mut dyn FnMut(&str) -> ControlFlow<()>,
) -> Result {
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)> {
- 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)> {
+ 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> {
+ Some(FocusRequest {
+ note,
+ max_passages: chat::FOCUS_PASSAGES,
+ })
+}
+
/// What retrieval hands the model for `query`, as passages.
fn retrieved(vault: &Vault, query: &str) -> Vec {
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,
+ /// The focus-passage cap the user set, if any (`LlmConfig::focus_passages`).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub focus_passages: Option,
/// 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 {
+ 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 {
+ 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,
+ typed: Option<&str>,
+ parse: fn(&str) -> Option,
+) -> Option {
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,
api_key: Option,
max_tool_calls: Option,
+ focus_passages: Option,
) -> 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,
api_key: Option,
max_tool_calls: Option,
+ focus_passages: Option,
keys: &dyn crate::keychain::KeyStore,
) -> ChatPrefs {
let clean = |v: Option| {
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 {
+ 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 {
+ raw.trim()
+ .parse::()
+ .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| {
+ 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) -> 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,
/// 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 =>
- 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 => 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 => ({
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 {
// 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 {
// 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 {
model,
null,
null,
+ null,
() => `Chat model set to ${model}.`,
);
}
@@ -2133,6 +2148,7 @@ async function clearChatKey(): Promise {
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 {
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 {
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(" {
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)}
@@ -149,6 +150,24 @@ function toolCapFieldHtml(setup: ChatSetup | null): string {
field to go back to the default. Overrides B2_LLM_MAX_TOOL_CALLS.
`;
}
+/**
+ * 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 `
+
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 B2_LLM_FOCUS_PASSAGES.
`;
+}
+
/**
* 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
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
---
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