Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions crates/b2-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ pub enum Command {
Ask {
/// The question, in plain language.
question: String,
/// The note you are asking about: a vault-relative path. Its passages are
/// numbered first and the model is told "this note" means it.
#[arg(long, value_name = "PATH")]
note: Option<String>,
#[command(flatten)]
llm: LlmArgs,
},
Expand All @@ -211,6 +215,9 @@ pub enum Command {
/// mid-stream (the partial text stands); `/exit` or Ctrl-D leaves. History is
/// session-only: nothing about a chat is ever written to your notes or the index.
Chat {
/// The note the conversation is about, as `ask --note`, for every turn.
#[arg(long, value_name = "PATH")]
note: Option<String>,
#[command(flatten)]
llm: LlmArgs,
},
Expand Down
41 changes: 31 additions & 10 deletions crates/b2-cli/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,31 @@
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};
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,
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.
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();
}
Expand All @@ -31,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);
Expand All @@ -46,8 +57,13 @@ pub fn cmd_why(
Ok(())
}

pub fn cmd_chat(cli: &Cli, 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);
Expand All @@ -63,6 +79,9 @@ pub fn cmd_chat(cli: &Cli, llm_args: &LlmArgs) -> Result<(), CliError> {
Nothing here is saved.",
llm.model_id()
);
if let Some(note) = note {
eprintln!("About: {note}. \"This note\" in a question means it.");
}
}
if !cli.json {
note_fake_llm();
Expand All @@ -86,8 +105,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.
Expand All @@ -108,9 +128,10 @@ fn ask_streamed(
llm: &dyn LlmProvider,
question: &str,
history: &[ChatTurn],
focus: Option<FocusRequest<'_>>,
json: bool,
) -> Result<AnswerView, CliError> {
let answer = vault.ask(llm, question, history, &mut |token| {
let answer = vault.ask(llm, question, history, focus, &mut |token| {
stream_token(token, json)
})?;
finish_answer(&answer, json);
Expand Down
8 changes: 6 additions & 2 deletions crates/b2-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down
13 changes: 8 additions & 5 deletions crates/b2-cli/src/wiring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ use b2_core::vault::Vault;
use b2_llm::LlmConfig;
use std::path::Path;

/// The chat provider: the fake under `B2_LLM=fake`, else the real client from the
/// environment plus flags, probed first so a stopped daemon fails before the question.
pub fn open_llm(args: &LlmArgs) -> Result<Box<dyn LlmProvider>, CliError> {
let config =
LlmConfig::from_env().with_overrides(args.llm_url.as_deref(), args.llm_model.as_deref());
/// The chat configuration: the environment plus flags.
pub fn llm_config(args: &LlmArgs) -> LlmConfig {
LlmConfig::from_env().with_overrides(args.llm_url.as_deref(), args.llm_model.as_deref())
}

/// The chat provider: the fake under `B2_LLM=fake`, else the real client for `config`,
/// probed first so a stopped daemon fails before the question.
pub fn open_llm(config: LlmConfig) -> Result<Box<dyn LlmProvider>, CliError> {
Ok(b2_llm::probed_provider(config)?)
}

Expand Down
29 changes: 29 additions & 0 deletions crates/b2-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
84 changes: 79 additions & 5 deletions crates/b2-core/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ use std::ops::ControlFlow;
/// so chat adds no new candidate width (GH #141/#142).
pub const ASK_PASSAGES: usize = 10;

/// 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 \
Expand All @@ -30,13 +45,66 @@ 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 [`FocusRequest::max_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.")
}

/// 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 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.",
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(),
Expand All @@ -62,15 +130,21 @@ pub fn condense_query(llm: &dyn LlmProvider, question: &str, history: &[ChatTurn
}
}

/// Step 2: assemble the grounded chat request.
/// Step 2: assemble the grounded chat request. With a `focus`, its passages are expected
/// to lead `passages`.
pub fn build_request(
question: &str,
history: &[ChatTurn],
passages: Vec<ContextPassage>,
focus: Option<&Focus>,
) -> ChatRequest {
let system = match focus {
Some(f) => format!("{GROUNDED_SYSTEM_PROMPT}\n\n{}", focus_lines(f)),
None => GROUNDED_SYSTEM_PROMPT.to_string(),
};
ChatRequest {
kind: RequestKind::Chat,
system: GROUNDED_SYSTEM_PROMPT.to_string(),
system,
turns: turns_with_question(history, question),
passages,
tools: Vec::new(),
Expand Down
Loading
Loading