@hypercarrier/rarebit 0.2.0 → 0.2.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.
@@ -204,14 +204,15 @@ export default function registerFixtureRarebit(pi) {
204
204
  return output.includes("FULL_RECAP_TAIL_24") ? output : false;
205
205
  }, 75_000);
206
206
  const widgetAnsi = await pane(socket, session, true);
207
- assert.match(widgetPane, /Recap/);
207
+ assert.match(widgetPane, /Recap·appearsfinished·[0-9]{2}\/[0-9]{2} [0-9]{2}:[0-9]{2}\(GMT[+-][0-9][^\n]*\[got it\]/);
208
+ assert.match(widgetPane, /\[got it\]/);
208
209
  assert.match(widgetPane, /FULL_RECAP_TAIL_1/);
209
210
  assert.match(widgetPane, /FULL_RECAP_TAIL_24/);
210
211
  assert.doesNotMatch(widgetPane, /recap expand/i);
211
212
  assert.match(widgetAnsi, /\x1b\[[0-9;]*m/);
212
213
  assert.match(widgetAnsi, /Recap/);
213
- assert.match(widgetAnsi, /\x1b\[38;5;241m[^\n]*Recap/, "Recap header uses the muted foreground");
214
- assert.match(widgetAnsi, /\x1b\[38;5;244m[^\n]*FULL_RECAP_TAIL_1/, "Recap body uses the faded foreground");
214
+ assert.match(widgetAnsi, /\x1b\[38;(?:5;241|2;102;102;102)m[^\n]*Recap/, "Recap header uses the muted foreground");
215
+ assert.match(widgetAnsi, /\x1b\[38;(?:5;244|2;128;128;128)m[^\n]*FULL_RECAP_TAIL_1/, "Recap body uses the faded foreground");
215
216
  assert.ok(Date.now() - summaryAt >= 59_750, "Recap rendered before the one-minute default");
216
217
 
217
218
  const requestCountAtWidget = requests.length;
@@ -225,17 +226,18 @@ export default function registerFixtureRarebit(pi) {
225
226
 
226
227
  await press(socket, session, "Enter");
227
228
  await waitFor("second provider request", async () => requests.filter((entry) => entry.kind === "interactive").length >= 2, 15_000);
228
- const afterSubmit = await waitFor("Recap clear after submit", async () => {
229
+ const afterSubmit = await waitFor("second interactive response", async () => {
229
230
  const output = await pane(socket, session);
230
- return !output.includes("FULL_RECAP_TAIL_24") ? output : false;
231
- }, 5_000);
232
- assert.doesNotMatch(afterSubmit, /FULL_RECAP_TAIL_24/);
231
+ return output.includes("INTERACTIVE_2_DONE") ? output : false;
232
+ }, 15_000);
233
233
 
234
234
  const secondInteractive = requests.filter((entry) => entry.kind === "interactive")[1];
235
235
  assert.doesNotMatch(JSON.stringify(secondInteractive.body), /FULL_RECAP_TAIL/);
236
236
  const sessionFiles = (await walk(sessionDir)).filter((file) => file.endsWith(".jsonl"));
237
237
  const sessionText = (await Promise.all(sessionFiles.map((file) => readFile(file, "utf8")))).join("\n");
238
- assert.doesNotMatch(sessionText, /FULL_RECAP_TAIL/);
238
+ const nativeEntries = sessionText.split("\n").filter(Boolean).map(JSON.parse);
239
+ assert.ok(nativeEntries.some((entry) => entry.type === "custom" && entry.customType === "rarebit-recap" && entry.data?.summary?.includes("FULL_RECAP_TAIL_24")), "the scrollable Recap is persisted as native custom evidence");
240
+ assert.ok(!nativeEntries.filter((entry) => entry.type === "message").some((entry) => JSON.stringify(entry).includes("FULL_RECAP_TAIL")), "Recap prose stays out of model conversation messages");
239
241
  const settings = JSON.parse(await readFile(join(agentDir, "settings.json"), "utf8"));
240
242
  assert.deepEqual(settings.other_extension, { preserve: true });
241
243
  assert.equal(settings.rarebit.summary_prompt, "Use short factual bullets and state uncertainty.");
@@ -251,7 +253,7 @@ export default function registerFixtureRarebit(pi) {
251
253
  elapsedFromSummaryMs: Date.now() - summaryAt,
252
254
  providerRequests: requests.length,
253
255
  evidenceRoot: keepTemp ? temp : null,
254
- checks: [artifactLabel, "custom summary_prompt reached provider", "full multiline Recap tail rendered", "ANSI themed widget rendered", "typing preserved Recap", "submit cleared Recap", "display content excluded from next provider and Session"],
256
+ checks: [artifactLabel, "custom summary_prompt reached provider", "full multiline native Recap rendered", "ANSI themed Recap rendered", "typing preserved Recap", "native Recap evidence survived submit", "display content excluded from next provider conversation messages"],
255
257
  }, null, 2));
256
258
  } catch (error) {
257
259
  console.error(`Rarebit v0.2 Pi E2E failed: ${error?.stack ?? error}`);
@@ -265,5 +267,5 @@ export default function registerFixtureRarebit(pi) {
265
267
  await execFile(tmux, ["-L", socket, "kill-server"]).catch(() => {});
266
268
  }
267
269
  if (provider?.listening) await new Promise((resolvePromise) => provider.close(resolvePromise));
268
- if (!keepTemp) await rm(temp, { recursive: true, force: true });
270
+ if (!keepTemp) await rm(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
269
271
  }
@@ -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,7 @@ import {
42
42
  registerRarebitLifecycle,
43
43
  } from "./lifecycle.mjs";
44
44
  import { createRarebitRecapController } from "./rarebit-recap.mjs";
45
+ import { readRarebitCurrent } from "./rarebit-store.mjs";
45
46
  import {
46
47
  openRarebitPalette,
47
48
  openRarebitSettings,
@@ -134,9 +135,22 @@ export default function registerPiRarebit(pi, config = {}) {
134
135
  const activityReporter =
135
136
  explicit.activityReporter ?? createHerdrActivityReporter();
136
137
  const recapController = createRarebitRecapController({
138
+ appendEntry: typeof pi.registerEntryRenderer === "function" && typeof pi.appendEntry === "function"
139
+ ? (type, data) => pi.appendEntry(type, data) : undefined,
137
140
  delayMs: explicit.recap?.delayMs,
138
141
  timezone: explicit.recap?.timezone,
139
142
  });
143
+ pi.registerEntryRenderer?.("rarebit-recap", recapController.renderEntry);
144
+ const acknowledgeRecap = (ctx) => {
145
+ const result = recapController.acknowledge(ctx);
146
+ notify(ctx, result.acknowledged ? "Recap marked as read" :
147
+ result.reason === "already_read" ? "Recap already marked as read" : "No Recap to mark as read in this branch", "info");
148
+ };
149
+ if (typeof pi.registerEntryRenderer === "function")
150
+ pi.registerShortcut?.("ctrl+alt+g", {
151
+ description: "Mark the latest Recap as read",
152
+ handler: async (ctx) => acknowledgeRecap(ctx),
153
+ });
140
154
  const projectActivity = (ctx) => {
141
155
  const entries = ctx?.sessionManager?.getBranch?.() ?? [];
142
156
  return projectRarebitSessionActivity({
@@ -287,7 +301,7 @@ export default function registerPiRarebit(pi, config = {}) {
287
301
  );
288
302
  },
289
303
  });
290
- notifyOutcome(ctx, result, synthesisTriggered, effective.diagnostics);
304
+ if (!force) notifyOutcome(ctx, result, synthesisTriggered, effective.diagnostics);
291
305
  recapController.offer({
292
306
  ctx,
293
307
  result,
@@ -299,7 +313,7 @@ export default function registerPiRarebit(pi, config = {}) {
299
313
  rarebitRoot: effective.rarebitRoot,
300
314
  allowExternalSession: effective.allowExternalSession === true,
301
315
  });
302
- return result;
316
+ return { ...result, rarebitEffective: effective };
303
317
  };
304
318
  const schedule = createDetachedMaterializer(materialize, (_error, ctx) => {
305
319
  notify(
@@ -308,15 +322,49 @@ export default function registerPiRarebit(pi, config = {}) {
308
322
  "error",
309
323
  );
310
324
  });
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
- );
325
+ const manualAssessmentIsCurrent = async (result, ctx) => {
326
+ const session = identityFrom(ctx);
327
+ const generation = activeSession?.generation;
328
+ const selectionHash = selectRarebits(ctx?.sessionManager?.getBranch?.() ?? []).manifestHash;
329
+ if (!sameActiveSession(session)) return false;
330
+ const current = await readRarebitCurrent({
331
+ sessionFile: session.sessionFile,
332
+ sessionRoot: result.rarebitEffective?.sessionRoot,
333
+ rarebitRoot: result.rarebitEffective?.rarebitRoot,
334
+ allowExternalSession: result.rarebitEffective?.allowExternalSession === true,
335
+ }).catch(() => null);
336
+ if (!sameActiveSession(session) || activeSession?.generation !== generation ||
337
+ !sameActiveSession(identityFrom(ctx)) ||
338
+ selectRarebits(ctx?.sessionManager?.getBranch?.() ?? []).manifestHash !== selectionHash)
339
+ return false;
340
+ return current?.artifactState?.receiptRef?.jobId === result.record?.jobId &&
341
+ current?.artifactState?.nativeRef?.sessionId === session.sessionId &&
342
+ current?.artifactState?.nativeRef?.selectionManifestHash === selectionHash &&
343
+ current?.artifactState?.syncState === "assessment_current";
344
+ };
345
+ const scheduleManualSummary = (ctx) => {
346
+ void materialize(ctx, { force: true }).then(async (result) => {
347
+ if (result?.inFlight) {
348
+ notify(ctx, "Rarebit Summary is already in flight", "info");
349
+ return;
350
+ }
351
+ if (result?.record?.status === "ok") {
352
+ const isCurrent = await manualAssessmentIsCurrent(result, ctx);
353
+ const duplicate = result?.duplicate === true;
354
+ notify(ctx,
355
+ duplicate
356
+ ? isCurrent ? "Rarebit Summary is already current" : "Rarebit Summary already exists but is not current"
357
+ : isCurrent ? "Rarebit Summary updated and current" : "Rarebit Summary generated but is not current",
358
+ isCurrent ? "info" : "warning",
359
+ );
360
+ return;
361
+ }
362
+ if (result?.record?.status === "skipped_ephemeral_session")
363
+ notify(ctx, "Rarebit Summary unavailable: Session is not persisted", "warning");
364
+ else
365
+ notifyOutcome(ctx, { record: result?.record }, false, result?.rarebitEffective?.diagnostics ?? {});
366
+ }).catch(() => notify(ctx, "Rarebit Summary failed; inspect its private materialization", "error"));
367
+ };
320
368
 
321
369
  const identityFrom = (ctx) => ({
322
370
  sessionId: String(
@@ -408,7 +456,7 @@ export default function registerPiRarebit(pi, config = {}) {
408
456
  registerRarebitLifecycle(pi, schedule, {
409
457
  onSessionStart: (ctx, generation) => {
410
458
  recapController.invalidate(ctx, generation);
411
- activeSession = identityFrom(ctx);
459
+ activeSession = { ...identityFrom(ctx), generation };
412
460
  activityReporter.start(projectActivity(ctx));
413
461
  },
414
462
  onAgentStart: (ctx, generation) => {
@@ -758,6 +806,10 @@ ${fence(requestText)}`;
758
806
  );
759
807
  return;
760
808
  }
809
+ if (subcommand === "got-it") {
810
+ acknowledgeRecap(ctx);
811
+ return;
812
+ }
761
813
  if (subcommand === "recap") {
762
814
  if (effective.recap?.enabled === false) {
763
815
  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({
@@ -93,6 +93,11 @@ export const RAREBIT_COMMAND_GRAMMAR = Object.freeze({
93
93
  description: "Show the current Recap in the TUI",
94
94
  forms: [[]],
95
95
  },
96
+ {
97
+ name: "got-it",
98
+ description: "Mark the latest displayed Recap as read",
99
+ forms: [[]],
100
+ },
96
101
  {
97
102
  name: "summarize",
98
103
  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
+ }