@hypercarrier/rarebit 0.2.0 → 0.3.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.
@@ -0,0 +1,56 @@
1
+ // Empirical Pi 0.84.2 renderer probe. Uses the native fullscreen renderer and
2
+ // a synthetic terminal adapter; no provider, credentials, or private sessions.
3
+ import assert from "node:assert/strict";
4
+ import { TuiAltScreen, ScrollView, Container, Text } from "@earendil-works/pi-tui";
5
+ import { createRarebitRecapController } from "../src/rarebit-recap.mjs";
6
+ import { selectRarebits } from "../src/rarebit-core.mjs";
7
+
8
+ const { renderLayoutFrame } = await import(new URL("layout.js", import.meta.resolve("@earendil-works/pi-tui")));
9
+
10
+ let input;
11
+ const terminal = {
12
+ columns: 80, rows: 12, kittyProtocolActive: false,
13
+ start(fn) { input = fn; }, stop() {}, write() {}, moveBy() {},
14
+ hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {},
15
+ clearScreen() {}, setTitle() {}, setProgress() {},
16
+ };
17
+ const branch = [{ type: "message", id: "synthetic-owner", message: { role: "user", content: "Synthetic renderer probe" } }];
18
+ const selection = selectRarebits(branch);
19
+ const receipt = { status: "ok", sessionId: "synthetic-session", jobId: "synthetic-recap",
20
+ observedAt: new Date(0).toISOString(), sessionStatus: "finished", summary: "SCROLLABLE_RECAP_SENTINEL",
21
+ selection: { manifestHash: selection.manifestHash, selectorVersion: selection.manifest.selectorVersion } };
22
+ const ctx = { mode: "tui", ui: { setWidget() {} }, sessionManager: {
23
+ getHeader: () => ({ id: receipt.sessionId }), getSessionFile: () => "/tmp/synthetic-probe.jsonl", getBranch: () => branch,
24
+ } };
25
+ const controller = createRarebitRecapController({
26
+ appendEntry: (customType, data) => branch.push({ type: "custom", id: "synthetic-recap-entry", customType, data }),
27
+ readCurrent: async () => ({ receipt, artifactState: { syncState: "assessment_current", applicability: "exact_selection",
28
+ receiptRef: { jobId: receipt.jobId }, projection: { status: receipt.sessionStatus } } }),
29
+ });
30
+ assert.equal((await controller.showExisting(ctx)).shown, true);
31
+ const document = new Container();
32
+ document.addChild(controller.renderEntry(branch.at(-1), {}, { fg: (_color, text) => text }));
33
+ document.addChild(new Text(Array.from({ length: 30 }, (_, i) => `Synthetic update ${i}`).join("\n"), 0, 0));
34
+ const scroll = new ScrollView(document, { primary: true, follow: "none" });
35
+ const tui = new TuiAltScreen(terminal);
36
+ tui.setLayoutRoot(scroll);
37
+ const received = [];
38
+ tui.addInputListener((data) => { received.push(data); });
39
+ try {
40
+ tui.start(); tui.renderNow();
41
+ scroll.scrollToStart(); tui.renderNow();
42
+ const atTop = renderLayoutFrame(scroll, 80, 12, () => {}).lines;
43
+ assert.ok(atTop.some((line) => line.includes("SCROLLABLE_RECAP_SENTINEL")));
44
+ input("\x1b[<65;3;4M"); tui.renderNow();
45
+ const afterWheel = scroll.scrollTop;
46
+ assert.ok(afterWheel > 0, "native wheel events scroll the transcript");
47
+ input("\x1b[<0;3;2M"); input("\x1b[<0;3;2m"); input("g");
48
+ scroll.scrollToEnd(); tui.renderNow();
49
+ const atBottom = renderLayoutFrame(scroll, 80, 12, () => {}).lines;
50
+ assert.ok(!atBottom.some((line) => line.includes("SCROLLABLE_RECAP_SENTINEL")), "recap is not pinned");
51
+ console.log(JSON.stringify({ pi: "0.84.2", nativeRenderer: true, syntheticTerminal: true,
52
+ recapVisibleAtTop: true, recapVisibleAtBottom: false, wheelScrollTop: afterWheel,
53
+ keyboardReachedExtension: received.includes("g"),
54
+ mouseReachedExtension: received.some((data) => data.startsWith("\x1b[<")),
55
+ }, null, 2));
56
+ } finally { tui.stop(); controller.dispose(); }
package/src/extension.mjs CHANGED
@@ -42,6 +42,8 @@ import {
42
42
  registerRarebitLifecycle,
43
43
  } from "./lifecycle.mjs";
