@hypercarrier/rarebit 0.1.0-alpha.5 → 0.2.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +24 -2
  2. package/README.md +96 -20
  3. package/RUNBOOK.md +32 -9
  4. package/bin/piq.mjs +14 -0
  5. package/docs/examples/rarebit.settings.json +19 -0
  6. package/package.json +29 -3
  7. package/scripts/e2e-fork-pi.mjs +824 -0
  8. package/scripts/e2e-recap-pi.mjs +456 -0
  9. package/scripts/e2e-settings-pi.mjs +267 -0
  10. package/scripts/e2e-v020-pi.mjs +269 -0
  11. package/scripts/verify-package.mjs +20 -12
  12. package/src/extension.mjs +280 -21
  13. package/src/index.mjs +1 -0
  14. package/src/lifecycle.mjs +32 -7
  15. package/src/pi-session.mjs +237 -0
  16. package/src/piq-cli.mjs +96 -0
  17. package/src/piq.mjs +265 -0
  18. package/src/rarebit-activity.mjs +8 -2
  19. package/src/rarebit-cli.mjs +103 -5
  20. package/src/rarebit-command.mjs +29 -1
  21. package/src/rarebit-core.mjs +87 -9
  22. package/src/rarebit-fork-lineage.mjs +69 -0
  23. package/src/rarebit-fork.mjs +241 -0
  24. package/src/rarebit-recap.mjs +422 -0
  25. package/src/rarebit-service.mjs +60 -7
  26. package/src/rarebit-session.mjs +12 -160
  27. package/src/rarebit-settings-ui.mjs +479 -0
  28. package/src/rarebit-settings.mjs +90 -7
  29. package/src/types.d.ts +91 -3
  30. package/test/lifecycle-contract-independent.test.mjs +2 -0
  31. package/test/package-artifact-identity.test.mjs +54 -0
  32. package/test/piq.test.mjs +212 -0
  33. package/test/rarebit-command.test.mjs +11 -4
  34. package/test/rarebit-fork.test.mjs +124 -0
  35. package/test/rarebit-recap.test.mjs +513 -0
  36. package/test/rarebit-settings-ui.test.mjs +388 -0
  37. package/test/rarebit-v020.test.mjs +294 -0
  38. package/test/summary-attention-independent.test.mjs +1 -1
