@hypercarrier/rarebit 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-10-07
4
+
5
+ - Add `/rarebit btw [--rarebits] [--readonly] [question]`. It opens a
6
+ temporary side Pi in a Herdr split on a private snapshot of the active
7
+ branch, so you can ask about history while the main agent keeps working.
8
+ `--rarebits` snapshots only Rarebit messages. By default the side Pi
9
+ inherits the parent's active tools; `--readonly` limits it to read-only
10
+ built-in tools without extensions.
11
+ - Add the optional `rarebit.btw.model` setting and a BTW model field in
12
+ Rarebit settings. Unset, BTW uses the parent model and thinking level.
13
+ - Autocomplete `/rarebit btw` flags. Add BTW actions to the command palette.
14
+
15
+ ## 0.4.0 — 2026-10-07
16
+
17
+ - Replace Recall's hour-bucketed `rarebit_conversation/v1` with a flat
18
+ `rarebit_conversation/v2` message list. Each message has `seq`, `round`,
19
+ `role`, `kind`, second-precision UTC `time`, and `text`. Detailed evidence
20
+ occurrences gain the same `seq` join key.
21
+ - Add the conversation schema and `jq` tail queries to the Recall message.
22
+
3
23
  ## 0.3.0 — 2026-10-04
4
24
 
5
25
  - Add `/rarebit view [context|all|peek|off]` and a View action in the palette.
package/README.md CHANGED
@@ -173,6 +173,30 @@ npm exec -- piq entries --session /absolute/path/to/session.jsonl
173
173
  PiQ is read-only JSONL output for `jq`. It does not classify Rarebits, mutate a
174
174
  Session, invoke a model, or migrate legacy entries.
175
175
 
176
+ #### BTW: side questions while the agent works
177
+
178
+ `/rarebit btw` opens a temporary Pi in a Herdr split beside the current pane.
179
+ Ask it about the history while the main agent keeps working. The main Session
180
+ is unchanged.
181
+
182
+ ```text
183
+ /rarebit btw What did we decide about the release version?
184
+ /rarebit btw --rarebits --readonly Why did we drop the old schema?
185
+ ```
186
+
187
+ - By default, BTW copies the active branch. `--rarebits` copies only the
188
+ Rarebit messages, which is cheaper and reaches past compaction.
189
+ - By default, the side Pi loads extensions and enables the parent's active
190
+ tools. `--readonly` enables only `read`, `grep`, `find`, and `ls`, and loads
191
+ no extensions, skills, context files, or MCP servers.
192
+ - The side Pi uses `rarebit.btw.model` when set, for example
193
+ `"btw": { "model": "provider/model:low" }`. Otherwise it uses the parent
194
+ model and thinking level.
195
+ - A new BTW replaces the earlier BTW pane in the tab. Closing the pane deletes
196
+ the private snapshot in the OS temp directory.
197
+
198
+ BTW requires Pi inside Herdr.
199
+
176
200
  ### 4. Summary, Title, and Recap
177
201
 
