@hypercarrier/rarebit 0.1.0-alpha.6 → 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.
@@ -0,0 +1,479 @@
1
+ import { RECAP_ENTRY_TYPE, RECAP_READ_TYPE, latestRecap, recapIsRead } from "./rarebit-read-checkpoint.mjs";
2
+ import { exactSelectionApplies, projectRarebitArtifactState } from "./rarebit-artifact-state.mjs";
3
+ import { selectRarebits } from "./rarebit-core.mjs";
4
+ import {
5
+ DEFAULT_RAREBIT_RECAP_DELAY_MS,
6
+ DEFAULT_RAREBIT_RECAP_TIMEZONE,
7
+ normalizeRarebitRecapTimezone,
8
+ } from "./rarebit-settings.mjs";
9
+ import { readRarebitCurrent } from "./rarebit-store.mjs";
10
+ import { rarebitSummaryPresentation } from "./rarebit-visual-language.mjs";
11
+
12
+ export const RAREBIT_RECAP_WIDGET_KEY = "rarebit-recap";
13
+ export {
14
+ DEFAULT_RAREBIT_RECAP_DELAY_MS,
15
+ DEFAULT_RAREBIT_RECAP_TIMEZONE,
16
+ } from "./rarebit-settings.mjs";
17
+
18
+ function sessionIdentity(ctx) {
19
+ return {
20
+ sessionId: String(
21
+ ctx?.sessionManager?.getHeader?.()?.id ?? ctx?.sessionId ?? "",
22
+ ),
23
+ sessionFile: ctx?.sessionManager?.getSessionFile?.() ?? null,
24
+ };
25
+ }
26
+
27
+ function branchSnapshot(ctx) {
28
+ const branch = ctx?.sessionManager?.getBranch?.();
29
+ return Array.isArray(branch) ? branch.slice() : [];
30
+ }
31
+
32
+ function receiptCoverage(receipt, ctx) {
33
+ if (receipt?.status !== "ok") return { state: null, coverage: null };
34
+ const identity = sessionIdentity(ctx);
35
+ if (!identity.sessionId || !identity.sessionFile || receipt.sessionId !== identity.sessionId) return { state: null, coverage: null };
36
+ const selection = selectRarebits(branchSnapshot(ctx));
37
+ const current = projectRarebitArtifactState({
38
+ native: { availability: "available", sessionId: identity.sessionId,
39
+ selection: { ...selection, selectorVersion: selection.manifest.selectorVersion } },
40
+ materialization: { availability: "available", records: [receipt] },
41
+ });
42
+ if (current.receiptRef?.jobId !== receipt.jobId ||
43
+ !["request_current", "assessment_current"].includes(current.syncState)) return { state: current, coverage: null };
44
+ if (current.applicability === "exact_selection" && exactSelectionApplies(receipt.selection, selection))
45
+ return { state: current, coverage: { coveredEntryId: selection.occurrences.at(-1)?.sourceEntryId, selectionHash: receipt.selection.manifestHash } };
46
+ if (current.applicability === "request_generation") {
47
+ const covered = selection.occurrences[receipt.selection.occurrenceCount - 1];
48
+ return { state: current, coverage: covered ? { coveredEntryId: covered.sourceEntryId, selectionHash: receipt.selection.manifestHash } : null };
49
+ }
50
+ return { state: current, coverage: null };
51
+ }
52
+
53
+ function observedAtLabel(observedAt, timezone) {
54
+ const date = new Date(observedAt);
55
+ if (Number.isNaN(date.valueOf())) return "unknown";
56
+ const usesHostTimezone = timezone === DEFAULT_RAREBIT_RECAP_TIMEZONE;
57
+ const options = usesHostTimezone ? {} : { timeZone: timezone };
58
+ const parts = new Intl.DateTimeFormat("en-US", {
59
+ month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
60
+ hourCycle: "h23", ...options,
61
+ }).formatToParts(date);
62
+ const value = (type) => parts.find((part) => part.type === type)?.value ?? "00";
63
+ let offset = "GMT";
64
+ try {
65
+ offset = new Intl.DateTimeFormat("en", { timeZoneName: "shortOffset", ...options })
66
+ .formatToParts(date).find((part) => part.type === "timeZoneName")?.value ?? "GMT";
67
+ } catch {}
68
+ return `${value("month")}/${value("day")} ${value("hour")}:${value("minute")}(${offset})`;
69
+ }
70
+
71
+ let recapWidgetComponents;
72
+ let loadedComponents;
73
+
74
+ async function loadRecapWidgetComponents() {
75
+ if (!recapWidgetComponents) {
76
+ recapWidgetComponents = Promise.all([
77
+ import("@earendil-works/pi-tui"),
78
+ import("@earendil-works/pi-coding-agent"),
79
+ ]).then(([tui, agent]) => ({
80
+ Container: tui.Container,
81
+ Text: tui.Text,
82
+ DynamicBorder: agent.DynamicBorder,
83
+ truncateToWidth: tui.truncateToWidth,
84
+ visibleWidth: tui.visibleWidth,
85
+ })).then((components) => (loadedComponents = components));
86
+ }
87
+ return recapWidgetComponents;
88
+ }
89
+
90
+ function summaryContent(
91
+ receipt,
92
+ { timezone = DEFAULT_RAREBIT_RECAP_TIMEZONE } = {},
93
+ ) {
94
+ const presentation = rarebitSummaryPresentation(receipt.sessionStatus);
95
+ const mark = presentation.mark ?? "";
96
+ const heading = `${mark}Recap·${presentation.label.replaceAll(" ", "")}·${observedAtLabel(receipt.observedAt, timezone)}`;
97
+ const summary = String(receipt.summary ?? "").trim();
98
+ return { heading, summary };
99
+ }
100
+
101
+ function recapComponent(content, theme) {
102
+ const border = (text) => theme.fg("borderMuted", text);
103
+ const widget = new loadedComponents.Container();
104
+ const summary = new loadedComponents.Text("", 1, 0);
105
+ const compactSummary = new loadedComponents.Text("", 0, 0);
106
+ let previousSummary;
107
+ let previousCompactSummary;
108
+ const themed = (text, color, component, previous) => {
109
+ const rendered = theme.fg(color, text);
110
+ if (rendered !== previous.value) {
111
+ previous.value = rendered;
112
+ component.setText(rendered);
113
+ }
114
+ };
115
+ const themedHeading = {
116
+ render(width) {
117
+ const action = content.action
118
+ ? loadedComponents.truncateToWidth(theme.fg("dim", content.action), Math.max(0, width), "")
119
+ : "";
120
+ const actionWidth = loadedComponents.visibleWidth(action);
121
+ const heading = loadedComponents.truncateToWidth(
122
+ theme.fg("dim", content.heading), Math.max(0, width - actionWidth), "",
123
+ );
124
+ return [`${heading}${action}`];
125
+ },
126
+ invalidate() {},
127
+ };
128
+ const themedSummary = {
129
+ render(width) {
130
+ if (width < 3) {
131
+ themed(content.summary, "muted", compactSummary, previousCompactSummary ??= {});
132
+ return compactSummary.render(width);
133
+ }
134
+ themed(content.summary, "muted", summary, previousSummary ??= {});
135
+ return summary.render(width);
136
+ },
137
+ invalidate() {
138
+ previousSummary = undefined;
139
+ previousCompactSummary = undefined;
140
+ summary.invalidate();
141
+ compactSummary.invalidate();
142
+ },
143
+ };
144
+ widget.addChild(new loadedComponents.DynamicBorder(border));
145
+ widget.addChild(themedHeading);
146
+ widget.addChild(themedSummary);
147
+ widget.addChild(new loadedComponents.DynamicBorder(border));
148
+ return {
149
+ render(width) {
150
+ const lines = widget.render(width);
151
+ return width < 3
152
+ ? lines.map((line) => loadedComponents.truncateToWidth(line, Math.max(0, width), ""))
153
+ : lines;
154
+ },
155
+ invalidate() { widget.invalidate(); },
156
+ };
157
+ }
158
+
159
+ function currentContextOptions(ctx, options) {
160
+ return {
161
+ sessionFile: sessionIdentity(ctx).sessionFile,
162
+ ...(options?.sessionRoot ? { sessionRoot: options.sessionRoot } : {}),
163
+ ...(options?.rarebitRoot ? { rarebitRoot: options.rarebitRoot } : {}),
164
+ ...(options?.allowExternalSession === true
165
+ ? { allowExternalSession: true }
166
+ : {}),
167
+ };
168
+ }
169
+
170
+ function sameIdentity(left, right) {
171
+ return (
172
+ left?.sessionId === right?.sessionId &&
173
+ left?.sessionFile === right?.sessionFile
174
+ );
175
+ }
176
+
177
+ /**
178
+ * Private Pi-only presentation controller. It consumes existing receipts and
179
+ * uses native custom entries for scrollable, human-only Recaps and read state.
180
+ * Older Pi versions retain the widget fallback. It never invokes a model.
181
+ */
182
+ export function createRarebitRecapController({
183
+ delayMs = DEFAULT_RAREBIT_RECAP_DELAY_MS,
184
+ timezone = DEFAULT_RAREBIT_RECAP_TIMEZONE,
185
+ readCurrent = readRarebitCurrent,
186
+ appendEntry,
187
+ setTimeoutFn = setTimeout,
188
+ clearTimeoutFn = clearTimeout,
189
+ } = {}) {
190
+ if (!Number.isSafeInteger(delayMs) || delayMs < 0)
191
+ throw new RangeError("Rarebit recap delay must be a nonnegative integer");
192
+ const recapTimezone = normalizeRarebitRecapTimezone(timezone);
193
+
194
+ let liveContext;
195
+ let liveLifecycleGeneration = 0;
196
+ let controllerGeneration = 0;
197
+ let timer;
198
+ let pending;
199
+ let offerEpoch = 0;
200
+ const displayedReceipts = new Set();
201
+
202
+ const tuiContext = (ctx = liveContext) =>
203
+ ctx?.mode === "tui" && typeof ctx?.ui?.setWidget === "function"
204
+ ? ctx
205
+ : null;
206
+
207
+ const clearWidget = (ctx = liveContext) => {
208
+ const target = tuiContext(ctx);
209
+ if (!target) return;
210
+ try {
211
+ target.ui.setWidget(RAREBIT_RECAP_WIDGET_KEY, undefined, {
212
+ placement: "aboveEditor",
213
+ });
214
+ } catch {
215
+ // TUI rendering is best-effort and must not affect Summary materialization.
216
+ }
217
+ };
218
+
219
+ const cancelTimer = () => {
220
+ if (timer !== undefined) clearTimeoutFn(timer);
221
+ timer = undefined;
222
+ pending = undefined;
223
+ offerEpoch += 1;
224
+ };
225
+
226
+ const updateContext = (ctx, lifecycleGeneration) => {
227
+ if (ctx) liveContext = ctx;
228
+ if (Number.isSafeInteger(lifecycleGeneration))
229
+ liveLifecycleGeneration = lifecycleGeneration;
230
+ };
231
+
232
+ const invalidate = (ctx, lifecycleGeneration) => {
233
+ const previousContext = liveContext;
234
+ clearWidget(previousContext);
235
+ updateContext(ctx, lifecycleGeneration);
236
+ controllerGeneration += 1;
237
+ cancelTimer();
238
+ clearWidget(ctx);
239
+ };
240
+
241
+ const captureMaterialization = (ctx) => {
242
+ const snapshotGeneration = ctx?.rarebitLifecycleGeneration;
243
+ if (
244
+ Number.isSafeInteger(snapshotGeneration) &&
245
+ snapshotGeneration !== liveLifecycleGeneration
246
+ )
247
+ return null;
248
+ return {
249
+ controllerGeneration,
250
+ lifecycleGeneration: Number.isSafeInteger(snapshotGeneration)
251
+ ? snapshotGeneration
252
+ : liveLifecycleGeneration,
253
+ };
254
+ };
255
+
256
+ const tokenIsCurrent = (token) =>
257
+ Boolean(
258
+ token &&
259
+ token.controllerGeneration === controllerGeneration &&
260
+ token.lifecycleGeneration === liveLifecycleGeneration,
261
+ );
262
+
263
+ const readReceipt = async (ctx, options) => {
264
+ const identity = sessionIdentity(ctx);
265
+ if (!identity.sessionFile) return null;
266
+ const current = await readCurrent({
267
+ ...currentContextOptions(ctx, options),
268
+ });
269
+ const receipt = current?.receipt;
270
+ const artifactState = current?.artifactState;
271
+ if (!receipt || !["request_current", "assessment_current"].includes(artifactState?.syncState) ||
272
+ !["request_generation", "exact_selection"].includes(artifactState?.applicability) ||
273
+ artifactState?.receiptRef?.jobId !== receipt.jobId ||
274
+ artifactState?.projection?.status !== receipt.sessionStatus)
275
+ return { receipt: null, reason: artifactState?.projection?.reason ?? artifactState?.retry?.reason ?? artifactState?.syncState ?? "no_summary" };
276
+ const projection = receiptCoverage(receipt, ctx);
277
+ return projection.coverage ? { receipt, coverage: projection.coverage } : {
278
+ receipt: null, reason: projection.state?.retry?.reason ?? projection.state?.projection?.reason ?? projection.state?.syncState ?? "stale_or_divergent",
279
+ };
280
+ };
281
+
282
+ const display = async (
283
+ ctx,
284
+ receipt,
285
+ { timezone = recapTimezone, token, expectedOfferEpoch, storeOptions } = {},
286
+ ) => {
287
+ const target = tuiContext(ctx);
288
+ let coverage = receiptCoverage(receipt, ctx).coverage;
289
+ if (!target || !coverage) return false;
290
+ const observedControllerGeneration = controllerGeneration;
291
+ const observedLifecycleGeneration = liveLifecycleGeneration;
292
+ const observedOfferEpoch = expectedOfferEpoch ?? offerEpoch;
293
+ const observedIdentity = sessionIdentity(ctx);
294
+ try {
295
+ await loadRecapWidgetComponents();
296
+ const current = await readReceipt(liveContext, storeOptions).catch(() => null);
297
+ if (
298
+ observedControllerGeneration !== controllerGeneration ||
299
+ observedLifecycleGeneration !== liveLifecycleGeneration ||
300
+ observedOfferEpoch !== offerEpoch ||
301
+ (token && !tokenIsCurrent(token)) ||
302
+ !sameIdentity(observedIdentity, sessionIdentity(liveContext)) ||
303
+ !current?.receipt || current.receipt.jobId !== receipt.jobId ||
304
+ current.coverage.coveredEntryId !== coverage.coveredEntryId ||
305
+ current.coverage.selectionHash !== coverage.selectionHash
306
+ )
307
+ return false;
308
+ receipt = current.receipt;
309
+ coverage = current.coverage;
310
+ const content = summaryContent(receipt, {
311
+ timezone: normalizeRarebitRecapTimezone(timezone),
312
+ });
313
+ if (typeof appendEntry === "function") {
314
+ const existing = branchSnapshot(ctx).findLast((entry) =>
315
+ entry.type === "custom" && entry.customType === RECAP_ENTRY_TYPE &&
316
+ entry.data?.version === 1 && entry.data.jobId === receipt.jobId &&
317
+ entry.data.coveredEntryId === coverage.coveredEntryId &&
318
+ entry.data.selectionHash === coverage.selectionHash);
319
+ if (!existing) appendEntry(RECAP_ENTRY_TYPE, {
320
+ version: 1, ...content, jobId: receipt.jobId,
321
+ coveredEntryId: coverage.coveredEntryId, selectionHash: coverage.selectionHash,
322
+ });
323
+ return true;
324
+ }
325
+ target.ui.setWidget(
326
+ RAREBIT_RECAP_WIDGET_KEY,
327
+ (_tui, theme) => {
328
+ return recapComponent(content, theme);
329
+ },
330
+ { placement: "aboveEditor" },
331
+ );
332
+ return true;
333
+ } catch {
334
+ return false;
335
+ }
336
+ };
337
+
338
+ const showExisting = async (ctx, options = {}) => {
339
+ controllerGeneration += 1;
340
+ cancelTimer();
341
+ updateContext(ctx);
342
+ const observedGeneration = controllerGeneration;
343
+ const observedOfferEpoch = offerEpoch;
344
+ const identity = sessionIdentity(ctx);
345
+ const current = await readReceipt(ctx, options).catch(() => null);
346
+ if (
347
+ observedGeneration !== controllerGeneration ||
348
+ observedOfferEpoch !== offerEpoch ||
349
+ !sameIdentity(identity, sessionIdentity(liveContext))
350
+ )
351
+ return { shown: false, reason: "stale" };
352
+ if (!current?.receipt) {
353
+ clearWidget(ctx);
354
+ return { shown: false, reason: current?.reason ?? "no_current_summary" };
355
+ }
356
+ const shown = await display(ctx, current.receipt, {
357
+ storeOptions: options,
358
+ timezone: options.timezone ?? options.recapTimezone ?? recapTimezone,
359
+ expectedOfferEpoch: observedOfferEpoch,
360
+ });
361
+ return { shown, reason: shown ? "current_summary" : "stale_or_unavailable", receipt: current.receipt };
362
+ };
363
+
364
+ const offer = ({ ctx, result, token, ...options }) => {
365
+ const record = result?.record;
366
+ if (
367
+ !tuiContext() ||
368
+ !tokenIsCurrent(token) ||
369
+ record?.status !== "ok" ||
370
+ typeof record.summary !== "string" ||
371
+ !record.summary.trim() ||
372
+ options.recapEnabled === false
373
+ )
374
+ return false;
375
+ const key = `${record.sessionId}:${record.branch?.pathHash ?? ""}:${record.jobId}`;
376
+ if (displayedReceipts.has(key) || pending?.key === key) return false;
377
+ cancelTimer();
378
+ const offeredIdentity = sessionIdentity(ctx);
379
+ pending = {
380
+ token,
381
+ record,
382
+ identity: offeredIdentity,
383
+ options,
384
+ key,
385
+ offerEpoch: offerEpoch + 1,
386
+ };
387
+ offerEpoch = pending.offerEpoch;
388
+ const fire = async () => {
389
+ timer = undefined;
390
+ const offerState = pending;
391
+ pending = undefined;
392
+ if (
393
+ !offerState ||
394
+ offerState.offerEpoch !== offerEpoch ||
395
+ !tokenIsCurrent(offerState.token)
396
+ )
397
+ return;
398
+ const current = liveContext;
399
+ const currentIdentity = sessionIdentity(current);
400
+ if (!sameIdentity(offerState.identity, currentIdentity)) return;
401
+ const currentReceipt = await readReceipt(current, offerState.options).catch(
402
+ () => null,
403
+ );
404
+ if (
405
+ offerState.offerEpoch !== offerEpoch ||
406
+ !tokenIsCurrent(offerState.token)
407
+ )
408
+ return;
409
+ if (!currentReceipt?.receipt || currentReceipt.receipt.jobId !== offerState.record.jobId) return;
410
+ if (await display(current, currentReceipt.receipt, {
411
+ storeOptions: offerState.options,
412
+ token: offerState.token,
413
+ expectedOfferEpoch: offerState.offerEpoch,
414
+ timezone:
415
+ offerState.options.timezone ??
416
+ offerState.options.recapTimezone ??
417
+ recapTimezone,
418
+ })) {
419
+ displayedReceipts.add(offerState.key);
420
+ if (displayedReceipts.size > 128)
421
+ displayedReceipts.delete(displayedReceipts.values().next().value);
422
+ }
423
+ };
424
+ timer = setTimeoutFn(fire, options.recapDelayMs ?? delayMs);
425
+ timer?.unref?.();
426
+ return true;
427
+ };
428
+
429
+ const renderEntry = (entry, _options, theme) => {
430
+ if (entry.data?.version !== 1 || typeof entry.data.summary !== "string" ||
431
+ typeof entry.data.heading !== "string") return undefined;
432
+ let component;
433
+ // Resume can render entries before the optional Pi imports settle. Keep a
434
+ // placeholder while the optional components load, then request a redraw.
435
+ if (!loadedComponents)
436
+ void loadRecapWidgetComponents().then(() => clearWidget()).catch(() => {});
437
+ return {
438
+ render(width) {
439
+ if (!loadedComponents) return [theme.fg("dim", "Recap loading…".slice(0, width))];
440
+ const read = recapIsRead(branchSnapshot(liveContext), entry);
441
+ component = recapComponent({ ...entry.data,
442
+ action: read ? " ✓ got it" : " [got it]" }, theme);
443
+ return component.render(width);
444
+ },
445
+ invalidate() { component?.invalidate(); },
446
+ };
447
+ };
448
+
449
+ const acknowledge = (ctx) => {
450
+ if (typeof appendEntry !== "function" || !sameIdentity(sessionIdentity(ctx), sessionIdentity(liveContext)))
451
+ return { acknowledged: false, reason: "unavailable" };
452
+ const branch = branchSnapshot(ctx);
453
+ const recap = latestRecap(branch);
454
+ if (!recap) return { acknowledged: false, reason: "no_recap" };
455
+ if (recapIsRead(branch, recap))
456
+ return { acknowledged: false, reason: "already_read" };
457
+ appendEntry(RECAP_READ_TYPE, { version: 1, recapEntryId: recap.id,
458
+ jobId: recap.data.jobId, coveredEntryId: recap.data.coveredEntryId,
459
+ selectionHash: recap.data.selectionHash });
460
+ clearWidget(ctx);
461
+ return { acknowledged: true };
462
+ };
463
+
464
+ const dispose = () => {
465
+ invalidate(undefined, undefined);
466
+ liveContext = undefined;
467
+ };
468
+
469
+ return {
470
+ renderEntry,
471
+ acknowledge,
472
+ captureMaterialization,
473
+ offer,
474
+ showExisting,
475
+ updateContext,
476
+ invalidate,
477
+ dispose,
478
+ };
479
+ }
@@ -1,12 +1,16 @@
1
+ import { recapReadCheckpoint } from "./rarebit-read-checkpoint.mjs";
1
2
  import {
2
3
  DEFAULT_RAREBIT_SUMMARY_POLICY,
4
+ DEFAULT_RAREBIT_SUMMARY_PROMPT_GUIDANCE,
3
5
  RAREBIT_SUMMARY_WRITABLE_LIFECYCLE_BOUNDARIES,
6
+ RAREBIT_SUMMARY_PROMPT_IDENTITY_VERSION,
4
7
  RAREBIT_SUMMARY_PROMPT_VERSION,
5
8
  RAREBIT_TITLE_PROMPT_VERSION,
6
9
  composeRarebitSummaryDerivationInput,
7
10
  composeRarebitTitlePrompt,
8
11
  evaluateRarebitSummaryEligibility,
9
12
  measureRarebits,
13
+ normalizeRarebitSummaryPrompt,
10
14
  normalizeRarebitSummaryPolicy,
11
15
  normalizeRarebitSummarySynthesis,
12
16
  normalizeRarebitTitle,
@@ -25,15 +29,34 @@ import {
25
29
  extractRarebitSynthesisReceipt,
26
30
  resolveRarebitModelConfiguration,
27
31
  } from "./rarebit-model.mjs";
32
+ import { DEFAULT_RAREBIT_MAX_INPUT_TOKENS } from "./rarebit-settings.mjs";
28
33
  import {
29
34
  RAREBIT_AUTOMATIC_SUMMARY_POLICY_CONTRACT,
30
35
  automaticSummaryInhibitionIdentity,
31
36
  } from "./automatic-summary-policy.mjs";
32
37
 
33
38
  export const RAREBIT_TITLE_IMPLEMENTATION_VERSION = "hc-rarebit-title-v4";
34
- export const RAREBIT_SUMMARY_IMPLEMENTATION_VERSION = "hc-rarebit-summary-v6";
39
+ export const RAREBIT_SUMMARY_IMPLEMENTATION_VERSION = "hc-rarebit-summary-v7";
35
40
  export const RAREBIT_SUMMARY_SCHEMA_VERSION = 4;
36
- export const DEFAULT_RAREBIT_MAX_PROMPT_CHARS = 256_000;
41
+ export const RAREBIT_INPUT_CHARS_PER_TOKEN = 4;
42
+ export const DEFAULT_RAREBIT_MAX_PROMPT_CHARS =
43
+ DEFAULT_RAREBIT_MAX_INPUT_TOKENS * RAREBIT_INPUT_CHARS_PER_TOKEN;
44
+
45
+ function maxPromptCharsFromConfig(config) {
46
+ if (config.maxPromptChars !== undefined) {
47
+ if (!Number.isSafeInteger(config.maxPromptChars) || config.maxPromptChars < 1)
48
+ throw new RangeError("maxPromptChars must be a positive safe integer");
49
+ return config.maxPromptChars;
50
+ }
51
+ const maxInputTokens =
52
+ config.maxInputTokens ?? DEFAULT_RAREBIT_MAX_INPUT_TOKENS;
53
+ if (!Number.isSafeInteger(maxInputTokens) || maxInputTokens < 1)
54
+ throw new RangeError("maxInputTokens must be a positive safe integer");
55
+ const maxPromptChars = maxInputTokens * RAREBIT_INPUT_CHARS_PER_TOKEN;
56
+ if (!Number.isSafeInteger(maxPromptChars))
57
+ throw new RangeError("maxInputTokens exceeds the supported prompt limit");
58
+ return maxPromptChars;
59
+ }
37
60
 
38
61
  function sessionFileFrom(ctx) {
39
62
  const sessionFile = ctx?.sessionManager?.getSessionFile?.();
@@ -97,16 +120,39 @@ function machineModelProvenance(value) {
97
120
  };
98
121
  }
99
122
 
123
+ function summaryCacheSessionId(sessionId, promptVersion, summaryPrompt) {
124
+ const usesBuiltinContract =
125
+ promptVersion === RAREBIT_SUMMARY_PROMPT_VERSION &&
126
+ summaryPrompt.guidance === DEFAULT_RAREBIT_SUMMARY_PROMPT_GUIDANCE;
127
+ if (usesBuiltinContract) return sessionId;
128
+ const promptIdentity =
129
+ summaryPrompt.promptIdentity ??
130
+ sha256({
131
+ version: RAREBIT_SUMMARY_PROMPT_IDENTITY_VERSION,
132
+ guidance: summaryPrompt.guidance,
133
+ });
134
+ return sha256({
135
+ version: "rarebit-summary-cache-v1",
136
+ sessionId,
137
+ promptVersion,
138
+ promptIdentity,
139
+ });
140
+ }
141
+
100
142
  /**
101
143
  * Shared imperative summary shell for Pi and CLI adapters. The functional core
102
144
  * decides selection, measurement, policy, prompts, and identity; this shell
103
145
  * owns only model invocation and append-only derived-record persistence.
104
146
  */
105
147
  export async function processRarebitSummary(ctx, config = {}) {
148
+ // Resolve before model selection or job reservation. Invalid prompt config
149
+ // must fail closed without a provider call or a misleading receipt.
150
+ const summaryPrompt = normalizeRarebitSummaryPrompt(config.summaryPrompt);
106
151
  const branch = ctx?.sessionManager?.getBranch?.() ?? config.branch ?? [];
107
152
  const sessionFile = config.sessionFile ?? sessionFileFrom(ctx);
108
153
  const sessionId = config.sessionId ?? sessionIdFrom(ctx);
109
154
  const selection = selectRarebits(branch);
155
+ const readCheckpoint = recapReadCheckpoint(branch, selection);
110
156
  const measurement = measureRarebits(branch, selection);
111
157
  const policy = normalizeRarebitSummaryPolicy(
112
158
  config.summaryPolicy ?? DEFAULT_RAREBIT_SUMMARY_POLICY,
@@ -165,10 +211,13 @@ export async function processRarebitSummary(ctx, config = {}) {
165
211
  provenance: { source: "not_required", status: "not_required" },
166
212
  };
167
213
  const promptVersion = config.promptVersion ?? RAREBIT_SUMMARY_PROMPT_VERSION;
168
- const maxPromptChars =
169
- config.maxPromptChars ?? DEFAULT_RAREBIT_MAX_PROMPT_CHARS;
170
- if (!Number.isInteger(maxPromptChars) || maxPromptChars < 1)
171
- throw new RangeError("maxPromptChars must be a positive integer");
214
+ // Keep the receipt self-describing without persisting the user guidance.
215
+ // The digest is already part of the job identity and is safe to expose as
216
+ // provenance beside the stable prompt contract version.
217
+ const receiptPromptVersion = summaryPrompt.promptIdentity
218
+ ? `${promptVersion}:${summaryPrompt.promptIdentity}`
219
+ : promptVersion;
220
+ const maxPromptChars = maxPromptCharsFromConfig(config);
172
221
  const inputCoveragePolicy = {
173
222
  strategy: "newest_suffix_with_explicit_omission",
174
223
  maxPromptChars,
@@ -180,9 +229,10 @@ export async function processRarebitSummary(ctx, config = {}) {
180
229
  branch: branchRef,
181
230
  selection,
182
231
  policy,
183
- inputPolicy: inputCoveragePolicy,
232
+ inputPolicy: { ...inputCoveragePolicy, ...(readCheckpoint ? { readCheckpoint } : {}) },
184
233
  lifecycleBoundary,
185
234
  promptVersion,
235
+ promptIdentity: summaryPrompt.promptIdentity,
186
236
  model: modelResolution.model,
187
237
  });
188
238
  const jobId = inhibited
@@ -212,7 +262,7 @@ export async function processRarebitSummary(ctx, config = {}) {
212
262
  selection: machineSelection(selection),
213
263
  model: modelResolution.model,
214
264
  modelProvenance: machineModelProvenance(modelResolution.provenance),
215
- promptVersion,
265
+ promptVersion: receiptPromptVersion,
216
266
  ...(inhibited ? { automaticSummaryPolicy } : {}),
217
267
  };
218
268
  if (!sessionFile) {
@@ -273,6 +323,8 @@ export async function processRarebitSummary(ctx, config = {}) {
273
323
  }
274
324
  const derivationInput = composeRarebitSummaryDerivationInput(selection, {
275
325
  promptVersion,
326
+ summaryPrompt: config.summaryPrompt,
327
+ readCheckpoint,
276
328
  lifecycleBoundary,
277
329
  maxPromptChars,
278
330
  });
@@ -312,7 +364,11 @@ export async function processRarebitSummary(ctx, config = {}) {
312
364
  const response = await client.complete({
313
365
  prompt,
314
366
  model: modelResolution.model,
315
- cacheSessionId: sessionId,
367
+ cacheSessionId: summaryCacheSessionId(
368
+ sessionId,
369
+ promptVersion,
370
+ summaryPrompt,
371
+ ),
316
372
  });
317
373
  const synthesisResult = normalizeRarebitSummarySynthesis(
318
374
  extractModelText(response),