@@ -0,0 +1,513 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
7
+
8
+ import { selectRarebits } from "../src/rarebit-core.mjs";
9
+ import registerPiRarebit from "../src/extension.mjs";
10
+ import { readConfiguredRarebitSettings } from "../src/extension.mjs";
11
+ import { processRarebitSummary } from "../src/rarebit-service.mjs";
12
+ import { readRarebitSession } from "../src/rarebit-session.mjs";
13
+ import { readRarebitCurrent } from "../src/rarebit-store.mjs";
14
+ import {
15
+ createRarebitRecapController,
16
+ DEFAULT_RAREBIT_RECAP_DELAY_MS,
17
+ RAREBIT_RECAP_WIDGET_KEY,
18
+ } from "../src/rarebit-recap.mjs";
19
+
20
+ function branchFor(id = "assistant-1") {
21
+ return [
22
+ {
23
+ type: "message",
24
+ id: "owner-1",
25
+ message: { role: "user", content: "synthetic owner request" },
26
+ },
27
+ {
28
+ type: "message",
29
+ id,
30
+ message: {
31
+ role: "assistant",
32
+ stopReason: "stop",
33
+ content: "synthetic completed response",
34
+ },
35
+ },
36
+ ];
37
+ }
38
+
39
+ function contextFor({ sessionId = "session-1", sessionFile = "/tmp/session-1.jsonl", branch = branchFor(), setWidget }) {
40
+ return {
41
+ mode: "tui",
42
+ hasUI: true,
43
+ ui: { setWidget },
44
+ sessionManager: {
45
+ getHeader: () => ({ id: sessionId }),
46
+ getSessionFile: () => sessionFile,
47
+ getBranch: () => branch,
48
+ },
49
+ };
50
+ }
51
+
52
+ function receiptFor(ctx, jobId = "job-1") {
53
+ const branch = ctx.sessionManager.getBranch();
54
+ const selection = selectRarebits(branch);
55
+ return {
56
+ type: "rarebit_summary",
57
+ status: "ok",
58
+ jobId,
59
+ sessionId: ctx.sessionManager.getHeader().id,
60
+ branch: {
61
+ leafId: branch.at(-1)?.id ?? null,
62
+ },
63
+ selection: {
64
+ manifestHash: selection.manifestHash,
65
+ selectorVersion: selection.manifest.selectorVersion,
66
+ },
67
+ sessionStatus: "finished",
68
+ summary: "RECAP_SENTINEL: display-only current Summary",
69
+ };
70
+ }
71
+
72
+ function currentFor(receipt) {
73
+ return {
74
+ receipt,
75
+ artifactState: {
76
+ syncState: "assessment_current",
77
+ applicability: "exact_selection",
78
+ receiptRef: { jobId: receipt.jobId },
79
+ projection: { status: receipt.sessionStatus },
80
+ },
81
+ };
82
+ }
83
+
84
+ function fakeClock() {
85
+ let now = 0;
86
+ let nextId = 1;
87
+ const timers = new Map();
88
+ return {
89
+ setTimeout(callback, delay) {
90
+ const id = nextId++;
91
+ timers.set(id, { at: now + delay, callback });
92
+ return id;
93
+ },
94
+ clearTimeout(id) {
95
+ timers.delete(id);
96
+ },
97
+ async advance(milliseconds) {
98
+ const target = now + milliseconds;
99
+ while (true) {
100
+ const due = [...timers.entries()]
101
+ .filter(([, timer]) => timer.at <= target)
102
+ .sort(([, left], [, right]) => left.at - right.at)
103
+ .at(0);
104
+ if (!due) break;
105
+ const [id, timer] = due;
106
+ timers.delete(id);
107
+ now = timer.at;
108
+ await timer.callback();
109
+ }
110
+ now = target;
111
+ },
112
+ get pending() {
113
+ return timers.size;
114
+ },
115
+ };
116
+ }
117
+
118
+ test("default recap delay is one minute and a current receipt renders only in the TUI widget", async () => {
119
+ assert.equal(DEFAULT_RAREBIT_RECAP_DELAY_MS, 60_000);
120
+ const clock = fakeClock();
121
+ const widgets = [];
122
+ const ctx = contextFor({
123
+ setWidget: (...args) => widgets.push(args),
124
+ });
125
+ const receipt = receiptFor(ctx);
126
+ let reads = 0;
127
+ const controller = createRarebitRecapController({
128
+ readCurrent: async () => {
129
+ reads += 1;
130
+ return currentFor(receipt);
131
+ },
132
+ setTimeoutFn: clock.setTimeout,
133
+ clearTimeoutFn: clock.clearTimeout,
134
+ });
135
+
136
+ controller.updateContext(ctx);
137
+ const token = controller.captureMaterialization(ctx);
138
+ assert.equal(
139
+ controller.offer({ ctx, result: { record: receipt }, token }),
140
+ true,
141
+ );
142
+ await clock.advance(59_999);
143
+ assert.equal(widgets.length, 0);
144
+ assert.equal(reads, 0);
145
+ assert.equal(clock.pending, 1);
146
+
147
+ await clock.advance(1);
148
+ await Promise.resolve();
149
+ await Promise.resolve();
150
+ assert.equal(reads, 1);
151
+ assert.equal(widgets.length, 1);
152
+ assert.equal(widgets[0][0], RAREBIT_RECAP_WIDGET_KEY);
153
+ assert.match(widgetText(widgets[0][1]), /RECAP_SENTINEL/);
154
+ assert.deepEqual(widgets[0][2], { placement: "aboveEditor" });
155
+ });
156
+
157
+ test("typing does not clear the recap, but submitted input and a new turn do", async () => {
158
+ const clock = fakeClock();
159
+ const widgets = [];
160
+ const ctx = contextFor({ setWidget: (...args) => widgets.push(args) });
161
+ const receipt = receiptFor(ctx);
162
+ const controller = createRarebitRecapController({
163
+ delayMs: 1,
164
+ readCurrent: async () => currentFor(receipt),
165
+ setTimeoutFn: clock.setTimeout,
166
+ clearTimeoutFn: clock.clearTimeout,
167
+ });
168
+ controller.updateContext(ctx);
169
+ controller.offer({
170
+ ctx,
171
+ result: { record: receipt },
172
+ token: controller.captureMaterialization(ctx),
173
+ });
174
+ await clock.advance(1);
175
+ await Promise.resolve();
176
+ await Promise.resolve();
177
+ assert.equal(widgets.length, 1);
178
+
179
+ // Pi has no event for each editor keystroke. The recap remains visible while
180
+ // the owner types; only a submitted input or lifecycle transition clears it.
181
+ assert.equal(widgets.length, 1);
182
+ controller.invalidate(ctx, 1);
183
+ assert.equal(widgets.at(-1)[1], undefined);
184
+
185
+ controller.offer({
186
+ ctx,
187
+ result: { record: receipt },
188
+ token: controller.captureMaterialization(ctx),
189
+ });
190
+ controller.invalidate(ctx, 2);
191
+ await clock.advance(1);
192
+ assert.equal(widgets.filter((call) => call[1] !== undefined).length, 1);
193
+ assert.equal(clock.pending, 0);
194
+ });
195
+
196
+ test("session or branch changes cancel a detached read and cannot render stale completion", async () => {
197
+ const clock = fakeClock();
198
+ const widgets = [];
199
+ const firstBranch = branchFor("assistant-1");
200
+ const first = contextFor({
201
+ sessionId: "session-1",
202
+ sessionFile: "/tmp/session-1.jsonl",
203
+ branch: firstBranch,
204
+ setWidget: (...args) => widgets.push(args),
205
+ });
206
+ const secondBranch = branchFor("assistant-2");
207
+ const second = contextFor({
208
+ sessionId: "session-2",
209
+ sessionFile: "/tmp/session-2.jsonl",
210
+ branch: secondBranch,
211
+ setWidget: (...args) => widgets.push(args),
212
+ });
213
+ const receipt = receiptFor(first);
214
+ let resolveRead;
215
+ const readPending = new Promise((resolve) => {
216
+ resolveRead = resolve;
217
+ });
218
+ const controller = createRarebitRecapController({
219
+ delayMs: 1,
220
+ readCurrent: async () => readPending,
221
+ setTimeoutFn: clock.setTimeout,
222
+ clearTimeoutFn: clock.clearTimeout,
223
+ });
224
+ controller.updateContext(first);
225
+ controller.offer({
226
+ ctx: first,
227
+ result: { record: receipt },
228
+ token: controller.captureMaterialization(first),
229
+ });
230
+ const advancing = clock.advance(1);
231
+ await Promise.resolve();
232
+ controller.invalidate(second, 2);
233
+ resolveRead(currentFor(receipt));
234
+ await advancing;
235
+ await Promise.resolve();
236
+ await Promise.resolve();
237
+ assert.equal(
238
+ widgets.some((call) => call[0] === RAREBIT_RECAP_WIDGET_KEY && call[1]),
239
+ false,
240
+ );
241
+ assert.equal(widgets.at(-1)[1], undefined);
242
+ });
243
+
244
+ test("showExisting accepts current metadata-only branch drift and rejects changed selection", async () => {
245
+ const root = await mkdtemp(join(tmpdir(), "rarebit-recap-branch-drift-"));
246
+ const sessionRoot = join(root, "sessions");
247
+ const rarebitRoot = join(root, "rarebit");
248
+ const manager = SessionManager.create(root, sessionRoot);
249
+ const timestamp = Date.now();
250
+ manager.appendMessage({
251
+ role: "user",
252
+ content: [{ type: "text", text: "fixture owner request" }],
253
+ timestamp,
254
+ });
255
+ manager.appendMessage({
256
+ role: "assistant",
257
+ content: [{ type: "text", text: "fixture completed response" }],
258
+ api: "openai-completions",
259
+ provider: "fixture",
260
+ model: "summary",
261
+ usage: {
262
+ input: 1,
263
+ output: 1,
264
+ cacheRead: 0,
265
+ cacheWrite: 0,
266
+ totalTokens: 2,
267
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
268
+ },
269
+ stopReason: "stop",
270
+ timestamp: timestamp + 1,
271
+ });
272
+ const sessionFile = manager.getSessionFile();
273
+ const baseContext = {
274
+ cwd: root,
275
+ mode: "tui",
276
+ hasUI: true,
277
+ model: { provider: "fixture", id: "summary" },
278
+ sessionManager: manager,
279
+ };
280
+ try {
281
+ const materialized = await processRarebitSummary(baseContext, {
282
+ sessionRoot,
283
+ rarebitRoot,
284
+ model: { provider: "fixture", id: "summary" },
285
+ summaryPolicy: { minTotalLength: 0, maxRarebitRatio: 1 },
286
+ lifecycleBoundary: "agent_settled",
287
+ queryAutomaticSummaryPolicy: async () => ({ decision: "abstain" }),
288
+ modelClient: {
289
+ complete: async () => ({
290
+ text: JSON.stringify({
291
+ summary: "fixture current Summary",
292
+ sessionStatus: "finished",
293
+ statusReason: "all_requests_accomplished",
294
+ }),
295
+ }),
296
+ },
297
+ });
298
+ assert.equal(materialized.record.status, "ok");
299
+
300
+ manager.appendCustomEntry("fixture.metadata", { source: "session-ui" });
301
+ const active = SessionManager.open(sessionFile, sessionRoot);
302
+ const loaded = await readRarebitSession(sessionFile, { sessionRoot });
303
+ const current = await readRarebitCurrent({
304
+ sessionFile,
305
+ sessionRoot,
306
+ rarebitRoot,
307
+ });
308
+ assert.equal(loaded.branch.at(-1)?.id, active.getLeafId());
309
+ assert.equal(loaded.branch.at(-1)?.type, "custom");
310
+ assert.notEqual(materialized.record.branch.leafId, active.getLeafId());
311
+ assert.equal(
312
+ loaded.selection.manifestHash,
313
+ materialized.record.selection.manifestHash,
314
+ );
315
+ assert.equal(current.artifactState.syncState, "assessment_current");
316
+ assert.equal(current.artifactState.applicability, "exact_selection");
317
+ assert.equal(
318
+ current.artifactState.receiptRef.jobId,
319
+ materialized.record.jobId,
320
+ );
321
+
322
+ const widgets = [];
323
+ const controller = createRarebitRecapController({ delayMs: 0 });
324
+ const activeContext = {
325
+ ...baseContext,
326
+ ui: { setWidget: (...args) => widgets.push(args) },
327
+ sessionManager: active,
328
+ };
329
+ const shown = await controller.showExisting(activeContext, {
330
+ sessionRoot,
331
+ rarebitRoot,
332
+ });
333
+ assert.equal(shown.shown, true);
334
+ assert.equal(shown.reason, "current_summary");
335
+ assert.equal(widgets.at(-1)[0], RAREBIT_RECAP_WIDGET_KEY);
336
+
337
+ active.appendMessage({
338
+ role: "user",
339
+ content: [{ type: "text", text: "selection changed" }],
340
+ timestamp: timestamp + 2,
341
+ });
342
+ const changed = SessionManager.open(sessionFile, sessionRoot);
343
+ const changedLoaded = await readRarebitSession(sessionFile, {
344
+ sessionRoot,
345
+ });
346
+ assert.notEqual(
347
+ changedLoaded.selection.manifestHash,
348
+ loaded.selection.manifestHash,
349
+ );
350
+ const rejected = await controller.showExisting(
351
+ { ...activeContext, sessionManager: changed },
352
+ { sessionRoot, rarebitRoot },
353
+ );
354
+ assert.equal(rejected.shown, false);
355
+ assert.equal(rejected.reason, "no_current_summary");
356
+ assert.equal(widgets.at(-1)[1], undefined);
357
+ } finally {
358
+ await rm(root, { recursive: true, force: true });
359
+ }
360
+ });
361
+
362
+ test("extension lifecycle materializes a real sidecar and arms the widget without Session prose", async () => {
363
+ const root = await mkdtemp(join(tmpdir(), "rarebit-recap-extension-"));
364
+ const sessionRoot = join(root, "sessions");
365
+ const rarebitRoot = join(root, "rarebit");
366
+ const sessionFile = join(sessionRoot, "session.jsonl");
367
+ const branch = branchFor().map((entry, index, entries) => ({
368
+ ...entry,
369
+ parentId: index === 0 ? null : entries[index - 1].id,
370
+ timestamp: `2026-09-27T00:00:0${index}.000Z`,
371
+ }));
372
+ await (await import("node:fs/promises")).mkdir(sessionRoot, { recursive: true });
373
+ await writeFile(
374
+ sessionFile,
375
+ `${[
376
+ {
377
+ type: "session",
378
+ version: 3,
379
+ id: "session-1",
380
+ cwd: root,
381
+ },
382
+ ...branch,
383
+ ]
384
+ .map((entry) => JSON.stringify(entry))
385
+ .join("\n")}\n`,
386
+ { mode: 0o600 },
387
+ );
388
+
389
+ const handlers = new Map();
390
+ const commands = new Map();
391
+ const widgets = [];
392
+ let providerCalls = 0;
393
+ const pi = {
394
+ on: (event, handler) => handlers.set(event, handler),
395
+ registerCommand: (name, command) => commands.set(name, command),
396
+ getSessionName: () => "synthetic recap session",
397
+ events: { emit() {} },
398
+ };
399
+ registerPiRarebit(pi, {
400
+ sessionRoot,
401
+ rarebitRoot,
402
+ model: { provider: "test", id: "summary-model" },
403
+ summaryPolicy: { minTotalLength: 0, maxRarebitRatio: 1 },
404
+ recap: { delayMs: 1 },
405
+ settingsLoader: async () => ({
406
+ autoTitle: false,
407
+ recap: { enabled: true, delayMs: 1 },
408
+ summaryPolicy: { minTotalLength: 0, maxRarebitRatio: 1 },
409
+ }),
410
+ queryAutomaticSummaryPolicy: async () => ({
411
+ decision: "abstain",
412
+ queryStatus: "test",
413
+ }),
414
+ modelClient: {
415
+ complete: async () => {
416
+ providerCalls += 1;
417
+ return {
418
+ text: JSON.stringify({
419
+ summary: "RECAP_SENTINEL: generated only for the sidecar",
420
+ sessionStatus: "finished",
421
+ statusReason: "all_requests_accomplished",
422
+ }),
423
+ };
424
+ },
425
+ },
426
+ activityReporter: {
427
+ start() {},
428
+ update() {},
429
+ stop() {},
430
+ },
431
+ });
432
+ const ctx = {
433
+ cwd: root,
434
+ mode: "tui",
435
+ hasUI: true,
436
+ sessionId: "session-1",
437
+ model: { provider: "test", id: "summary-model" },
438
+ isProjectTrusted: () => true,
439
+ ui: {
440
+ setWidget: (...args) => widgets.push(args),
441
+ notify() {},
442
+ },
443
+ sessionManager: {
444
+ getHeader: () => ({ id: "session-1" }),
445
+ getSessionFile: () => sessionFile,
446
+ getBranch: () => branch,
447
+ },
448
+ };
449
+ try {
450
+ handlers.get("session_start")({}, ctx);
451
+ handlers.get("agent_start")({}, ctx);
452
+ handlers.get("agent_end")(
453
+ { messages: [branch.at(-1).message] },
454
+ ctx,
455
+ );
456
+ handlers.get("agent_settled")({}, ctx);
457
+ const deadline = Date.now() + 2_000;
458
+ while (!widgets.some((call) => call[1] !== undefined)) {
459
+ if (Date.now() > deadline) throw new Error("recap widget did not render");
460
+ await new Promise((resolve) => setTimeout(resolve, 5));
461
+ }
462
+ assert.equal(providerCalls, 1);
463
+ const rendered = widgets.find((call) => call[1] !== undefined);
464
+ assert.equal(rendered[0], RAREBIT_RECAP_WIDGET_KEY);
465
+ assert.match(widgetText(rendered[1]), /RECAP_SENTINEL/);
466
+ assert.deepEqual(rendered[2], { placement: "aboveEditor" });
467
+
468
+ const native = await readFile(sessionFile, "utf8");
469
+ assert.doesNotMatch(native, /RECAP_SENTINEL/);
470
+ await commands.get("rarebit").handler("recap", ctx);
471
+ assert.equal(providerCalls, 1, "displaying an existing recap must not synthesize");
472
+ } finally {
473
+ handlers.get("session_shutdown")({}, ctx);
474
+ await rm(root, { recursive: true, force: true });
475
+ }
476
+ });
477
+
478
+ test("settings preserve a global recap disable when a trusted project overrides only the delay", async () => {
479
+ const root = await mkdtemp(join(tmpdir(), "rarebit-recap-settings-"));
480
+ const agentDir = join(root, "agent");
481
+ const projectDir = join(root, "project");
482
+ await mkdir(agentDir, { recursive: true });
483
+ await mkdir(join(projectDir, ".pi"), { recursive: true });
484
+ await writeFile(
485
+ join(agentDir, "settings.json"),
486
+ JSON.stringify({
487
+ rarebit: {
488
+ model: "test/summary-model",
489
+ recap: { enabled: false },
490
+ },
491
+ }),
492
+ );
493
+ await writeFile(
494
+ join(projectDir, ".pi", "settings.json"),
495
+ JSON.stringify({ rarebit: { recap: { delay_ms: 123 } } }),
496
+ );
497
+ try {
498
+ const settings = await readConfiguredRarebitSettings({
499
+ cwd: projectDir,
500
+ projectTrusted: true,
501
+ model: { provider: "test", id: "summary-model" },
502
+ agentDir,
503
+ });
504
+ assert.equal(settings.recap.enabled, false);
505
+ assert.equal(settings.recap.delayMs, 123);
506
+ } finally {
507
+ await rm(root, { recursive: true, force: true });
508
+ }
509
+ });
510
+
511
+ function widgetText(factory) {
512
+ return factory({ requestRender() {} }, { fg: (_name, text) => text }).render(160).join("\n");
513
+ }