178
202
  Summary and Title are optional derivations over the selected evidence. Recap is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypercarrier/rarebit",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Sparse, provenance-bearing conversational evidence recovery for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "HyperCarrier contributors",
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Live Rarebit BTW canary in Herdr with the installed Pi and a real model.
4
+ *
5
+ * Uses only synthetic Session prose in a disposable directory. It opens an
6
+ * unfocused Herdr tab, starts Pi with this Rarebit checkout on the synthetic
7
+ * Session, types `/rarebit btw ...`, and checks that the side pane answers a
8
+ * question that needs the history but is not a copy of it. It then checks
9
+ * pane replacement and snapshot cleanup.
10
+ *
11
+ * Env: RAREBIT_BTW_E2E_MODEL (default magpie/claude/claude-haiku-4-5).
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import { execFileSync } from "node:child_process";
15
+ import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { dirname, join, resolve } from "node:path";
18
+
19
+ const packageRoot = resolve(dirname(new URL(import.meta.url).pathname), "..");
20
+ const herdrBin = process.env.HERDR_BIN_PATH || "herdr";
21
+ const model = process.env.RAREBIT_BTW_E2E_MODEL ?? "magpie/claude/claude-haiku-4-5";
22
+ const LABEL = "rarebit-btw";
23
+
24
+ const herdr = (...args) => {
25
+ const out = execFileSync(herdrBin, args, { encoding: "utf8", timeout: 120_000 });
26
+ return out.trim() ? JSON.parse(out) : {};
27
+ };
28
+ const herdrText = (...args) => execFileSync(herdrBin, args, { encoding: "utf8", timeout: 120_000 });
29
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
30
+ const quote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
31
+
32
+ function assistantMessage(text) {
33
+ return {
34
+ role: "assistant", content: [{ type: "text", text }], api: "synthetic", provider: "synthetic",
35
+ model: "synthetic", stopReason: "stop", timestamp: Date.parse("2026-10-07T00:00:02Z"),
36
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
37
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
38
+ };
39
+ }
40
+
41
+ async function syntheticSession(directory) {
42
+ const file = join(directory, "source.jsonl");
43
+ const lines = [
44
+ { type: "session", version: 3, id: "btw-e2e-source", timestamp: "2026-10-07T00:00:00.000Z", cwd: directory },
45
+ { type: "message", id: "u1", parentId: null, timestamp: "2026-10-07T00:00:01.000Z",
46
+ message: { role: "user", content: "Plan: the synthetic deploy window opens at 14:30 UTC and lasts 95 minutes.", timestamp: Date.parse("2026-10-07T00:00:01Z") } },
47
+ { type: "message", id: "a1", parentId: "u1", timestamp: "2026-10-07T00:00:02.000Z",
48
+ message: assistantMessage("Noted the synthetic deploy window plan.") },
49
+ ];
50
+ await writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`);
51
+ return file;
52
+ }
53
+
54
+ const btwPanes = (tabId) => herdr("pane", "list").result.panes
55
+ .filter((pane) => pane.tab_id === tabId && pane.label === LABEL);
56
+
57
+ async function waitForBtwPane(tabId, exclude, timeoutMs = 20_000) {
58
+ const deadline = Date.now() + timeoutMs;
59
+ while (Date.now() < deadline) {
60
+ const pane = btwPanes(tabId).find((candidate) => candidate.pane_id !== exclude);
61
+ if (pane) return pane.pane_id;
62
+ await sleep(250);
63
+ }
64
+ throw new Error("no BTW pane appeared");
65
+ }
66
+
67
+ async function snapshotDirs() {
68
+ return new Set((await readdir(tmpdir())).filter((name) => name.startsWith("hc-rarebit-btw-")));
69
+ }
70
+
71
+ async function ask(mainPane, tabId, command, previousPane) {
72
+ const before = await snapshotDirs();
73
+ herdrText("pane", "send-text", mainPane, command);
74
+ herdrText("pane", "send-keys", mainPane, "Enter");
75
+ const pane = await waitForBtwPane(tabId, previousPane);
76
+ herdrText("pane", "wait-output", pane, "--regex", "ANSWER\\s*=\\s*16:05", "--timeout", "120000");
77
+ const created = [...await snapshotDirs()].filter((name) => !before.has(name));
78
+ assert.equal(created.length, 1, "one private snapshot per BTW");
79
+ herdrText("pane", "wait-output", mainPane, "--match", "Rarebit BTW opened", "--timeout", "10000");
80
+ const snapshot = join(tmpdir(), created[0]);
81
+ return { pane, snapshot, launcher: await readFile(join(snapshot, "launch.sh"), "utf8") };
82
+ }
83
+
84
+ const work = await mkdtemp(join(tmpdir(), "rarebit-btw-e2e-"));
85
+ let tabId;
86
+ try {
87
+ const source = await syntheticSession(work);
88
+ const tab = herdr("tab", "create", "--no-focus", "--cwd", work, "--label", "rarebit-btw-e2e");
89
+ tabId = tab.result.tab.tab_id;
90
+ const mainPane = tab.result.root_pane.pane_id;
91
+ herdrText("pane", "run", mainPane, [
92
+ "pi", "--session", source, "--no-extensions", "-e", join(packageRoot, "src/extension.mjs"),
93
+ "--model", model, "--no-skills", "--no-context-files", "--no-mcp",
94
+ ].map(quote).join(" "));
95
+ herdrText("pane", "wait-output", mainPane, "--match", "deploy window", "--timeout", "60000");
96
+ await sleep(1500);
97
+
98
+ const question = "When does the deploy window close? Reply exactly as ANSWER=HH:MM";
99
+ const rarebits = await ask(mainPane, tabId, `/rarebit btw --rarebits --readonly ${question}`);
100
+ assert.match(rarebits.launcher, /'--no-extensions'.*'--tools' 'read,grep,find,ls'|'--tools' 'read,grep,find,ls'.*'--no-extensions'/s);
101
+ console.log(`ok read-only Rarebits BTW answered in ${rarebits.pane}`);
102
+
103
+ const full = await ask(mainPane, tabId, `/rarebit btw ${question}`, rarebits.pane);
104
+ assert.deepEqual(btwPanes(tabId).map((pane) => pane.pane_id), [full.pane], "the new BTW replaced the old one");
105
+ assert.match(full.launcher, /'--tools' '[^']*read[^']*bash/, "full BTW inherits the parent's active tools");
106
+ assert.doesNotMatch(full.launcher, /--no-extensions/);
107
+ const replacedName = rarebits.snapshot.split("/").at(-1);
108
+ for (const deadline = Date.now() + 15_000; (await snapshotDirs()).has(replacedName) && Date.now() < deadline;) await sleep(500);
109
+ assert.ok(!(await snapshotDirs()).has(replacedName), "replaced BTW deleted its snapshot");
110
+ console.log(`ok full BTW with inherited tools answered in ${full.pane} and replaced the earlier pane`);
111
+
112
+ herdrText("pane", "close", full.pane);
113
+ const name = full.snapshot.split("/").at(-1);
114
+ const deadline = Date.now() + 15_000;
115
+ while ((await snapshotDirs()).has(name) && Date.now() < deadline) await sleep(500);
116
+ assert.ok(!(await snapshotDirs()).has(name), "closed BTW deleted its snapshot");
117
+ console.log("ok closing the BTW pane deleted its snapshot");
118
+ } catch (error) {
119
+ if (tabId) {
120
+ for (const pane of herdr("pane", "list").result.panes.filter((p) => p.tab_id === tabId))
121
+ console.error(`--- ${pane.pane_id} ${pane.label ?? ""}\n${herdrText("pane", "read", pane.pane_id, "--source", "recent-unwrapped").split("\n").slice(-25).join("\n")}`);
122
+ }
123
+ throw error;
124
+ } finally {
125
+ if (tabId) try { herdrText("tab", "close", tabId); } catch {}
126
+ await rm(work, { recursive: true, force: true });
127
+ }
@@ -18,29 +18,47 @@ async function waitFor(predicate, label, timeout = 2_000) {
18
18
  function expectedRecallMarkdown(conversationPath, detailedPath, request) {
19
19
  return `# Rarebit Recall
20
20
 
21
- *Use these local private evidence files to recover historical context for this turn.*
21
+ *Answer the **Current request** below. Use these local private files to recover earlier context from this Session.*
22
22
 
23
- ## How to use this bundle
23
+ ## Files
24
24
 
25
- 1. Treat **Current request** below as the exact current user request.
26
- 2. Read **Conversation** first for meaning.
27
- 3. Use **Detailed evidence** only for source, Session, branch, or lineage facts.
28
- 4. Answer the current request using this evidence.
29
-
30
- ## Local private evidence files
31
-
32
- **Conversation** — \`rarebit_conversation/v1\`
25
+ **Conversation** — \`rarebit_conversation/v2\`. Read this first.
33
26
 
34
27
  \`\`\`text
35
28
  ${conversationPath}
36
29
  \`\`\`
37
30
 
38
- **Detailed evidence** — \`rarebit_message_recall/v1\`
31
+ **Detailed evidence** — \`rarebit_message_recall/v1\`. Use it only for source, Session, branch, or lineage facts. Join on \`seq\`.
39
32
 
40
33
  \`\`\`text
41
34
  ${detailedPath}
42
35
  \`\`\`
43
36
 
37
+ ## Conversation schema
38
+
39
+ \`\`\`text
40
+ { messageCount, lastRound, messages: [{ seq, round, role, kind, time, text }] }
41
+ seq 1-based, oldest first
42
+ round increments at each user message; agent messages before the first user message have round 0
43
+ role "user" | "agent"
44
+ kind "request" (user) | "progress" (agent text before a tool call) | "reply" (agent final answer)
45
+ time UTC "YYYY-MM-DDTHH:MM:SSZ", or null
46
+ \`\`\`
47
+
48
+ Messages contain user and agent text only. Tool calls and tool results are excluded.
49
+
50
+ ## Example queries
51
+
52
+ Set \`F\` to the Conversation path. Read the smallest slice that answers the request.
53
+
54
+ \`\`\`sh
55
+ jq '.messages[-20:]' "$F" # last 20 messages
56
+ jq '[.messages[] | select(.role == "user")][-3:]' "$F" # last 3 user messages
57
+ jq '[.messages[] | select(.kind == "reply")][-10:]' "$F" # last 10 agent replies
58
+ jq '.lastRound as $r | [.messages[] | select(.round > $r - 5)]' "$F" # last 5 rounds
59
+ jq '(now - 8*3600 | todate) as $t | [.messages[] | select(.time >= $t)]' "$F" # last 8 hours
60
+ \`\`\`
61
+
44
62
  ## Current request
45
63
 
46
64
  \`\`\`text
@@ -50,7 +68,7 @@ ${request}
50
68
 
51
69
  function conversationPathFrom(message) {
52
70
  const match = message.match(
53
- /\*\*Conversation\*\* — `rarebit_conversation\/v1`\n\n```text\n([^\n]*rarebit-conversation\.json)\n```/,
71
+ /\*\*Conversation\*\* — `rarebit_conversation\/v2`\. Read this first\.\n\n```text\n([^\n]*rarebit-conversation\.json)\n```/,
54
72
  );
55
73
  assert.ok(match, "Recall Markdown must contain the fenced conversation path");
56
74
  return match[1];
@@ -58,7 +76,7 @@ function conversationPathFrom(message) {
58
76
 
59
77
  function detailedPathFrom(message) {
60
78
  const match = message.match(
61
- /\*\*Detailed evidence\*\* — `rarebit_message_recall\/v1`\n\n```text\n([^\n]*rarebit-evidence\.json)\n```/,
79
+ /\*\*Detailed evidence\*\* — `rarebit_message_recall\/v1`\. Use it only for source, Session, branch, or lineage facts\. Join on `seq`\.\n\n```text\n([^\n]*rarebit-evidence\.json)\n```/,
62
80
  );
63
81
  assert.ok(match, "Recall Markdown must contain the fenced detailed path");
64
82
  return match[1];
package/src/extension.mjs CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  sessionFilenameFor,
33
33
  writeRarebitForkFile,
34
34
  } from "./rarebit-fork.mjs";
35
+ import { openRarebitBtwPane, prepareRarebitBtw, watchRarebitBtwPane } from "./rarebit-btw.mjs";
35
36
  import {
36
37
  RAREBIT_CONVERSATION_SCHEMA_VERSION,
37
38
  RAREBIT_RECALL_SCHEMA_VERSION,
@@ -118,6 +119,10 @@ export async function readConfiguredRarebitSettings({
118
119
  summaryPrompt: resolved.summaryPrompt,
119
120
  diagnostics: resolved.diagnostics,
120
121
  recap: resolved.recap,
122
+ ...(resolved.btwModel ? { btwModel: resolved.btwModel } : {}),
123
+ ...(resolved.btwModelConfigurationError
124
+ ? { btwModelConfigurationError: resolved.btwModelConfigurationError }
125
+ : {}),
121
126
  reserveTokens: resolveReserveTokens(global.value, project.value, model),
122
127
  };
123
128
  }
@@ -543,25 +548,43 @@ export default function registerPiRarebit(pi, config = {}) {
543
548
  };
544
549
  const recallMessage = (recall, requestText) => `# Rarebit Recall
545
550
 
546
- *Use these local private evidence files to recover historical context for this turn.*
547
-
548
- ## How to use this bundle
551
+ *Answer the **Current request** below. Use these local private files to recover earlier context from this Session.*
549
552
 
550
- 1. Treat **Current request** below as the exact current user request.
551
- 2. Read **Conversation** first for meaning.
552
- 3. Use **Detailed evidence** only for source, Session, branch, or lineage facts.
553
- 4. Answer the current request using this evidence.
553
+ ## Files
554
554
 
555
- ## Local private evidence files
556
-
557
- **Conversation** — \`rarebit_conversation/v${RAREBIT_CONVERSATION_SCHEMA_VERSION}\`
555
+ **Conversation** — \`rarebit_conversation/v${RAREBIT_CONVERSATION_SCHEMA_VERSION}\`. Read this first.
558
556
 
559
557
  ${fence(recall.conversationPath)}
560
558
 
561
- **Detailed evidence** — \`rarebit_message_recall/v${RAREBIT_RECALL_SCHEMA_VERSION}\`
559
+ **Detailed evidence** — \`rarebit_message_recall/v${RAREBIT_RECALL_SCHEMA_VERSION}\`. Use it only for source, Session, branch, or lineage facts. Join on \`seq\`.
562
560
 
563
561
  ${fence(recall.detailedPath)}
564
562
 
563
+ ## Conversation schema
564
+
565
+ \`\`\`text
566
+ { messageCount, lastRound, messages: [{ seq, round, role, kind, time, text }] }
567
+ seq 1-based, oldest first
568
+ round increments at each user message; agent messages before the first user message have round 0
569
+ role "user" | "agent"
570
+ kind "request" (user) | "progress" (agent text before a tool call) | "reply" (agent final answer)
571
+ time UTC "YYYY-MM-DDTHH:MM:SSZ", or null
572
+ \`\`\`
573
+
574
+ Messages contain user and agent text only. Tool calls and tool results are excluded.
575
+
576
+ ## Example queries
577
+
578
+ Set \`F\` to the Conversation path. Read the smallest slice that answers the request.
579
+
580
+ \`\`\`sh
581
+ jq '.messages[-20:]' "$F" # last 20 messages
582
+ jq '[.messages[] | select(.role == "user")][-3:]' "$F" # last 3 user messages
583
+ jq '[.messages[] | select(.kind == "reply")][-10:]' "$F" # last 10 agent replies
584
+ jq '.lastRound as $r | [.messages[] | select(.round > $r - 5)]' "$F" # last 5 rounds
585
+ jq '(now - 8*3600 | todate) as $t | [.messages[] | select(.time >= $t)]' "$F" # last 8 hours
586
+ \`\`\`
587
+
565
588
  ## Current request
566
589
 
567
590
  ${fence(requestText)}`;
@@ -737,6 +760,30 @@ ${fence(requestText)}`;
737
760
  }
738
761
  return;
739
762
  }
763
+ if (subcommand === "btw") {
764
+ const flags = command.flags ?? [];
765
+ let btw;
766
+ try {
767
+ const effective = await loadEffective(ctx);
768
+ if (effective.btwModelConfigurationError) throw new Error(effective.btwModelConfigurationError);
769
+ const readonly = flags.includes("--readonly");
770
+ btw = await prepareRarebitBtw(ctx, {
771
+ mode: flags.includes("--rarebits") ? "rarebits" : "full",
772
+ question: rest[0] ?? "",
773
+ readonly,
774
+ activeTools: readonly ? undefined : pi.getActiveTools?.(),
775
+ model: effective.btwModel,
776
+ thinking: pi.getThinkingLevel?.(),
777
+ });
778
+ const opened = await openRarebitBtwPane({ launcherPath: btw.launcherPath, cwd: ctx.cwd ?? process.cwd() });
779
+ watchRarebitBtwPane({ paneId: opened.paneId, directory: btw.directory });
780
+ notify(ctx, `Rarebit BTW opened beside this pane (${btw.mode}${btw.readonly ? ", readonly" : ""}; ${btw.model ?? "default model"}; ${btw.summary})`, "info");
781
+ } catch (error) {
782
+ await btw?.discard();
783
+ notify(ctx, `Rarebit BTW failed: ${error?.message ?? error}`, "error");
784
+ }
785
+ return;
786
+ }
740
787
  if (subcommand === "recall") {
741
788
  const prompt = rest[0];
742
789
  if (typeof pi.sendUserMessage !== "function") {
@@ -0,0 +1,245 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { promisify } from "node:util";
7
+
8
+ import {
9
+ buildRarebitForkPlan,
10
+ createRarebitForkEntries,
11
+ writeRarebitForkFile,
12
+ } from "./rarebit-fork.mjs";
13
+
14
+ /**
15
+ * Rarebit BTW: a temporary side Pi Session beside the main Session.
16
+ *
17
+ * The main Session stays the authority. BTW writes a private snapshot of the
18
+ * active branch to the OS temp directory, opens a Herdr split, and runs a
19
+ * second Pi on that snapshot. By default the side Pi loads extensions as
20
+ * usual and enables the parent's active tools. Read-only BTW enables only
21
+ * read-only built-in tools and loads no extensions, skills, context files,
22
+ * or MCP servers. Closing the side Pi deletes the snapshot.
23
+ */
24
+
25
+ export const RAREBIT_BTW_MODES = Object.freeze(["full", "rarebits"]);
26
+ export const RAREBIT_BTW_PANE_LABEL = "rarebit-btw";
27
+ export const RAREBIT_BTW_READONLY_TOOLS = "read,grep,find,ls";
28
+
29
+ const execFileAsync = promisify(execFile);
30
+
31
+ function messageOf(entry) {
32
+ return entry?.type === "message" ? entry.message : null;
33
+ }
34
+
35
+ /**
36
+ * Drop the unfinished tail of a mid-turn snapshot: the first assistant
37
+ * message whose tool calls have no results, and everything after it.
38
+ */
39
+ export function completedBranchPrefix(branch) {
40
+ const resultIds = new Set();
41
+ for (const entry of branch) {
42
+ const message = messageOf(entry);
43
+ if (message?.role === "toolResult" && message.toolCallId) resultIds.add(message.toolCallId);
44
+ }
45
+ const cut = branch.findIndex((entry) => {
46
+ const message = messageOf(entry);
47
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) return false;
48
+ return message.content.some((part) => part?.type === "toolCall" && !resultIds.has(part.id));
49
+ });
50
+ return cut < 0 ? branch : branch.slice(0, cut);
51
+ }
52
+
53
+ /** Full mode: the active branch verbatim, under a new Session header. */
54
+ export function createFullBtwEntries({ header, sessionFile, cwd, branch, now = new Date() }) {
55
+ const entries = completedBranchPrefix(branch);
56
+ const sessionId = randomUUID();
57
+ return {
58
+ sessionId,
59
+ header: {
60
+ type: "session",
61
+ version: header?.version ?? 3,
62
+ id: sessionId,
63
+ timestamp: now.toISOString(),
64
+ cwd: resolve(cwd ?? header?.cwd ?? process.cwd()),
65
+ parentSession: resolve(sessionFile),
66
+ },
67
+ entries,
68
+ droppedTailEntries: branch.length - entries.length,
69
+ };
70
+ }
71
+
72
+ function shellQuote(value) {
73
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
74
+ }
75
+
76
+ export function btwSystemPrompt({ mode, sourceFile, readonly = false }) {
77
+ const history = mode === "rarebits"
78
+ ? "This Session holds only the Rarebit messages of another Pi Session: user requests and final agent replies."
79
+ : "This Session is a copy of another Pi Session's active branch.";
80
+ return [
81
+ "## BTW side chat",
82
+ history,
83
+ "The user asks quick side questions about that history while the original agent keeps working.",
84
+ "- Answer from the history. Be brief.",
85
+ readonly
86
+ ? "- Do not continue the original task. Do not modify files."
87
+ : "- Do not continue the original task unless the user asks.",
88
+ `- For detail the history omits, search the source Session JSONL with grep or read: ${sourceFile}`,
89
+ ].join("\n");
90
+ }
91
+
92
+ function toolArguments({ readonly, activeTools }) {
93
+ if (readonly) return [
94
+ "--tools", RAREBIT_BTW_READONLY_TOOLS,
95
+ "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-context-files", "--no-mcp",
96
+ ];
97
+ if (!Array.isArray(activeTools)) return [];
98
+ return activeTools.length > 0 ? ["--tools", activeTools.join(",")] : ["--no-tools"];
99
+ }
100
+
101
+ /** The argv for the side Pi. */
102
+ export function btwPiArguments({ sessionFile, model, thinking, readonly = false, activeTools, systemPrompt, question, name }) {
103
+ return [
104
+ "--session", sessionFile,
105
+ ...(model?.provider && model?.id ? ["--model", `${model.provider}/${model.id}`] : []),
106
+ ...(thinking ? ["--thinking", thinking] : []),
107
+ ...toolArguments({ readonly, activeTools }),
108
+ "--append-system-prompt", systemPrompt,
109
+ ...(name ? ["--name", name] : []),
110
+ ...(question ? ["--", question] : []),
111
+ ];
112
+ }
113
+
114
+ /** A launcher that runs the side Pi, then deletes the snapshot and exits the pane shell. */
115
+ export function btwLauncherScript({ piCommand = "pi", piArguments, directory }) {
116
+ return [
117
+ "#!/bin/sh",
118
+ `trap ${shellQuote(`rm -rf ${shellQuote(directory)}`)} EXIT`,
119
+ "trap 'exit 129' HUP INT TERM",
120
+ `${shellQuote(piCommand)} ${piArguments.map(shellQuote).join(" ")}`,
121
+ "",
122
+ ].join("\n");
123
+ }
124
+
125
+ /**
126
+ * Write the snapshot and launcher. Returns paths and counts; the caller owns
127
+ * `discard` until the launcher runs.
128
+ */
129
+ export async function prepareRarebitBtw(ctx, {
130
+ mode = "full",
131
+ question = "",
132
+ readonly = false,
133
+ activeTools,
134
+ model: configuredModel,
135
+ thinking,
136
+ tempRoot = tmpdir(),
137
+ now = () => new Date(),
138
+ piCommand = "pi",
139
+ } = {}) {
140
+ if (!RAREBIT_BTW_MODES.includes(mode)) throw new Error(`unknown BTW mode ${mode}`);
141
+ const sourceFile = ctx?.sessionManager?.getSessionFile?.();
142
+ const branch = ctx?.sessionManager?.getBranch?.();
143
+ const header = ctx?.sessionManager?.getHeader?.();
144
+ if (typeof sourceFile !== "string" || !Array.isArray(branch))
145
+ throw new Error("the current Pi Session must be persisted before BTW");
146
+ // A configured BTW model carries its own optional `:thinking` suffix; the
147
+ // parent model brings the parent thinking level.
148
+ const model = configuredModel ?? ctx?.model ?? null;
149
+ const sideThinking = configuredModel ? undefined : thinking;
150
+ const directory = await mkdtemp(join(resolve(tempRoot), "hc-rarebit-btw-"));
151
+ try {
152
+ await chmod(directory, 0o700);
153
+ let session;
154
+ let summary;
155
+ if (mode === "rarebits") {
156
+ const plan = buildRarebitForkPlan({
157
+ header, sessionFile: sourceFile, cwd: ctx.cwd, branch, targetCwd: ctx.cwd,
158
+ maxTokenLength: Math.max(1, Math.floor((model?.contextWindow ?? ctx?.model?.contextWindow ?? 200_000) / 2)),
159
+ targetModel: model, forkCreatedAt: now().toISOString(),
160
+ });
161
+ session = createRarebitForkEntries(plan);
162
+ summary = `${plan.selected.length} Rarebit messages, ~${plan.importedTokens} tokens` +
163
+ (plan.omittedOccurrences ? `, ${plan.omittedOccurrences} older omitted` : "");
164
+ } else {
165
+ session = createFullBtwEntries({ header, sessionFile: sourceFile, cwd: ctx.cwd, branch, now: now() });
166
+ summary = `${session.entries.length} entries` +
167
+ (session.droppedTailEntries ? `, ${session.droppedTailEntries} unfinished dropped` : "");
168
+ }
169
+ const sessionFile = join(directory, "session.jsonl");
170
+ await writeRarebitForkFile(sessionFile, session);
171
+ const piArguments = btwPiArguments({
172
+ sessionFile, model, thinking: sideThinking, readonly, activeTools,
173
+ systemPrompt: btwSystemPrompt({ mode, sourceFile: resolve(sourceFile), readonly }),
174
+ question: String(question ?? "").trim(),
175
+ name: `btw: ${mode}${readonly ? ", readonly" : ""}`,
176
+ });
177
+ const launcherPath = join(directory, "launch.sh");
178
+ await writeFile(launcherPath, btwLauncherScript({ piCommand, piArguments, directory }), { mode: 0o700 });
179
+ return {
180
+ mode, readonly, directory, sessionFile, launcherPath, summary,
181
+ model: model?.provider && model?.id ? `${model.provider}/${model.id}` : null,
182
+ discard: () => rm(directory, { recursive: true, force: true }),
183
+ };
184
+ } catch (error) {
185
+ await rm(directory, { recursive: true, force: true }).catch(() => {});
186
+ throw error;
187
+ }
188
+ }
189
+
190
+ async function herdrJson(herdrBin, args, run) {
191
+ const { stdout } = await run(herdrBin, args, { timeout: 5_000 });
192
+ return stdout.trim() ? JSON.parse(stdout) : {};
193
+ }
194
+
195
+ /**
196
+ * Open the side Pi in a Herdr split beside `paneId`. Another BTW pane already
197
+ * open in the same tab is closed first, so each BTW starts from a fresh
198
+ * snapshot. A BTW started inside a BTW pane keeps its own pane.
199
+ */
200
+ export async function openRarebitBtwPane({
201
+ launcherPath,
202
+ cwd,
203
+ paneId = process.env.HERDR_PANE_ID,
204
+ herdrBin = process.env.HERDR_BIN_PATH || "herdr",
205
+ direction = "right",
206
+ ratio = 0.45,
207
+ run = execFileAsync,
208
+ } = {}) {
209
+ if (!paneId) throw new Error("BTW needs Herdr: HERDR_PANE_ID is not set");
210
+ const current = await herdrJson(herdrBin, ["pane", "get", paneId], run);
211
+ const tabId = current?.result?.pane?.tab_id;
212
+ const listed = await herdrJson(herdrBin, ["pane", "list"], run);
213
+ for (const pane of listed?.result?.panes ?? []) {
214
+ if (pane.tab_id === tabId && pane.label === RAREBIT_BTW_PANE_LABEL && pane.pane_id !== paneId)
215
+ await run(herdrBin, ["pane", "close", pane.pane_id], { timeout: 5_000 });
216
+ }
217
+ const split = await herdrJson(herdrBin, [
218
+ "pane", "split", paneId, "--direction", direction, "--ratio", String(ratio),
219
+ "--cwd", cwd, "--focus",
220
+ ], run);
221
+ const newPaneId = split?.result?.pane?.pane_id;
222
+ if (!newPaneId) throw new Error("Herdr did not report the new pane");
223
+ await run(herdrBin, ["pane", "rename", newPaneId, RAREBIT_BTW_PANE_LABEL], { timeout: 5_000 });
224
+ await run(herdrBin, ["pane", "run", newPaneId, `exec sh ${shellQuote(launcherPath)}`], { timeout: 5_000 });
225
+ return { paneId: newPaneId };
226
+ }
227
+
228
+ /**
229
+ * Delete the snapshot once Herdr no longer reports the BTW pane. Herdr may
230
+ * kill the pane before the launcher's EXIT trap runs, so cleanup follows
231
+ * the pane's lifetime in a detached process that outlives both Pis.
232
+ */
233
+ export function watchRarebitBtwPane({
234
+ paneId,
235
+ directory,
236
+ herdrBin = process.env.HERDR_BIN_PATH || "herdr",
237
+ intervalSeconds = 3,
238
+ spawnImpl = spawn,
239
+ }) {
240
+ const script = [
241
+ `while [ -d ${shellQuote(directory)} ] && ${shellQuote(herdrBin)} pane get ${shellQuote(paneId)} >/dev/null 2>&1; do sleep ${Number(intervalSeconds)}; done`,
242
+ `rm -rf ${shellQuote(directory)}`,
243
+ ].join("; ");
244
+ spawnImpl("sh", ["-c", script], { detached: true, stdio: "ignore" }).unref();
245
+ }
@@ -10,6 +10,8 @@ const number = (usage, validate) => ({
10
10
  validate,
11
11
  });
12
12
 
13
+ const flag = (value, description) => ({ value, description });
14
+
13
15
  const rest = (usage) => ({
14
16
  kind: "rest",
15
17
  usage,
@@ -49,6 +51,15 @@ export const RAREBIT_COMMAND_GRAMMAR = Object.freeze({
49
51
  description: "Recall active-branch Rarebit messages with a prompt",
50
52
  forms: [[rest("<prompt...>")]],
51
53
  },
54
+ {
55
+ name: "btw",
56
+ description: "Ask side questions in a temporary Pi beside this one (Herdr)",
57
+ flags: [
58
+ flag("--rarebits", "Fork only Rarebit messages (default: full branch)"),
59
+ flag("--readonly", "Read-only tools, no extensions (default: inherit tools)"),
60
+ ],
61
+ forms: [[], [rest("<question...>")]],
62
+ },
52
63
  {
53
64
  name: "fork",
54
65
  description: "Open a bounded Rarebit fork without starting a model turn",
@@ -142,7 +153,8 @@ export function rarebitCommandUsage(
142
153
  );
143
154
  if (!definition) return rarebitCommandUsage(undefined, grammar);
144
155
  const note = definition.usageNote ? ` (${definition.usageNote})` : "";
145
- return `Usage: /${grammar.name} ${definition.name}${renderForms(definition.forms)}${note}`;
156
+ const flags = (definition.flags ?? []).map(({ value }) => ` [${value}]`).join("");
157
+ return `Usage: /${grammar.name} ${definition.name}${flags}${renderForms(definition.forms)}${note}`;
146
158
  }
147
159
 
148
160
  const matchToken = (definition, rawValue) => {
@@ -152,6 +164,28 @@ const matchToken = (definition, rawValue) => {
152
164
  return Number.isFinite(value) && definition.validate(value);
153
165
  };
154
166
 
167
+ const flagValues = (definition) => new Set((definition.flags ?? []).map(({ value }) => value));
168
+
169
+ // Leading flags, in any order, precede the free-form text.
170
+ function withFlags(definition, argumentsBefore) {
171
+ const known = flagValues(definition);
172
+ let text = String(argumentsBefore.at(-1) ?? "");
173
+ const flags = [];
174
+ for (;;) {
175
+ const word = text.match(/^\s*(\S+)/)?.[1];
176
+ if (!word || !known.has(word)) break;
177
+ if (!flags.includes(word)) flags.push(word);
178
+ text = text.replace(/^\s*\S+/, "");
179
+ }
180
+ const question = text.trim() ? text.replace(/^\s/, "") : "";
181
+ return {
182
+ ok: true,
183
+ subcommand: definition.name,
184
+ flags,
185
+ arguments: [...argumentsBefore.slice(0, -1), ...(question ? [question] : [])],
186
+ };
187
+ }
188
+
155
189
  export function parseRarebitCommand(input, grammar = RAREBIT_COMMAND_GRAMMAR) {
156
190
  const rawInput = String(input ?? "");
157
191
  const tokens = rawInput.trim().split(/\s+/).filter(Boolean);
@@ -170,6 +204,9 @@ export function parseRarebitCommand(input, grammar = RAREBIT_COMMAND_GRAMMAR) {
170
204
  const freeform = definition.forms.find(
171
205
  (form) => form.at(-1)?.kind === "rest",
172
206
  );
207
+ if (freeform && tokens.length === 1 && definition.forms.some((form) => form.length === 0)) {
208
+ return { ok: true, subcommand: definition.name, ...(definition.flags ? { flags: [] } : {}), arguments: [] };
209
+ }
173
210
  if (freeform) {
174
211
  const prefix = freeform.slice(0, -1);
175
212
  const leading = rawInput.trimStart();
@@ -191,6 +228,7 @@ export function parseRarebitCommand(input, grammar = RAREBIT_COMMAND_GRAMMAR) {
191
228
  }
192
229
  if (prefixMatches && /^\s/.test(remaining)) {
193
230
  const prompt = remaining.slice(1);
231
+ if (definition.flags) return withFlags(definition, [...parsed, prompt]);
194
232
  if (matchToken(freeform.at(-1), prompt)) {
195
233
  return {
196
234
  ok: true,
@@ -289,6 +327,21 @@ export function getRarebitArgumentCompletions(
289
327
  const matches = [];
290
328
  const seen = new Set();
291
329
 
330
+ if (definition.flags) {
331
+ const known = flagValues(definition);
332
+ if (!priorArguments.every((value) => known.has(value))) return null;
333
+ const flagMatches = definition.flags
334
+ .filter(({ value }) => !priorArguments.includes(value) && value.startsWith(partial))
335
+ .map(({ value, description }) =>
336
+ completionItem({
337
+ value: `${[definition.name, ...priorArguments, value].join(" ")} `,
338
+ label: value,
339
+ description,
340
+ }),
341
+ );
342
+ return flagMatches.length > 0 ? flagMatches : null;
343
+ }
344
+
292
345
  for (const form of definition.forms) {
293
346
  const priorMatches = priorArguments.every(
294
347
  (value, index) => form[index] && matchToken(form[index], value),
@@ -6,7 +6,7 @@ import { isAbsolute, join, resolve } from "node:path";
6
6
  import { selectRarebits } from "./rarebit-core.mjs";
7
7
 
8
8
  export const RAREBIT_RECALL_SCHEMA_VERSION = 1;
9
- export const RAREBIT_CONVERSATION_SCHEMA_VERSION = 1;
9
+ export const RAREBIT_CONVERSATION_SCHEMA_VERSION = 2;
10
10
  export const RAREBIT_RECALL_DETAILED_FILENAME = "rarebit-evidence.json";
11
11
  export const RAREBIT_RECALL_CONVERSATION_FILENAME = "rarebit-conversation.json";
12
12
 
@@ -52,16 +52,20 @@ function recallDocument({ sessionId, sessionFile, branch, selection, now }) {
52
52
  occurrenceCount: selection.occurrences.length,
53
53
  uniquePayloadCount: selection.payloads.length,
54
54
  occurrences: selection.occurrences.map(
55
- ({
56
- occurrenceId,
55
+ (
56
+ {
57
+ occurrenceId,
57
58
  sourceEntryId,
58
59
  order,
59
60
  timestamp,
60
61
  role,
61
62
  outcome,
62
- contentHash,
63
- text,
64
- }) => ({
63
+ contentHash,
64
+ text,
65
+ },
66
+ index,
67
+ ) => ({
68
+ seq: index + 1,
65
69
  occurrenceId,
66
70
  sourceEntryId,
67
71
  order,
@@ -76,37 +80,45 @@ function recallDocument({ sessionId, sessionFile, branch, selection, now }) {
76
80
  };
77
81
  }
78
82
 
79
- function conversationHour(timestamp) {
83
+ // Second precision matches jq `todate`, so `.time >= (now - N | todate)` and
84
+ // `fromdate` work without parsing fractional seconds.
85
+ function conversationTime(timestamp) {
80
86
  if (typeof timestamp !== "string") return null;
81
87
  const value = new Date(timestamp);
82
88
  if (Number.isNaN(value.getTime())) return null;
83
- return `${value.toISOString().slice(0, 13)}:00Z`;
89
+ return `${value.toISOString().slice(0, 19)}Z`;
84
90
  }
85
91
 
92
+ const CONVERSATION_KIND = Object.freeze({
93
+ user: "request",
94
+ continuation: "progress",
95
+ stop: "reply",
96
+ });
97
+
98
+ /**
99
+ * One flat, branch-ordered message list. `seq` matches the detailed
100
+ * occurrence `seq`. A round starts at each user message, so tail slices by
101
+ * message, role, kind, round, or time are single jq filters.
102
+ */
86
103
  function conversationDocument(selection) {
87
- const byHour = new Map();
88
- for (const occurrence of selection.occurrences) {
89
- const hour = conversationHour(occurrence.timestamp);
90
- let bucket = byHour.get(hour);
91
- if (!bucket) {
92
- bucket = { hour, messages: [] };
93
- byHour.set(hour, bucket);
94
- }
95
- bucket.messages.push({
96
- role: occurrence.role === "user" ? "user" : "agent",
97
- content: occurrence.text,
98
- });
99
- }
100
- const hours = [...byHour.values()].sort((left, right) => {
101
- if (left.hour === null) return right.hour === null ? 0 : 1;
102
- if (right.hour === null) return -1;
103
- return left.hour.localeCompare(right.hour);
104
+ let round = 0;
105
+ const messages = selection.occurrences.map((occurrence, index) => {
106
+ if (occurrence.outcome === "user") round += 1;
107
+ return {
108
+ seq: index + 1,
109
+ round,
110
+ role: occurrence.outcome === "user" ? "user" : "agent",
111
+ kind: CONVERSATION_KIND[occurrence.outcome],
112
+ time: conversationTime(occurrence.timestamp),
113
+ text: occurrence.text,
114
+ };
104
115
  });
105
116
  return {
106
117
  schemaVersion: RAREBIT_CONVERSATION_SCHEMA_VERSION,
107
118
  type: "rarebit_conversation",
108
- timeZone: "UTC",
109
- hours,
119
+ messageCount: messages.length,
120
+ lastRound: round,
121
+ messages,
110
122
  };
111
123
  }
112
124
 
@@ -197,6 +197,7 @@ export const RAREBIT_SETTINGS_FIELDS = Object.freeze([
197
197
  field("recap_timezone", "Recap", "Recap timezone", ["recap", "timezone"], "timezone", `Timestamp zone. Blank uses the host timezone (${HOST_TIMEZONE}).`, { defaultValue: HOST_TIMEZONE }),
198
198
  field("auto_title", "Session", "Automatic Session title", ["auto_title"], "boolean", "Generate a Rarebit title from the first persisted owner message.", { defaultValue: true }),
199
199
  field("model", "Session", "Rarebit model", ["model"], "string", "Model in provider/model form. Blank removes the local override.", { defaultValue: "Not set" }),
200
+ field("btw_model", "Session", "BTW model", ["btw", "model"], "string", "Model for /rarebit btw in provider/model form; add :<thinking> to set thinking. Blank uses the parent Session model.", { defaultValue: "Parent model" }),
200
201
  ]);
201
202
 
202
203
  const paletteItems = [
@@ -207,6 +208,8 @@ const paletteItems = [
207
208
  { id: "summarize", tab: "Actions", label: "Summarize", description: "Force a Rarebit Summary materialization." },
208
209
  { id: "recall", tab: "Actions", label: "Recall", description: "Recall active-branch Rarebit messages with a prompt." },
209
210
  { id: "fork", tab: "Actions", label: "Fork", description: "Open a bounded Rarebit fork without a model turn." },
211
+ { id: "btw", tab: "Actions", label: "BTW", description: "Ask side questions in a temporary Pi beside this one." },
212
+ { id: "btw_rarebits", tab: "Actions", label: "BTW (Rarebits)", description: "BTW with Rarebit messages only." },
210
213
  { id: "summary_status", tab: "Summary", label: "Summary status", description: "Show effective Summary policy." },
211
214
  { id: "summary_settings", tab: "Summary", label: "Summary settings", description: "Edit Summary diagnostics and input policy." },
212
215
  { id: "recap", tab: "Recap", label: "Recap", description: "Show the current Recap in the TUI." },
@@ -475,6 +478,8 @@ export function rarebitPaletteCommand(selection) {
475
478
  case "recall": return "recall";
476
479
  case "title": return "title";
477
480
  case "fork": return "fork";
481
+ case "btw": return "btw";
482
+ case "btw_rarebits": return "btw --rarebits";
478
483
  case "help": return "help";
479
484
  default: return undefined;
480
485
  }
@@ -72,8 +72,8 @@ export function mergeRarebitSettings(
72
72
  return merged;
73
73
  }
74
74
 
75
- export function modelFromRarebitSettings(rarebit) {
76
- const candidate = rarebit?.model;
75
+ export function modelFromRarebitSettings(rarebit, { key = "model" } = {}) {
76
+ const candidate = key.split(".").reduce((value, part) => value?.[part], rarebit);
77
77
  if (isRecord(candidate)) {
78
78
  const provider = nonEmptyString(candidate.provider);
79
79
  const id = nonEmptyString(candidate.id);
@@ -91,10 +91,10 @@ export function modelFromRarebitSettings(rarebit) {
91
91
  };
92
92
  }
93
93
  if (candidate === undefined)
94
- return { error: "Rarebit setting rarebit.model is missing" };
94
+ return { error: `Rarebit setting rarebit.${key} is missing` };
95
95
  return {
96
96
  error:
97
- "Rarebit setting rarebit.model must be provider/model or {provider,id}",
97
+ `Rarebit setting rarebit.${key} must be provider/model or {provider,id}`,
98
98
  };
99
99
  }
100
100
 
@@ -110,6 +110,10 @@ export function resolveRarebitSettings(
110
110
  ) {
111
111
  const rarebit = mergeRarebitSettings(globalSettings, projectSettings);
112
112
  const resolved = modelFromRarebitSettings(rarebit);
113
+ // An omitted BTW model means "use the parent Session model".
114
+ const btw = rarebit?.btw?.model === undefined
115
+ ? {}
116
+ : modelFromRarebitSettings(rarebit, { key: "btw.model" });
113
117
  const recap =
114
118
  isRecord(rarebit?.recap)
115
119
  ? rarebit.recap
@@ -134,6 +138,8 @@ export function resolveRarebitSettings(
134
138
  return {
135
139
  ...(resolved.model ? { model: resolved.model } : {}),
136
140
  ...(resolved.error ? { modelConfigurationError: resolved.error } : {}),
141
+ ...(btw.model ? { btwModel: btw.model } : {}),
142
+ ...(btw.error ? { btwModelConfigurationError: btw.error } : {}),
137
143
  summaryPolicy: {
138
144
  ...DEFAULT_RAREBIT_SUMMARY_POLICY,
139
145
  ...(rarebit.min_total_length === undefined
@@ -0,0 +1,158 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+
7
+ import {
8
+ RAREBIT_BTW_PANE_LABEL,
9
+ completedBranchPrefix,
10
+ openRarebitBtwPane,
11
+ prepareRarebitBtw,
12
+ watchRarebitBtwPane,
13
+ } from "../src/rarebit-btw.mjs";
14
+ import { getRarebitArgumentCompletions, parseRarebitCommand } from "../src/rarebit-command.mjs";
15
+ import { resolveRarebitSettings } from "../src/rarebit-settings.mjs";
16
+
17
+ const user = (id, parentId, text) => ({
18
+ type: "message", id, parentId, timestamp: "2026-10-07T00:00:00.000Z",
19
+ message: { role: "user", content: text, timestamp: 0 },
20
+ });
21
+ const assistant = (id, parentId, content, stopReason = "stop") => ({
22
+ type: "message", id, parentId, timestamp: "2026-10-07T00:00:01.000Z",
23
+ message: { role: "assistant", content, stopReason, timestamp: 0 },
24
+ });
25
+ const toolResult = (id, parentId, toolCallId) => ({
26
+ type: "message", id, parentId, timestamp: "2026-10-07T00:00:02.000Z",
27
+ message: { role: "toolResult", toolCallId, toolName: "read", content: [], timestamp: 0 },
28
+ });
29
+
30
+ const settledBranch = [
31
+ user("u1", null, "synthetic request alpha"),
32
+ assistant("a1", "u1", [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], "toolUse"),
33
+ toolResult("t1", "a1", "c1"),
34
+ assistant("a2", "t1", [{ type: "text", text: "synthetic reply alpha" }]),
35
+ ];
36
+
37
+ function contextFor(branch, sessionFile = "/synthetic/source.jsonl") {
38
+ return {
39
+ cwd: "/synthetic/cwd",
40
+ model: { provider: "synthetic", id: "model", contextWindow: 100_000 },
41
+ sessionManager: {
42
+ getSessionFile: () => sessionFile,
43
+ getBranch: () => branch,
44
+ getHeader: () => ({ type: "session", version: 3, id: "source-id", cwd: "/synthetic/cwd" }),
45
+ },
46
+ };
47
+ }
48
+
49
+ test("btw parses leading flags in any order before the question", () => {
50
+ const parsed = (input) => {
51
+ const { flags, arguments: rest } = parseRarebitCommand(input);
52
+ return { flags, rest };
53
+ };
54
+ assert.deepEqual(parsed("btw"), { flags: [], rest: [] });
55
+ assert.deepEqual(parsed("btw why X?"), { flags: [], rest: ["why X?"] });
56
+ assert.deepEqual(parsed("btw --rarebits"), { flags: ["--rarebits"], rest: [] });
57
+ assert.deepEqual(parsed("btw --readonly --rarebits why --rarebits?"), { flags: ["--readonly", "--rarebits"], rest: ["why --rarebits?"] });
58
+ });
59
+
60
+ test("btw autocompletes unused flags until the question starts", () => {
61
+ const values = (prefix) => getRarebitArgumentCompletions(prefix)?.map(({ value }) => value) ?? null;
62
+ assert.deepEqual(values("btw "), ["btw --rarebits ", "btw --readonly "]);
63
+ assert.deepEqual(values("btw --ra"), ["btw --rarebits "]);
64
+ assert.deepEqual(values("btw --rarebits "), ["btw --rarebits --readonly "]);
65
+ assert.equal(values("btw --rarebits --readonly "), null);
66
+ assert.equal(values("btw why "), null);
67
+ });
68
+
69
+ test("rarebit.btw.model is optional and validated like rarebit.model", () => {
70
+ assert.equal(resolveRarebitSettings({ rarebit: { model: "a/b" } }).btwModel, undefined);
71
+ assert.deepEqual(resolveRarebitSettings({ rarebit: { btw: { model: "p/m:low" } } }).btwModel, { provider: "p", id: "m:low" });
72
+ assert.match(resolveRarebitSettings({ rarebit: { btw: { model: "bad" } } }).btwModelConfigurationError, /rarebit\.btw\.model/);
73
+ });
74
+
75
+ test("a mid-turn snapshot drops the assistant message with unresolved tool calls", () => {
76
+ const pending = assistant("a3", "a2", [{ type: "toolCall", id: "c2", name: "read", arguments: {} }], "toolUse");
77
+ assert.deepEqual(completedBranchPrefix([...settledBranch, pending]).map((e) => e.id), ["u1", "a1", "t1", "a2"]);
78
+ assert.equal(completedBranchPrefix(settledBranch), settledBranch);
79
+ });
80
+
81
+ test("full BTW writes the active branch under a new Session and a self-deleting launcher", async (t) => {
82
+ const tempRoot = await mkdtemp(join(tmpdir(), "rarebit-btw-test-"));
83
+ t.after(() => rm(tempRoot, { recursive: true, force: true }));
84
+ const btw = await prepareRarebitBtw(contextFor(settledBranch), {
85
+ question: "what was alpha?", tempRoot, thinking: "low", activeTools: ["read", "bash", "team_sync"],
86
+ });
87
+ const lines = (await readFile(btw.sessionFile, "utf8")).trim().split("\n").map((line) => JSON.parse(line));
88
+ assert.equal(lines[0].type, "session");
89
+ assert.notEqual(lines[0].id, "source-id");
90
+ assert.equal(lines[0].parentSession, "/synthetic/source.jsonl");
91
+ assert.deepEqual(lines.slice(1).map((e) => e.id), ["u1", "a1", "t1", "a2"]);
92
+ assert.equal((await stat(btw.directory)).mode & 0o777, 0o700);
93
+ const launcher = await readFile(btw.launcherPath, "utf8");
94
+ assert.match(launcher, /trap .*rm -rf .*hc-rarebit-btw-.* EXIT/);
95
+ for (const flag of ["--tools' 'read,bash,team_sync", "--model' 'synthetic/model", "--thinking' 'low", "'--' 'what was alpha?'"])
96
+ assert.ok(launcher.includes(flag), flag);
97
+ assert.ok(!launcher.includes("--no-extensions"));
98
+ await btw.discard();
99
+ });
100
+
101
+ test("read-only BTW drops extensions and limits tools; a configured model drops parent thinking", async (t) => {
102
+ const tempRoot = await mkdtemp(join(tmpdir(), "rarebit-btw-test-"));
103
+ t.after(() => rm(tempRoot, { recursive: true, force: true }));
104
+ const btw = await prepareRarebitBtw(contextFor(settledBranch), {
105
+ tempRoot, readonly: true, thinking: "high", activeTools: ["bash"], model: { provider: "p", id: "m:low" },
106
+ });
107
+ const launcher = await readFile(btw.launcherPath, "utf8");
108
+ for (const flag of ["--tools' 'read,grep,find,ls", "--no-extensions", "--no-mcp", "--model' 'p/m:low", "Do not modify files"])
109
+ assert.ok(launcher.includes(flag), flag);
110
+ assert.ok(!launcher.includes("--thinking"));
111
+ assert.ok(!launcher.includes("bash"));
112
+ });
113
+
114
+ test("Rarebits BTW keeps only user requests and final replies", async (t) => {
115
+ const tempRoot = await mkdtemp(join(tmpdir(), "rarebit-btw-test-"));
116
+ t.after(() => rm(tempRoot, { recursive: true, force: true }));
117
+ const btw = await prepareRarebitBtw(contextFor(settledBranch), { mode: "rarebits", tempRoot });
118
+ const texts = (await readFile(btw.sessionFile, "utf8")).trim().split("\n").map((line) => JSON.parse(line))
119
+ .filter((entry) => entry.type === "message" && entry.rarebitFork?.kind === "import")
120
+ .map((entry) => entry.message.role);
121
+ assert.deepEqual(texts, ["user", "assistant"]);
122
+ assert.doesNotMatch(await readFile(btw.launcherPath, "utf8"), /' '--' '/);
123
+ });
124
+
125
+ test("the pane opener replaces another BTW pane in the same tab only, never its own", async () => {
126
+ const calls = [];
127
+ const replies = {
128
+ "pane get": { result: { pane: { tab_id: "tab-1" } } },
129
+ "pane list": { result: { panes: [
130
+ { pane_id: "old", tab_id: "tab-1", label: RAREBIT_BTW_PANE_LABEL },
131
+ { pane_id: "other-tab", tab_id: "tab-2", label: RAREBIT_BTW_PANE_LABEL },
132
+ { pane_id: "main", tab_id: "tab-1", label: RAREBIT_BTW_PANE_LABEL },
133
+ ] } },
134
+ "pane split": { result: { pane: { pane_id: "new" } } },
135
+ };
136
+ const run = async (_bin, args) => {
137
+ calls.push(args.join(" "));
138
+ return { stdout: JSON.stringify(replies[args.slice(0, 2).join(" ")] ?? {}) };
139
+ };
140
+ const opened = await openRarebitBtwPane({ launcherPath: "/tmp/x/launch.sh", cwd: "/w", paneId: "main", run });
141
+ assert.equal(opened.paneId, "new");
142
+ assert.deepEqual(calls.filter((c) => c.startsWith("pane close")), ["pane close old"]);
143
+ assert.ok(calls.includes(`pane rename new ${RAREBIT_BTW_PANE_LABEL}`));
144
+ assert.ok(calls.includes("pane run new exec sh '/tmp/x/launch.sh'"));
145
+ });
146
+
147
+ test("the cleanup watcher is detached and removes the snapshot after the pane is gone", () => {
148
+ let spawned;
149
+ watchRarebitBtwPane({
150
+ paneId: "w1:p1", directory: "/tmp/hc-rarebit-btw-x", herdrBin: "herdr",
151
+ spawnImpl: (command, args, options) => {
152
+ spawned = { command, args, options };
153
+ return { unref() {} };
154
+ },
155
+ });
156
+ assert.equal(spawned.options.detached, true);
157
+ assert.match(spawned.args[1], /while \[ -d '\/tmp\/hc-rarebit-btw-x' \] && 'herdr' pane get 'w1:p1'.*done; rm -rf '\/tmp\/hc-rarebit-btw-x'/);
158
+ });
@@ -14,11 +14,11 @@ const values = (prefix) =>
14
14
  test("one grammar derives command description and usage", () => {
15
15
  assert.equal(
16
16
  rarebitCommandDescription(),
17
- "Rarebit menu/settings/view/help/status/recall/fork/config/auto-title/title/recap/got-it/summarize",
17
+ "Rarebit menu/settings/view/help/status/recall/btw/fork/config/auto-title/title/recap/got-it/summarize",
18
18
  );
19
19
  assert.equal(
20
20
  rarebitCommandUsage(),
21
- "Usage: /rarebit [menu|settings|view|help|status|recall|fork|config|auto-title|title|recap|got-it|summarize]",
21
+ "Usage: /rarebit [menu|settings|view|help|status|recall|btw|fork|config|auto-title|title|recap|got-it|summarize]",
22
22
  );
23
23
  assert.equal(
24
24
  rarebitCommandUsage("config"),
@@ -105,6 +105,7 @@ test("autocomplete covers subcommands and discrete nested arguments", () => {
105
105
  "help",
106
106
  "status",
107
107
  "recall",
108
+ "btw",
108
109
  "fork",
109
110
  "config",
110
111
  "auto-title",
@@ -140,27 +140,52 @@ test("recall materialization writes private detailed evidence and a chronologica
140
140
  await readFile(result.conversationPath, "utf8"),
141
141
  );
142
142
  assert.deepEqual(conversation, {
143
- schemaVersion: 1,
143
+ schemaVersion: 2,
144
144
  type: "rarebit_conversation",
145
- timeZone: "UTC",
146
- hours: [
145
+ messageCount: 3,
146
+ lastRound: 1,
147
+ messages: [
147
148
  {
148
- hour: "2026-07-24T01:00Z",
149
- messages: [{ role: "user", content: "Exact owner evidence." }],
149
+ seq: 1,
150
+ round: 1,
151
+ role: "user",
152
+ kind: "request",
153
+ time: "2026-07-24T01:02:03Z",
154
+ text: "Exact owner evidence.",
150
155
  },
151
156
  {
152
- hour: "2026-07-24T02:00Z",
153
- messages: [
154
- { role: "agent", content: "Exact continuation evidence." },
155
- { role: "agent", content: "Exact stop evidence." },
156
- ],
157
+ seq: 2,
158
+ round: 1,
159
+ role: "agent",
160
+ kind: "progress",
161
+ time: "2026-07-24T02:00:00Z",
162
+ text: "Exact continuation evidence.",
163
+ },
164
+ {
165
+ seq: 3,
166
+ round: 1,
167
+ role: "agent",
168
+ kind: "reply",
169
+ time: "2026-07-24T02:45:00Z",
170
+ text: "Exact stop evidence.",
157
171
  },
158
172
  ],
159
173
  });
174
+ assert.deepEqual(
175
+ detailed.selection.occurrences.map(({ seq, sourceEntryId }) => ({
176
+ seq,
177
+ sourceEntryId,
178
+ })),
179
+ [
180
+ { seq: 1, sourceEntryId: "owner" },
181
+ { seq: 2, sourceEntryId: "continue" },
182
+ { seq: 3, sourceEntryId: "stop" },
183
+ ],
184
+ );
160
185
  const conversationText = await readFile(result.conversationPath, "utf8");
161
186
  assert.doesNotMatch(
162
187
  conversationText,
163
- /sourceEntryId|occurrenceId|manifestHash|contentHash|timestamp|session/i,
188
+ /sourceEntryId|occurrenceId|manifestHash|contentHash|session/i,
164
189
  );
165
190
  } finally {
166
191
  await result.discard();
@@ -168,7 +193,7 @@ test("recall materialization writes private detailed evidence and a chronologica
168
193
  }
169
194
  });
170
195
 
171
- test("conversation buckets sort chronologically and treat producer-tagged input as a normal user message", async () => {
196
+ test("conversation keeps branch order, numbers rounds, and treats producer-tagged input as a normal user message", async () => {
172
197
  const root = await fixtureRoot("bucket-order");
173
198
  const sessionFile = join(root, "session.jsonl");
174
199
  await writeFile(sessionFile, "{}\n");
@@ -218,23 +243,23 @@ test("conversation buckets sort chronologically and treat producer-tagged input
218
243
  const conversation = JSON.parse(
219
244
  await readFile(result.conversationPath, "utf8"),
220
245
  );
221
- assert.deepEqual(conversation.hours, [
222
- {
223
- hour: "2026-07-24T01:00Z",
224
- messages: [{ role: "agent", content: "Earlier agent evidence." }],
225
- },
226
- {
227
- hour: "2026-07-24T02:00Z",
228
- messages: [
229
- { role: "user", content: "Unknown-origin user evidence." },
230
- { role: "agent", content: "Later agent evidence." },
231
- ],
232
- },
233
- {
234
- hour: null,
235
- messages: [{ role: "agent", content: "Unknown-time agent evidence." }],
236
- },
237
- ]);
246
+ assert.equal(conversation.lastRound, 1);
247
+ assert.deepEqual(
248
+ conversation.messages.map(({ seq, round, role, kind, time, text }) => [
249
+ seq,
250
+ round,
251
+ role,
252
+ kind,
253
+ time,
254
+ text,
255
+ ]),
256
+ [
257
+ [1, 1, "user", "request", "2026-07-24T02:10:00Z", "Unknown-origin user evidence."],
258
+ [2, 1, "agent", "reply", "2026-07-24T01:10:00Z", "Earlier agent evidence."],
259
+ [3, 1, "agent", "reply", null, "Unknown-time agent evidence."],
260
+ [4, 1, "agent", "reply", "2026-07-24T02:20:00Z", "Later agent evidence."],
261
+ ],
262
+ );
238
263
  assert.doesNotMatch(
239
264
  await readFile(result.conversationPath, "utf8"),
240
265
  /human|producer|rpc/,
@@ -318,29 +343,47 @@ test("recall sends one exact fenced Markdown steer message in idle and busy cont
318
343
  "\n# Heading-looking request\n- list-looking item\n```text\ninside backticks\n```\n~~~~\ninside tildes\n~~~~\nTrailing request\n";
319
344
  const expectedMessage = `# Rarebit Recall
320
345
 
321
- *Use these local private evidence files to recover historical context for this turn.*
322
-
323
- ## How to use this bundle
346
+ *Answer the **Current request** below. Use these local private files to recover earlier context from this Session.*
324
347
 
325
- 1. Treat **Current request** below as the exact current user request.
326
- 2. Read **Conversation** first for meaning.
327
- 3. Use **Detailed evidence** only for source, Session, branch, or lineage facts.
328
- 4. Answer the current request using this evidence.
348
+ ## Files
329
349
 
330
- ## Local private evidence files
331
-
332
- **Conversation** — \`rarebit_conversation/v1\`
350
+ **Conversation** — \`rarebit_conversation/v2\`. Read this first.
333
351
 
334
352
  \`\`\`text
335
353
  ${recall.conversationPath}
336
354
  \`\`\`
337
355
 
338
- **Detailed evidence** — \`rarebit_message_recall/v1\`
356
+ **Detailed evidence** — \`rarebit_message_recall/v1\`. Use it only for source, Session, branch, or lineage facts. Join on \`seq\`.
339
357
 
340
358
  ~~~~text
341
359
  ${recall.detailedPath}
342
360
  ~~~~
343
361
 
362
+ ## Conversation schema
363
+
364
+ \`\`\`text
365
+ { messageCount, lastRound, messages: [{ seq, round, role, kind, time, text }] }
366
+ seq 1-based, oldest first
367
+ round increments at each user message; agent messages before the first user message have round 0
368
+ role "user" | "agent"
369
+ kind "request" (user) | "progress" (agent text before a tool call) | "reply" (agent final answer)
370
+ time UTC "YYYY-MM-DDTHH:MM:SSZ", or null
371
+ \`\`\`
372
+
373
+ Messages contain user and agent text only. Tool calls and tool results are excluded.
374
+
375
+ ## Example queries
376
+
377
+ Set \`F\` to the Conversation path. Read the smallest slice that answers the request.
378
+
379
+ \`\`\`sh
380
+ jq '.messages[-20:]' "$F" # last 20 messages
381
+ jq '[.messages[] | select(.role == "user")][-3:]' "$F" # last 3 user messages
382
+ jq '[.messages[] | select(.kind == "reply")][-10:]' "$F" # last 10 agent replies
383
+ jq '.lastRound as $r | [.messages[] | select(.round > $r - 5)]' "$F" # last 5 rounds
384
+ jq '(now - 8*3600 | todate) as $t | [.messages[] | select(.time >= $t)]' "$F" # last 8 hours
385
+ \`\`\`
386
+
344
387
  ## Current request
345
388
 
346
389
  \`\`\`\`text
@@ -390,7 +433,7 @@ test("long delimiter runs ending at value boundaries use the shortest safe fence
390
433
  assert.equal(harness.sent.length, 1);
391
434
  const message = harness.sent[0].args[0];
392
435
  const conversationBlock = message.match(
393
- /\*\*Conversation\*\* — `rarebit_conversation\/v1`\n\n([`~]+)text\n([^\n]*)\n\1\n\n\*\*Detailed evidence\*\*/,
436
+ /\*\*Conversation\*\* — `rarebit_conversation\/v2`\. Read this first\.\n\n([`~]+)text\n([^\n]*)\n\1\n\n\*\*Detailed evidence\*\*/,
394
437
  );
395
438
  assert.ok(conversationBlock);
396
439
  assert.equal(conversationBlock[2], conversationPath);