@hypercarrier/rarebit 0.2.1 → 0.4.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.
@@ -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);
@@ -0,0 +1,270 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { createRarebitViewController } from "../src/rarebit-view.mjs";
7
+ import registerRarebit from "../src/extension.mjs";
8
+ import { rarebitPaletteCommand } from "../src/rarebit-settings-ui.mjs";
9
+
10
+ // A host boundary records command/UI effects. The reader uses the installed Pi
11
+ // Markdown and keyboard implementations; no view helper is replaced.
12
+ function host({ mode = "tui", failOverlay = false } = {}) {
13
+ const commands = new Map(), shortcuts = new Map(), events = new Map(), entryRenderers = new Map();
14
+ const notices = [], statuses = new Map(), overlays = [];
15
+ let settingsReads = 0, picker, renderRequests = 0;
16
+ const tui = { children: [], terminal: { rows: 25, columns: 100 }, requestRender() { renderRequests++; }, showOverlay() {} };
17
+ const theme = { fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text, inverse: (text) => text };
18
+ const entries = [
19
+ { type: "message", id: "old", timestamp: "2026-01-01T10:00:00Z", message: { role: "user", content: "Older Needle" } },
20
+ { type: "compaction", id: "cut1", firstKeptEntryId: "now", timestamp: "2026-01-01T11:00:00Z" },
21
+ { type: "compaction", id: "cut2", firstKeptEntryId: "now", timestamp: "2026-01-01T12:00:00Z" },
22
+ { type: "message", id: "now", timestamp: "2026-01-01T12:00:01Z", message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Newer needle" }, { type: "thinking", thinking: "Hidden reasoning" }] } },
23
+ { type: "message", id: "tool", message: { role: "toolResult", content: [{ type: "text", text: "Hidden tool" }] } },
24
+ ];
25
+ const ctx = {
26
+ mode, hasUI: true,
27
+ sessionManager: { getBranch: () => entries, getHeader: () => ({ id: "mock" }) },
28
+ ui: {
29
+ notify: (text) => notices.push(text),
30
+ setStatus(key, value) { if (value === undefined) statuses.delete(key); else statuses.set(key, value); },
31
+ setWidget() {},
32
+ async select() { assert.equal(statuses.size, 0); return picker; },
33
+ custom(factory, options) {
34
+ if (failOverlay && options?.overlay) return Promise.reject(new Error("mock unavailable"));
35
+ return new Promise((resolve, reject) => {
36
+ let finished = false;
37
+ const done = () => { finished = true; resolve(); };
38
+ try {
39
+ const component = factory(tui, theme, {}, done);
40
+ if (!finished && options?.overlay) {
41
+ overlays.push({ component, options, done });
42
+ options.onHandle?.({ isFocused: () => !options.overlayOptions.nonCapturing });
43
+ }
44
+ } catch (error) { reject(error); }
45
+ });
46
+ },
47
+ },
48
+ };
49
+ const pi = {
50
+ on(name, handler) { const handlers = events.get(name) ?? []; handlers.push(handler); events.set(name, handlers); },
51
+ registerEntryRenderer: (name, renderer) => entryRenderers.set(name, renderer),
52
+ registerCommand: (name, definition) => commands.set(name, definition),
53
+ registerShortcut: (name, definition) => shortcuts.set(name, definition),
54
+ };
55
+ registerRarebit(pi, { settingsLoader: async () => { settingsReads++; return {}; }, activityReporter: { start() {}, update() {}, stop() {} } });
56
+ return { ctx, commands, shortcuts, notices, statuses, overlays, tui, entries, entryRenderers,
57
+ command: (args) => commands.get("rarebit").handler(args, ctx),
58
+ event: async (name, detail = {}) => { for (const handler of events.get(name) ?? []) await handler(detail, ctx); },
59
+ setPicker(value) { picker = value; }, get settingsReads() { return settingsReads; }, get renderRequests() { return renderRequests; } };
60
+ }
61
+ const tick = () => new Promise((resolve) => setImmediate(resolve));
62
+
63
+ test("view command projects all branch prose and each compaction without reading settings", async () => {
64
+ const h = host();
65
+ await h.command("view all");
66
+ const pane = h.overlays.at(-1).component;
67
+ const output = pane.render(100).join("\n");
68
+ assert.match(output, /Older Needle/);
69
+ assert.match(output, /Newer needle/);
70
+ assert.doesNotMatch(output, /Hidden reasoning|Hidden tool/);
71
+ assert.match(output, /□ user message/);
72
+ assert.match(output, /● agent stops/);
73
+ assert.equal(output.match(/compaction /g).length, 2);
74
+ assert.equal(h.settingsReads, 0);
75
+ await h.command("view off");
76
+ assert.equal(h.statuses.size, 0);
77
+ });
78
+
79
+ test("focused overlay accepts Kitty search, smartcase, repeat and two-stage Escape", async () => {
80
+ const h = host();
81
+ await h.command("view all");
82
+ const pane = h.overlays.at(-1).component;
83
+ pane.render(100);
84
+ pane.handleInput("\x1b[47u");
85
+ pane.handleInput("needle");
86
+ pane.handleInput("\r");
87
+ assert.match(pane.render(100).at(-1), /\/needle · 1\/2/);
88
+ pane.handleInput("\x1b[110u");
89
+ assert.match(pane.render(100).at(-1), /\/needle · 2\/2/);
90
+ pane.handleInput("N");
91
+ assert.match(pane.render(100).at(-1), /\/needle · 1\/2/);
92
+ pane.handleInput("?"); pane.handleInput("Needle"); pane.handleInput("\r");
93
+ assert.match(pane.render(100).at(-1), /\?Needle · 1\/1/);
94
+ pane.handleInput("\x1b");
95
+ assert.equal(h.statuses.size, 1);
96
+ assert.doesNotMatch(pane.render(100).at(-1), /Needle ·/);
97
+ pane.handleInput("\x1b");
98
+ assert.equal(h.statuses.size, 0);
99
+ });
100
+
101
+ test("toggle defaults to all; overlay cycle and toggle use the controller and preserve preference", async () => {
102
+ const h = host();
103
+ await h.shortcuts.get("ctrl+alt+r").handler(h.ctx);
104
+ assert.match(h.statuses.get("rarebit-view"), /view: all/);
105
+ h.overlays.at(-1).component.handleInput("\x1b[110;13u");
106
+ await tick();
107
+ assert.match(h.statuses.get("rarebit-view"), /view: peek/);
108
+ assert.equal(h.overlays.at(-1).options.overlayOptions.nonCapturing, true);
109
+ await h.shortcuts.get("ctrl+super+r").handler(h.ctx);
110
+ assert.equal(h.statuses.size, 0);
111
+ await h.shortcuts.get("ctrl+alt+r").handler(h.ctx);
112
+ assert.match(h.statuses.get("rarebit-view"), /view: peek/);
113
+ await h.command("view off");
114
+ });
115
+
116
+ test("context fails closed and falls back to all on an unsupported transcript", async () => {
117
+ const h = host();
118
+ await h.command("view context");
119
+ await tick();
120
+ assert.match(h.notices.join("\n"), /Context view is unavailable on this Pi version; showing all\./);
121
+ assert.match(h.statuses.get("rarebit-view"), /view: all/);
122
+ await h.command("view off");
123
+ });
124
+
125
+ test("picker closes the current view; shutdown clears it and invalidates pending activation", async () => {
126
+ const h = host();
127
+ assert.equal(rarebitPaletteCommand({ id: "view" }), "view");
128
+ await h.command("view all");
129
+ h.setPicker(undefined);
130
+ await h.command("view");
131
+ assert.equal(h.statuses.size, 0);
132
+ const pending = h.command("view all");
133
+ await h.event("session_shutdown");
134
+ await pending;
135
+ assert.equal(h.statuses.size, 0);
136
+ await h.command("view peek");
137
+ const pane = h.overlays.at(-1).component;
138
+ assert.equal(pane.render(100).length, 18);
139
+ h.tui.terminal.rows = 40;
140
+ assert.equal(pane.render(100).length, 33);
141
+ await h.event("session_shutdown");
142
+ assert.equal(h.statuses.size, 0);
143
+ });
144
+
145
+ test("search wrap notice survives renders and clears on the next key", async () => {
146
+ const h = host();
147
+ await h.command("view all");
148
+ const pane = h.overlays.at(-1).component;
149
+ assert.match(pane.render(120).at(-1), /ctrl\+alt\+n next/);
150
+ pane.handleInput("/"); pane.handleInput("needle"); pane.handleInput("\r");
151
+ pane.handleInput("n"); pane.handleInput("n");
152
+ assert.match(pane.render(120).at(-1), /search hit BOTTOM, continuing at TOP/);
153
+ assert.match(pane.render(120).at(-1), /search hit BOTTOM, continuing at TOP/);
154
+ pane.handleInput("N");
155
+ assert.match(pane.render(120).at(-1), /search hit TOP, continuing at BOTTOM/);
156
+ pane.handleInput("j");
157
+ assert.doesNotMatch(pane.render(120).at(-1), /search hit/);
158
+ assert.match(pane.render(120).at(-1), /\/needle · 2\/2/);
159
+ await h.command("view off");
160
+ });
161
+
162
+ // Override only for a second installed Pi version. No personal Session is read.
163
+ const piRoot = process.env.RAREBIT_TEST_PI_ROOT ?? dirname(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))));
164
+ const piRequire = createRequire(pathToFileURL(join(piRoot, "package.json")));
165
+ const realTui = await import(pathToFileURL(piRequire.resolve("@earendil-works/pi-tui")));
166
+ const { AssistantMessageComponent } = await import(pathToFileURL(join(piRoot, "dist/modes/interactive/components/assistant-message.js")));
167
+ const { initTheme } = await import(pathToFileURL(join(piRoot, "dist/modes/interactive/theme/theme.js")));
168
+ initTheme("dark", false);
169
+ let ThemedText;
170
+ try {
171
+ ({ ThemedText } = await import(pathToFileURL(join(piRoot, "dist/modes/interactive/components/themed-text.js"))));
172
+ } catch (error) {
173
+ if (error.code !== "ERR_MODULE_NOT_FOUND") throw error;
174
+ }
175
+ const notice = (text) => ThemedText ? new ThemedText(() => text, 1, 0) : new realTui.Text(text, 1, 0);
176
+
177
+ function nativeHost(components) {
178
+ const document = new realTui.Container(), chat = new realTui.Container();
179
+ for (const component of components) chat.addChild(component);
180
+ document.addChild(chat);
181
+ const tui = { children: [document], terminal: { rows: 30, columns: 100 }, requestRender() {} };
182
+ const ctx = { mode: "tui", hasUI: true, sessionManager: { getBranch: () => [] }, ui: {
183
+ setStatus() {}, notify(text) { assert.fail(text); },
184
+ async custom(factory) { return factory(tui, {}, {}, () => {}); },
185
+ } };
186
+ return { chat, ctx, text: () => realTui.stripTerminalSequences(chat.render(100).join("\n")) };
187
+ }
188
+
189
+ test("context shows only newly appended real Pi notice components and restores earlier notices on off", async () => {
190
+ const h = nativeHost([notice("EARLIER_NOTICE")]);
191
+ const controller = createRarebitViewController();
192
+ await controller.command(h.ctx, "context");
193
+ try {
194
+ h.chat.addChild(new realTui.Spacer(5));
195
+ h.chat.addChild(notice("NEW_NOTICE"));
196
+ assert.equal(h.text().trim(), "NEW_NOTICE");
197
+ } finally { controller.dispose(); }
198
+ assert.match(h.text(), /EARLIER_NOTICE/);
199
+ assert.match(h.text(), /NEW_NOTICE/);
200
+ });
201
+
202
+ test("context shows a new status when Pi reuses an earlier notice component", async () => {
203
+ let message = "EARLIER_NOTICE";
204
+ const previousNotice = ThemedText ? new ThemedText(() => message, 1, 0) : new realTui.Text(message, 1, 0);
205
+ const h = nativeHost([previousNotice]);
206
+ const controller = createRarebitViewController();
207
+ await controller.command(h.ctx, "context");
208
+ try {
209
+ assert.doesNotMatch(h.text(), /EARLIER_NOTICE/);
210
+ previousNotice.invalidate();
211
+ assert.doesNotMatch(realTui.stripTerminalSequences(h.chat.render(40).join("\n")), /EARLIER_NOTICE/);
212
+ message = "UPDATED_NOTICE";
213
+ if (ThemedText) previousNotice.invalidate();
214
+ else previousNotice.setText(message);
215
+ assert.match(h.text(), /UPDATED_NOTICE/);
216
+ assert.doesNotMatch(h.text(), /EARLIER_NOTICE/);
217
+ } finally { controller.dispose(); }
218
+ assert.match(h.text(), /UPDATED_NOTICE/);
219
+ });
220
+
221
+ test("Session shutdown detaches Recap renderers from the outgoing context", async () => {
222
+ const h = host();
223
+ await h.event("session_start");
224
+ const component = h.entryRenderers.get("rarebit-recap")({
225
+ type: "custom", id: "recap", customType: "rarebit-recap",
226
+ data: { version: 1, heading: "Mock Recap", summary: "Generic recap prose" },
227
+ }, {}, { fg: (_color, text) => text });
228
+ for (let i = 0; i < 20 && component.render(100).join("\n").includes("Recap loading"); i++) await tick();
229
+ assert.match(component.render(100).join("\n"), /Generic recap prose/);
230
+ await h.event("session_shutdown");
231
+ h.ctx.sessionManager.getBranch = () => { throw new Error("outgoing Pi context is stale"); };
232
+ assert.doesNotThrow(() => component.render(100));
233
+ assert.match(component.render(100).join("\n"), /Generic recap prose/);
234
+ });
235
+
236
+ test("context preserves literal thinking-label prose with real Pi assistant components", async () => {
237
+ for (const withThinking of [false, true]) for (const hiddenBefore of [false, true]) {
238
+ const message = { role: "assistant", stopReason: "stop", content: [
239
+ ...(withThinking ? [{ type: "thinking", thinking: "REASONING_MUST_STAY_HIDDEN" }] : []),
240
+ { type: "text", text: "Thinking..." },
241
+ ] };
242
+ const assistant = new AssistantMessageComponent(message, hiddenBefore);
243
+ const h = nativeHost([assistant]);
244
+ const original = h.text();
245
+ const controller = createRarebitViewController();
246
+ await controller.command(h.ctx, "context");
247
+ try {
248
+ assert.equal(controller.activeMode, "context");
249
+ const during = h.text();
250
+ assert.equal(during.match(/Thinking\.\.\./g)?.length, 1, "selected literal prose must survive once");
251
+ assert.doesNotMatch(during, /REASONING_MUST_STAY_HIDDEN/);
252
+ // A Pi invalidation rebuilds the content tree. It must not revive chrome
253
+ // or change the selected prose on the next native render.
254
+ assistant.invalidate();
255
+ assert.equal(h.text().match(/Thinking\.\.\./g)?.length, 1);
256
+ } finally { controller.dispose(); }
257
+ assert.equal(h.text(), original, "off restores the real component rendering");
258
+ assert.equal(assistant.hideThinkingBlock, hiddenBefore);
259
+ }
260
+ });
261
+
262
+ test("non-TUI and rejected overlay operations stay off without unhandled rejections", async () => {
263
+ for (const options of [{ mode: "rpc" }, { failOverlay: true }]) {
264
+ const h = host(options);
265
+ await h.command("view all");
266
+ assert.equal(h.statuses.size, 0);
267
+ assert.match(h.notices.join("\n"), /unavailable on this Pi version/);
268
+ assert.equal(h.settingsReads, 0);
269
+ }
270
+ });