44
44
  import { createRarebitRecapController } from "./rarebit-recap.mjs";
45
+ import { createRarebitViewController } from "./rarebit-view.mjs";
46
+ import { readRarebitCurrent } from "./rarebit-store.mjs";
45
47
  import {
46
48
  openRarebitPalette,
47
49
  openRarebitSettings,
@@ -134,9 +136,29 @@ export default function registerPiRarebit(pi, config = {}) {
134
136
  const activityReporter =
135
137
  explicit.activityReporter ?? createHerdrActivityReporter();
136
138
  const recapController = createRarebitRecapController({
139
+ appendEntry: typeof pi.registerEntryRenderer === "function" && typeof pi.appendEntry === "function"
140
+ ? (type, data) => pi.appendEntry(type, data) : undefined,
137
141
  delayMs: explicit.recap?.delayMs,
138
142
  timezone: explicit.recap?.timezone,
139
143
  });
144
+ const viewController = createRarebitViewController();
145
+ for (const key of ["ctrl+alt+r", "ctrl+super+r"])
146
+ pi.registerShortcut?.(key, { description: "Toggle Rarebit view", handler: (ctx) => viewController.toggle(ctx) });
147
+ for (const key of ["ctrl+alt+n", "ctrl+super+n"])
148
+ pi.registerShortcut?.(key, { description: "Next Rarebit view", handler: (ctx) => viewController.cycle(ctx) });
149
+ pi.on("session_compact", () => viewController.refresh());
150
+ pi.on("message_end", () => viewController.refresh());
151
+ pi.registerEntryRenderer?.("rarebit-recap", recapController.renderEntry);
152
+ const acknowledgeRecap = (ctx) => {
153
+ const result = recapController.acknowledge(ctx);
154
+ notify(ctx, result.acknowledged ? "Recap marked as read" :
155
+ result.reason === "already_read" ? "Recap already marked as read" : "No Recap to mark as read in this branch", "info");
156
+ };
157
+ if (typeof pi.registerEntryRenderer === "function")
158
+ pi.registerShortcut?.("ctrl+alt+g", {
159
+ description: "Mark the latest Recap as read",
160
+ handler: async (ctx) => acknowledgeRecap(ctx),
161
+ });
140
162
  const projectActivity = (ctx) => {
141
163
  const entries = ctx?.sessionManager?.getBranch?.() ?? [];
142
164
  return projectRarebitSessionActivity({
@@ -287,7 +309,7 @@ export default function registerPiRarebit(pi, config = {}) {
287
309
  );
288
310
  },
289
311
  });
290
- notifyOutcome(ctx, result, synthesisTriggered, effective.diagnostics);
312
+ if (!force) notifyOutcome(ctx, result, synthesisTriggered, effective.diagnostics);
291
313
  recapController.offer({
292
314
  ctx,
293
315
  result,
@@ -299,7 +321,7 @@ export default function registerPiRarebit(pi, config = {}) {
299
321
  rarebitRoot: effective.rarebitRoot,
300
322
  allowExternalSession: effective.allowExternalSession === true,
301
323
  });
302
- return result;
324
+ return { ...result, rarebitEffective: effective };
303
325
  };
304
326
  const schedule = createDetachedMaterializer(materialize, (_error, ctx) => {
305
327
  notify(
@@ -308,15 +330,49 @@ export default function registerPiRarebit(pi, config = {}) {
308
330
  "error",
309
331
  );
310
332
  });
311
- const scheduleManualSummary = createDetachedMaterializer(
312
- (ctx) => materialize(ctx, { force: true }),
313
- (_error, ctx) =>
314
- notify(
315
- ctx,
316
- "Rarebit Summary failed; inspect its private materialization",
317
- "error",
318
- ),
319
- );
333
+ const manualAssessmentIsCurrent = async (result, ctx) => {
334
+ const session = identityFrom(ctx);
335
+ const generation = activeSession?.generation;
336
+ const selectionHash = selectRarebits(ctx?.sessionManager?.getBranch?.() ?? []).manifestHash;
337
+ if (!sameActiveSession(session)) return false;
338
+ const current = await readRarebitCurrent({
339
+ sessionFile: session.sessionFile,
340
+ sessionRoot: result.rarebitEffective?.sessionRoot,
341
+ rarebitRoot: result.rarebitEffective?.rarebitRoot,
342
+ allowExternalSession: result.rarebitEffective?.allowExternalSession === true,
343
+ }).catch(() => null);
344
+ if (!sameActiveSession(session) || activeSession?.generation !== generation ||
345
+ !sameActiveSession(identityFrom(ctx)) ||
346
+ selectRarebits(ctx?.sessionManager?.getBranch?.() ?? []).manifestHash !== selectionHash)
347
+ return false;
348
+ return current?.artifactState?.receiptRef?.jobId === result.record?.jobId &&
349
+ current?.artifactState?.nativeRef?.sessionId === session.sessionId &&
350
+ current?.artifactState?.nativeRef?.selectionManifestHash === selectionHash &&
351
+ current?.artifactState?.syncState === "assessment_current";
352
+ };
353
+ const scheduleManualSummary = (ctx) => {
354
+ void materialize(ctx, { force: true }).then(async (result) => {
355
+ if (result?.inFlight) {
356
+ notify(ctx, "Rarebit Summary is already in flight", "info");
357
+ return;
358
+ }
359
+ if (result?.record?.status === "ok") {
360
+ const isCurrent = await manualAssessmentIsCurrent(result, ctx);
361
+ const duplicate = result?.duplicate === true;
362
+ notify(ctx,
363
+ duplicate
364
+ ? isCurrent ? "Rarebit Summary is already current" : "Rarebit Summary already exists but is not current"
365
+ : isCurrent ? "Rarebit Summary updated and current" : "Rarebit Summary generated but is not current",
366
+ isCurrent ? "info" : "warning",
367
+ );
368
+ return;
369
+ }
370
+ if (result?.record?.status === "skipped_ephemeral_session")
371
+ notify(ctx, "Rarebit Summary unavailable: Session is not persisted", "warning");
372
+ else
373
+ notifyOutcome(ctx, { record: result?.record }, false, result?.rarebitEffective?.diagnostics ?? {});
374
+ }).catch(() => notify(ctx, "Rarebit Summary failed; inspect its private materialization", "error"));
375
+ };
320
376
 
321
377
  const identityFrom = (ctx) => ({
322
378
  sessionId: String(
@@ -407,8 +463,9 @@ export default function registerPiRarebit(pi, config = {}) {
407
463
 
408
464
  registerRarebitLifecycle(pi, schedule, {
409
465
  onSessionStart: (ctx, generation) => {
466
+ viewController.dispose();
410
467
  recapController.invalidate(ctx, generation);
411
- activeSession = identityFrom(ctx);
468
+ activeSession = { ...identityFrom(ctx), generation };
412
469
  activityReporter.start(projectActivity(ctx));
413
470
  },
414
471
  onAgentStart: (ctx, generation) => {
@@ -421,6 +478,7 @@ export default function registerPiRarebit(pi, config = {}) {
421
478
  recapController.invalidate(ctx, generation);
422
479
  },
423
480
  onSessionTree: (ctx, _event, generation) => {
481
+ viewController.refresh();
424
482
  recapController.invalidate(ctx, generation);
425
483
  activityReporter.update(projectActivity(ctx));
426
484
  },
@@ -428,11 +486,13 @@ export default function registerPiRarebit(pi, config = {}) {
428
486
  activityReporter.update(projectActivity(ctx));
429
487
  },
430
488
  onAgentSettled: (ctx) => {
489
+ viewController.refresh();
431
490
  recapController.updateContext(ctx);
432
491
  activityReporter.update(projectActivity(ctx));
433
492
  },
434
- onSessionShutdown: (ctx, generation) => {
435
- recapController.invalidate(ctx, generation);
493
+ onSessionShutdown: () => {
494
+ viewController.dispose();
495
+ recapController.dispose();
436
496
  activeSession = undefined;
437
497
  activityReporter.stop();
438
498
  },
@@ -513,6 +573,7 @@ ${fence(requestText)}`;
513
573
  let commandInput = String(args ?? "");
514
574
  const trimmedCommandInput = commandInput.trim();
515
575
  if (!trimmedCommandInput || trimmedCommandInput === "menu") {
576
+ viewController.dispose();
516
577
  const currentUiState = () => ({
517
578
  ...identityFrom(ctx),
518
579
  branchLeafId: ctx?.sessionManager?.getBranch?.()?.at(-1)?.id ?? null,
@@ -563,7 +624,12 @@ ${fence(requestText)}`;
563
624
  );
564
625
  return;
565
626
  }
627
+ if (subcommand === "view") {
628
+ await viewController.command(ctx, rest[0]);
629
+ return;
630
+ }
566
631
  if (subcommand === "settings") {
632
+ viewController.dispose();
567
633
  const currentUiState = () => ({
568
634
  ...identityFrom(ctx),
569
635
  branchLeafId: ctx?.sessionManager?.getBranch?.()?.at(-1)?.id ?? null,
@@ -728,7 +794,7 @@ ${fence(requestText)}`;
728
794
  : "settings_or_default";
729
795
  notify(
730
796
  ctx,
731
- `Rarebit ${subcommand}: auto-title=${autoTitleOverride ?? effective.autoTitle ?? true}; recap=${effective.recap?.enabled !== false ? "on" : "off"} after ${effective.recap?.delayMs ?? 60000}ms (${effective.recap?.timezone ?? "host"}); diagnostics=triggered:${effective.diagnostics?.summaryTriggered === true ? "on" : "off"},updated:${effective.diagnostics?.summaryUpdated === true ? "on" : "off"}; max_input_tokens=${effective.maxInputTokens ?? 64000}; max_rarebit_ratio=${policy.maxRarebitRatio} (${ratioSource}); min_total_length=${policy.minTotalLength} estimated tokens via ceil(chars/4) (${lengthSource}); measurement=${policy.measurementVersion}; model=${modelLabel(effective.model)} from ${effective.modelProvenance?.settingsKey ?? "explicit config"}${policyStatus}`,
797
+ `Rarebit ${subcommand}: view=${viewController.activeMode}; auto-title=${autoTitleOverride ?? effective.autoTitle ?? true}; recap=${effective.recap?.enabled !== false ? "on" : "off"} after ${effective.recap?.delayMs ?? 60000}ms (${effective.recap?.timezone ?? "host"}); diagnostics=triggered:${effective.diagnostics?.summaryTriggered === true ? "on" : "off"},updated:${effective.diagnostics?.summaryUpdated === true ? "on" : "off"}; max_input_tokens=${effective.maxInputTokens ?? 64000}; max_rarebit_ratio=${policy.maxRarebitRatio} (${ratioSource}); min_total_length=${policy.minTotalLength} estimated tokens via ceil(chars/4) (${lengthSource}); measurement=${policy.measurementVersion}; model=${modelLabel(effective.model)} from ${effective.modelProvenance?.settingsKey ?? "explicit config"}${policyStatus}`,
732
798
  "info",
733
799
  );
734
800
  return;
@@ -758,6 +824,10 @@ ${fence(requestText)}`;
758
824
  );
759
825
  return;
760
826
  }
827
+ if (subcommand === "got-it") {
828
+ acknowledgeRecap(ctx);
829
+ return;
830
+ }
761
831
  if (subcommand === "recap") {
762
832
  if (effective.recap?.enabled === false) {
763
833
  recapController.invalidate(ctx);
@@ -383,6 +383,7 @@ export function validateRarebitArtifactReceipt(record) {
383
383
  "hc-rarebit-summary-v4",
384
384
  "hc-rarebit-summary-v5",
385
385
  "hc-rarebit-summary-v6",
386
+ "hc-rarebit-summary-v7",
386
387
  ].includes(
387
388
  record.implementationVersion,
388
389
  ) ||
@@ -407,7 +408,7 @@ export function validateRarebitArtifactReceipt(record) {
407
408
  if (
408
409
  (hasInputCoverage && !validInputCoverage(record.inputCoverage)) ||
409
410
  (record.status === "ok" &&
410
- ["hc-rarebit-summary-v5", "hc-rarebit-summary-v6"].includes(
411
+ ["hc-rarebit-summary-v5", "hc-rarebit-summary-v6", "hc-rarebit-summary-v7"].includes(
411
412
  record.implementationVersion,
412
413
  ) &&
413
414
  !hasInputCoverage)
@@ -776,16 +777,7 @@ export function projectRarebitArtifactState({
776
777
  const settled = newest(
777
778
  exact.filter(({ record }) => record.lifecycleBoundary === "agent_settled"),
778
779
  );
779
- if (settled && (!activeRequest || newerThan(settled, activeRequest)))
780
- return result({
781
- syncState: "assessment_current",
782
- projection: projection(settled.record),
783
- applicability: "exact_selection",
784
- nativeRef: nativeReference(native),
785
- receiptRef: ref(settled.record),
786
- retry: null,
787
- });
788
- if (activeRequest) {
780
+ if (activeRequest && !(settled && newerThan(settled, activeRequest))) {
789
781
  const cutLength = activeRequest.record.selection.occurrenceCount;
790
782
  const settlementPending = native.selection.occurrences
791
783
  .slice(cutLength)
@@ -805,7 +797,11 @@ export function projectRarebitArtifactState({
805
797
  });
806
798
  }
807
799
  const assessment = newest(
808
- exact.filter(({ record }) => record.lifecycleBoundary !== "owner_request"),
800
+ exact.filter(({ record }) =>
801
+ record.lifecycleBoundary === "agent_settled" ||
802
+ record.lifecycleBoundary === "manual" ||
803
+ record.lifecycleBoundary === "session_start",
804
+ ),
809
805
  );
810
806
  if (assessment)
811
807
  return result({
@@ -29,6 +29,11 @@ export const RAREBIT_COMMAND_GRAMMAR = Object.freeze({
29
29
  description: "Open interactive Rarebit settings",
30
30
  forms: [[], [literal("global", "Edit global Pi settings")], [literal("project", "Edit trusted project settings")]],
31
31
  },
32
+ {
33
+ name: "view",
34
+ description: "Read Rarebits in the TUI",
35
+ forms: [[], [literal("context", "Current context only (experimental)")], [literal("all", "All Rarebits across compactions")], [literal("peek", "All Rarebits with the editor live")], [literal("off", "Normal transcript")]],
36
+ },
32
37
  {
33
38
  name: "help",
34
39
  description: "Show Rarebit command help",
@@ -93,6 +98,11 @@ export const RAREBIT_COMMAND_GRAMMAR = Object.freeze({
93
98
  description: "Show the current Recap in the TUI",
94
99
  forms: [[]],
95
100
  },
101
+ {
102
+ name: "got-it",
103
+ description: "Mark the latest displayed Recap as read",
104
+ forms: [[]],
105
+ },
96
106
  {
97
107
  name: "summarize",
98
108
  description: "Force a Rarebit Summary materialization",
@@ -6,7 +6,7 @@ import { createHash } from "node:crypto";
6
6
  // authority.
7
7
  export const RAREBIT_SELECTOR_VERSION = "rarebit-selector-v1";
8
8
  export const RAREBIT_MEASUREMENT_VERSION = "rarebit-prose-chars-div4-v1";
9
- export const RAREBIT_SUMMARY_PROMPT_VERSION = "rarebit-summary-v6";
9
+ export const RAREBIT_SUMMARY_PROMPT_VERSION = "rarebit-summary-v8";
10
10
  export const RAREBIT_SUMMARY_PROMPT_IDENTITY_VERSION =
11
11
  "rarebit-summary-prompt-v1";
12
12
  // User guidance is a UTF-16 string in JavaScript. Bound it before any Summary
@@ -16,7 +16,7 @@ export const MAX_RAREBIT_SUMMARY_PROMPT_CHARS = 64_000;
16
16
  // well below that limit even when the model returns multi-byte text.
17
17
  export const MAX_RAREBIT_SUMMARY_CHARS = 8_000;
18
18
  export const DEFAULT_RAREBIT_SUMMARY_PROMPT_GUIDANCE =
19
- "The summary is free-form prose. State when evidence is uncertain, confusing, contradictory, or importantly missing instead of inventing a coherent account.";
19
+ "Use 1–2 sentences, at most 300 characters including spaces. Lead with newest changes and needed user action. Flag material uncertainty or conflicting evidence.";
20
20
  export const RAREBIT_TITLE_PROMPT_VERSION = "rarebit-title-v1";
21
21
  export const RAREBIT_JOB_IDENTITY_VERSION = "rarebit-job-identity-v2";
22
22
 
@@ -324,6 +324,7 @@ function summaryPromptParts(messages, {
324
324
  omittedMessageCount = 0,
325
325
  omittedTextChars = 0,
326
326
  summaryPrompt,
327
+ readCheckpoint,
327
328
  } = {}) {
328
329
  const ownerRequest = lifecycleBoundary === "owner_request";
329
330
  const resolvedPrompt = normalizeRarebitSummaryPrompt(summaryPrompt);
@@ -343,6 +344,7 @@ function summaryPromptParts(messages, {
343
344
  "Summarize only what is explicitly stated in the ordered Rarebit evidence stream below.",
344
345
  "If an omission record appears, it is authoritative: earlier evidence is unavailable to you. Do not infer what was trimmed, and state when important missing evidence makes the account uncertain or confusing.",
345
346
  styleLine,
347
+ ...(readCheckpoint ? ["A recap_read record marks the evidence covered by the last Recap the user acknowledged. Focus the summary prose on updates AFTER that record, retaining earlier context only when needed to understand them. Acknowledgement does not resolve requests, grant approval, or prove task completion. Assess status against ALL supplied evidence, including unresolved requests before the record. If there are no later updates, say so briefly."] : []),
346
348
  "Identify every active, non-superseded request visible in the supplied evidence, not only the last turn. Explicit cancellation, replacement, supersession, and later resolution make an earlier request inactive.",
347
349
  "Selected evidence contains only user and assistant message prose at Rarebit continuation or stop boundaries. Tool-call inputs, tool results, hidden reasoning, and transport records are deliberately absent.",
348
350
  "Absence of a tool transcript is unobservable and must never by itself imply that work was not performed.",
@@ -377,6 +379,7 @@ export function composeRarebitSummaryDerivationInput(
377
379
  lifecycleBoundary = "manual",
378
380
  maxPromptChars = Number.MAX_SAFE_INTEGER,
379
381
  summaryPrompt,
382
+ readCheckpoint,
380
383
  } = {},
381
384
  ) {
382
385
  if (
@@ -388,45 +391,63 @@ export function composeRarebitSummaryDerivationInput(
388
391
  const resolvedPrompt = normalizeRarebitSummaryPrompt(summaryPrompt);
389
392
 
390
393
  const allMessages = semanticMessages(selection);
394
+ const checkpointIndex = readCheckpoint
395
+ ? selection.occurrences.findIndex((item) => item.sourceEntryId === readCheckpoint.coveredEntryId)
396
+ : -1;
397
+ const checkpointMessage = checkpointIndex < 0 ? null : {
398
+ type: "recap_read", coveredEntryId: readCheckpoint.coveredEntryId,
399
+ text: "The user acknowledged the Recap covering evidence through this point.",
400
+ };
401
+ let start = 0;
391
402
  let messages = allMessages.slice();
392
403
  let omittedMessageCount = 0;
393
404
  let omittedTextChars = 0;
394
- let prompt = renderSummaryPrompt(messages, {
395
- lifecycleBoundary,
396
- summaryPrompt,
397
- });
405
+ const render = () => {
406
+ const positioned = messages.slice();
407
+ if (checkpointMessage) {
408
+ // Keep read metadata beside the retained suffix. If the covered evidence
409
+ // was trimmed, place the boundary before the first retained occurrence.
410
+ const insertion = Math.max(0, Math.min(messages.length, checkpointIndex + 1 - start));
411
+ positioned.splice(insertion, 0, checkpointMessage);
412
+ }
413
+ return renderSummaryPrompt(positioned, {
414
+ lifecycleBoundary, readCheckpoint, omittedMessageCount, omittedTextChars,
415
+ summaryPrompt,
416
+ });
417
+ };
418
+ let prompt = render();
398
419
  while (prompt.length > maxPromptChars && messages.length > 1) {
399
420
  messages.shift();
421
+ start += 1;
400
422
  omittedMessageCount += 1;
401
- prompt = renderSummaryPrompt(messages, {
402
- lifecycleBoundary,
403
- omittedMessageCount,
404
- summaryPrompt,
405
- });
423
+ prompt = render();
406
424
  }
407
425
  if (prompt.length > maxPromptChars && messages.length === 1) {
408
- const original = messages[0].text;
426
+ const originalMessage = messages[0];
427
+ const original = originalMessage.text;
409
428
  let low = 0;
410
429
  let high = original.length;
411
430
  let best = null;
412
431
  while (low <= high) {
413
432
  const removed = Math.floor((low + high) / 2);
414
- const candidate = [{ ...messages[0], text: original.slice(removed) }];
415
- const candidatePrompt = renderSummaryPrompt(candidate, {
416
- lifecycleBoundary,
417
- omittedMessageCount,
418
- omittedTextChars: removed,
419
- summaryPrompt,
420
- });
433
+ messages[0] = { ...messages[0], text: original.slice(removed) };
434
+ omittedTextChars = removed;
435
+ const candidatePrompt = render();
421
436
  if (candidatePrompt.length <= maxPromptChars) {
422
- best = { messages: candidate, prompt: candidatePrompt, removed };
437
+ best = { prompt: candidatePrompt, removed };
423
438
  high = removed - 1;
424
439
  } else low = removed + 1;
425
440
  }
426
441
  if (best) {
427
- messages = best.messages;
442
+ messages[0] = { ...originalMessage, text: original.slice(best.removed) };
428
443
  prompt = best.prompt;
429
444
  omittedTextChars = best.removed;
445
+ } else {
446
+ // Keep the original evidence and explicit overflow when the fixed
447
+ // omission contract cannot fit within the caller's budget.
448
+ messages[0] = originalMessage;
449
+ omittedTextChars = 0;
450
+ prompt = render();
430
451
  }
431
452
  }
432
453
  return {
@@ -0,0 +1,59 @@
1
+ import { selectRarebits } from "./rarebit-core.mjs";
2
+ // Native custom entries are UI/read state, never provider conversation messages.
3
+ export const RECAP_ENTRY_TYPE = "rarebit-recap";
4
+ export const RECAP_READ_TYPE = "rarebit-recap-read";
5
+
6
+ export function latestRecap(branch) {
7
+ const entries = Array.isArray(branch) ? branch : [];
8
+ const selection = selectRarebits(entries);
9
+ return entries.findLast((entry, recapIndex) => {
10
+ if (entry.type !== "custom" || entry.customType !== RECAP_ENTRY_TYPE || entry.data?.version !== 1 ||
11
+ typeof entry.data?.jobId !== "string" || typeof entry.data?.coveredEntryId !== "string" ||
12
+ typeof entry.data?.selectionHash !== "string") return false;
13
+ const covered = selection.occurrences.find((item) => item.sourceEntryId === entry.data.coveredEntryId);
14
+ const coveredIndex = covered ? entries.findIndex((item) => item.id === covered.sourceEntryId) : -1;
15
+ return coveredIndex >= 0 && coveredIndex < recapIndex &&
16
+ selectRarebits(entries.slice(0, coveredIndex + 1)).manifestHash === entry.data.selectionHash;
17
+ });
18
+ }
19
+
20
+ function validatedMarker(entries, marker, selection) {
21
+ if (marker.type !== "custom" || marker.customType !== RECAP_READ_TYPE || marker.data?.version !== 1)
22
+ return null;
23
+ const markerIndex = entries.indexOf(marker);
24
+ const recapIndex = entries.findIndex((entry) => entry.id === marker.data.recapEntryId);
25
+ const recap = entries[recapIndex];
26
+ if (recapIndex < 0 || recapIndex >= markerIndex || recap.customType !== RECAP_ENTRY_TYPE || recap.data?.version !== 1 ||
27
+ recap.data.jobId !== marker.data.jobId || recap.data.coveredEntryId !== marker.data.coveredEntryId ||
28
+ recap.data.selectionHash !== marker.data.selectionHash) return null;
29
+ const covered = selection.occurrences.find((item) => item.sourceEntryId === marker.data.coveredEntryId);
30
+ const coveredIndex = covered ? entries.findIndex((entry) => entry.id === covered.sourceEntryId) : -1;
31
+ if (!covered || coveredIndex < 0 || coveredIndex >= recapIndex ||
32
+ selectRarebits(entries.slice(0, coveredIndex + 1)).manifestHash !== recap.data.selectionHash)
33
+ return null;
34
+ return { recap, covered };
35
+ }
36
+
37
+ export function recapIsRead(branch, recap, selection = selectRarebits(branch)) {
38
+ const entries = Array.isArray(branch) ? branch : [];
39
+ return entries.some((marker) => {
40
+ const valid = validatedMarker(entries, marker, selection);
41
+ return valid && valid.recap.data.jobId === recap?.data?.jobId &&
42
+ valid.recap.data.coveredEntryId === recap?.data?.coveredEntryId &&
43
+ valid.recap.data.selectionHash === recap?.data?.selectionHash;
44
+ });
45
+ }
46
+
47
+ export function recapReadCheckpoint(branch, selection) {
48
+ const entries = Array.isArray(branch) ? branch : [];
49
+ // Resolve coverage against the referenced earlier Recap. A click acknowledges
50
+ // that evidence cut, not later conversation or an unvalidated custom entry.
51
+ for (const marker of entries.slice().reverse()) {
52
+ const valid = validatedMarker(entries, marker, selection);
53
+ if (!valid) continue;
54
+ return { type: "recap_read", markerEntryId: marker.id,
55
+ recapEntryId: valid.recap.id, coveredEntryId: valid.covered.sourceEntryId,
56
+ coveredOrder: valid.covered.order };
57
+ }
58
+ return null;
59
+ }