@hypercarrier/rarebit 0.4.0 → 0.5.1
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/AGENTS.md +1 -1
- package/CHANGELOG.md +18 -0
- package/README.md +24 -0
- package/RUNBOOK.md +7 -3
- package/package.json +1 -1
- package/scripts/e2e-btw-herdr.mjs +127 -0
- package/src/extension.mjs +29 -0
- package/src/rarebit-btw.mjs +245 -0
- package/src/rarebit-command.mjs +54 -1
- package/src/rarebit-settings-ui.mjs +5 -0
- package/src/rarebit-settings.mjs +10 -4
- package/test/rarebit-btw.test.mjs +158 -0
- package/test/rarebit-command.test.mjs +3 -2
package/AGENTS.md
CHANGED
|
@@ -7,7 +7,7 @@ must not replace it or claim Task, Project, runtime, or delivery truth.
|
|
|
7
7
|
Keep the public surface small. Test CLI behavior, package boundaries, and Pi
|
|
8
8
|
installation paths. Do not add private-helper mocks, export-count tests,
|
|
9
9
|
coverage targets, or tautological snapshots. Run `npm ci`, `npm test`, and
|
|
10
|
-
`npm run verify:package` before a release
|
|
10
|
+
`npm run verify:package` before a release.
|
|
11
11
|
|
|
12
12
|
Do not record Session prose, provider credentials, Recall bundles, or private
|
|
13
13
|
paths in issues, fixtures, logs, or release records. `README.md` owns user
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1 — 2026-10-12
|
|
4
|
+
|
|
5
|
+
- Document the release privacy gate as the baseline range scan in `RUNBOOK.md`
|
|
6
|
+
and `AGENTS.md`. A change outside the npm `files` list ships on `main`
|
|
7
|
+
without a version.
|
|
8
|
+
|
|
9
|
+
## 0.5.0 — 2026-10-07
|
|
10
|
+
|
|
11
|
+
- Add `/rarebit btw [--rarebits] [--readonly] [question]`. It opens a
|
|
12
|
+
temporary side Pi in a Herdr split on a private snapshot of the active
|
|
13
|
+
branch, so you can ask about history while the main agent keeps working.
|
|
14
|
+
`--rarebits` snapshots only Rarebit messages. By default the side Pi
|
|
15
|
+
inherits the parent's active tools; `--readonly` limits it to read-only
|
|
16
|
+
built-in tools without extensions.
|
|
17
|
+
- Add the optional `rarebit.btw.model` setting and a BTW model field in
|
|
18
|
+
Rarebit settings. Unset, BTW uses the parent model and thinking level.
|
|
19
|
+
- Autocomplete `/rarebit btw` flags. Add BTW actions to the command palette.
|
|
20
|
+
|
|
3
21
|
## 0.4.0 — 2026-10-07
|
|
4
22
|
|
|
5
23
|
- Replace Recall's hour-bucketed `rarebit_conversation/v1` with a flat
|
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/RUNBOOK.md
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# Rarebit runbook
|
|
2
2
|
|
|
3
3
|
To release, merge the version bump and its [CHANGELOG.md](CHANGELOG.md) entry
|
|
4
|
-
to `main`, run
|
|
5
|
-
`
|
|
4
|
+
to `main`, run
|
|
5
|
+
`git-privacy-scan --ref HEAD --base "$(git config privacy.baseline)" range`,
|
|
6
|
+
then push tag `v<version>`. Any finding blocks the release; the pre-push hook
|
|
7
|
+
runs the same scan. The tag push runs
|
|
6
8
|
[`.github/workflows/publish.yml`](.github/workflows/publish.yml). It runs the
|
|
7
9
|
package gates, publishes one verified packed artifact with npm provenance, and
|
|
8
10
|
creates the GitHub Release. A prerelease version goes to npm `next`; any other
|
|
9
|
-
version goes to `latest`. Publication adds no commit.
|
|
11
|
+
version goes to `latest`. Publication adds no commit. A change outside the npm
|
|
12
|
+
`files` list, such as `claude-code/`, changes no package bytes: push it to
|
|
13
|
+
`main` without a version.
|
|
10
14
|
|
|
11
15
|
Set `rarebit.model` in global Pi settings or in a trusted Project's
|
|
12
16
|
`.pi/settings.json`. It must be `provider/model` or an object with `provider`
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
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
|
}
|
|
@@ -755,6 +760,30 @@ ${fence(requestText)}`;
|
|
|
755
760
|
}
|
|
756
761
|
return;
|
|
757
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
|
+
}
|
|
758
787
|
if (subcommand === "recall") {
|
|
759
788
|
const prompt = rest[0];
|
|
760
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
|
+
}
|
package/src/rarebit-command.mjs
CHANGED
|
@@ -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
|
-
|
|
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),
|
|
@@ -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
|
}
|
package/src/rarebit-settings.mjs
CHANGED
|
@@ -72,8 +72,8 @@ export function mergeRarebitSettings(
|
|
|
72
72
|
return merged;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export function modelFromRarebitSettings(rarebit) {
|
|
76
|
-
const candidate = rarebit
|
|
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:
|
|
94
|
+
return { error: `Rarebit setting rarebit.${key} is missing` };
|
|
95
95
|
return {
|
|
96
96
|
error:
|
|
97
|
-
|
|
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",
|