@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.
@@ -6,7 +6,17 @@ 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
+ export const RAREBIT_SUMMARY_PROMPT_IDENTITY_VERSION =
11
+ "rarebit-summary-prompt-v1";
12
+ // User guidance is a UTF-16 string in JavaScript. Bound it before any Summary
13
+ // provider work so a malformed settings value cannot create an unbounded job.
14
+ export const MAX_RAREBIT_SUMMARY_PROMPT_CHARS = 64_000;
15
+ // Receipts have a 48 KiB protocol limit. Keep the configurable Summary field
16
+ // well below that limit even when the model returns multi-byte text.
17
+ export const MAX_RAREBIT_SUMMARY_CHARS = 8_000;
18
+ export const DEFAULT_RAREBIT_SUMMARY_PROMPT_GUIDANCE =
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.";
10
20
  export const RAREBIT_TITLE_PROMPT_VERSION = "rarebit-title-v1";
11
21
  export const RAREBIT_JOB_IDENTITY_VERSION = "rarebit-job-identity-v2";
12
22
 
@@ -274,12 +284,51 @@ function semanticMessages(selection) {
274
284
  }));
275
285
  }
276
286
 
287
+ /**
288
+ * Resolve the bounded Summary style contract. The setting is a scalar prompt
289
+ * fragment. The fixed evidence and status contract remains in this module.
290
+ * The normalized identity is used by the service to prevent a changed prompt
291
+ * from reusing an older receipt.
292
+ */
293
+ export function normalizeRarebitSummaryPrompt(value) {
294
+ if (value === undefined) {
295
+ return {
296
+ guidance: DEFAULT_RAREBIT_SUMMARY_PROMPT_GUIDANCE,
297
+ promptIdentity: null,
298
+ };
299
+ }
300
+ if (typeof value !== "string")
301
+ throw new TypeError("summary_prompt must be a string");
302
+ const guidance = value.replace(/\r\n?/g, "\n").trim();
303
+ if (!guidance) throw new TypeError("summary_prompt must not be blank");
304
+ if (guidance.length > MAX_RAREBIT_SUMMARY_PROMPT_CHARS)
305
+ throw new RangeError(
306
+ `summary_prompt must not exceed ${MAX_RAREBIT_SUMMARY_PROMPT_CHARS} UTF-16 characters`,
307
+ );
308
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/.test(guidance))
309
+ throw new TypeError("summary_prompt contains unsafe control characters");
310
+ return {
311
+ guidance,
312
+ promptIdentity:
313
+ guidance === DEFAULT_RAREBIT_SUMMARY_PROMPT_GUIDANCE
314
+ ? null
315
+ : sha256({
316
+ version: RAREBIT_SUMMARY_PROMPT_IDENTITY_VERSION,
317
+ guidance,
318
+ }),
319
+ };
320
+ }
321
+
277
322
  function summaryPromptParts(messages, {
278
323
  lifecycleBoundary,
279
324
  omittedMessageCount = 0,
280
325
  omittedTextChars = 0,
326
+ summaryPrompt,
327
+ readCheckpoint,
281
328
  } = {}) {
282
329
  const ownerRequest = lifecycleBoundary === "owner_request";
330
+ const resolvedPrompt = normalizeRarebitSummaryPrompt(summaryPrompt);
331
+ const styleLine = resolvedPrompt.guidance;
283
332
  const omission =
284
333
  omittedMessageCount > 0 || omittedTextChars > 0
285
334
  ? {
@@ -294,7 +343,8 @@ function summaryPromptParts(messages, {
294
343
  "You are the HyperCarrier Rarebit summarizer.",
295
344
  "Summarize only what is explicitly stated in the ordered Rarebit evidence stream below.",
296
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.",
297
- "The summary is free-form prose. State when evidence is uncertain, confusing, contradictory, or importantly missing instead of inventing a coherent account.",
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."] : []),
298
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.",
299
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.",
300
350
  "Absence of a tool transcript is unobservable and must never by itself imply that work was not performed.",
@@ -328,55 +378,82 @@ export function composeRarebitSummaryDerivationInput(
328
378
  promptVersion = RAREBIT_SUMMARY_PROMPT_VERSION,
329
379
  lifecycleBoundary = "manual",
330
380
  maxPromptChars = Number.MAX_SAFE_INTEGER,
381
+ summaryPrompt,
382
+ readCheckpoint,
331
383
  } = {},
332
384
  ) {
333
- void promptVersion;
334
385
  if (
335
386
  !RAREBIT_SUMMARY_WRITABLE_LIFECYCLE_BOUNDARIES.includes(lifecycleBoundary)
336
387
  )
337
388
  throw new TypeError("Unsupported Summary lifecycle boundary");
338
389
  if (!Number.isInteger(maxPromptChars) || maxPromptChars < 1)
339
390
  throw new RangeError("maxPromptChars must be a positive integer");
391
+ const resolvedPrompt = normalizeRarebitSummaryPrompt(summaryPrompt);
340
392
 
341
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;
342
402
  let messages = allMessages.slice();
343
403
  let omittedMessageCount = 0;
344
404
  let omittedTextChars = 0;
345
- let prompt = renderSummaryPrompt(messages, { lifecycleBoundary });
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();
346
419
  while (prompt.length > maxPromptChars && messages.length > 1) {
347
420
  messages.shift();
421
+ start += 1;
348
422
  omittedMessageCount += 1;
349
- prompt = renderSummaryPrompt(messages, {
350
- lifecycleBoundary,
351
- omittedMessageCount,
352
- });
423
+ prompt = render();
353
424
  }
354
425
  if (prompt.length > maxPromptChars && messages.length === 1) {
355
- const original = messages[0].text;
426
+ const originalMessage = messages[0];
427
+ const original = originalMessage.text;
356
428
  let low = 0;
357
429
  let high = original.length;
358
430
  let best = null;
359
431
  while (low <= high) {
360
432
  const removed = Math.floor((low + high) / 2);
361
- const candidate = [{ ...messages[0], text: original.slice(removed) }];
362
- const candidatePrompt = renderSummaryPrompt(candidate, {
363
- lifecycleBoundary,
364
- omittedMessageCount,
365
- omittedTextChars: removed,
366
- });
433
+ messages[0] = { ...messages[0], text: original.slice(removed) };
434
+ omittedTextChars = removed;
435
+ const candidatePrompt = render();
367
436
  if (candidatePrompt.length <= maxPromptChars) {
368
- best = { messages: candidate, prompt: candidatePrompt, removed };
437
+ best = { prompt: candidatePrompt, removed };
369
438
  high = removed - 1;
370
439
  } else low = removed + 1;
371
440
  }
372
441
  if (best) {
373
- messages = best.messages;
442
+ messages[0] = { ...originalMessage, text: original.slice(best.removed) };
374
443
  prompt = best.prompt;
375
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();
376
451
  }
377
452
  }
378
453
  return {
379
454
  prompt,
455
+ promptIdentity: resolvedPrompt.promptIdentity,
456
+ promptVersion,
380
457
  coverage: {
381
458
  totalMessageCount: allMessages.length,
382
459
  includedMessageCount: messages.length,
@@ -425,17 +502,35 @@ function compactLine(value) {
425
502
  .trim();
426
503
  }
427
504
 
428
- export function normalizeRarebitSummary(value, { maxChars = 2_000 } = {}) {
505
+ function preserveProseLayout(value) {
506
+ return String(value ?? "")
507
+ .replace(/\r\n?/g, "\n")
508
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
509
+ .replace(/[\u2028\u2029]/g, "\n")
510
+ .trim();
511
+ }
512
+
513
+ export function normalizeRarebitSummary(
514
+ value,
515
+ { maxChars = MAX_RAREBIT_SUMMARY_CHARS } = {},
516
+ ) {
429
517
  if (!Number.isInteger(maxChars) || maxChars < 1)
430
518
  throw new RangeError("maxChars must be a positive integer");
431
- const compact = compactLine(value);
432
- if (!compact) throw new Error("Summary model returned no usable summary");
433
- return compact.slice(0, maxChars).trimEnd();
519
+ if (maxChars > MAX_RAREBIT_SUMMARY_CHARS)
520
+ throw new RangeError(
521
+ `maxChars must not exceed ${MAX_RAREBIT_SUMMARY_CHARS}`,
522
+ );
523
+ const normalized = preserveProseLayout(value);
524
+ if (!normalized) throw new Error("Summary model returned no usable summary");
525
+ if (normalized.length <= maxChars) return normalized;
526
+ throw new RangeError(
527
+ `Summary model returned ${normalized.length} characters; configured limit is ${maxChars}`,
528
+ );
434
529
  }
435
530
 
436
531
  export function normalizeRarebitSummarySynthesis(
437
532
  value,
438
- { maxChars = 2_000 } = {},
533
+ { maxChars = MAX_RAREBIT_SUMMARY_CHARS } = {},
439
534
  ) {
440
535
  let synthesis;
441
536
  try {
@@ -456,7 +551,9 @@ export function normalizeRarebitSummarySynthesis(
456
551
  if (!RAREBIT_SESSION_STATUS_REASONS[status].includes(reason))
457
552
  throw new TypeError("Summary model response has an illegal statusReason");
458
553
  return {
459
- summary: normalizeRarebitSummary(synthesis.summary, { maxChars }),
554
+ summary: normalizeRarebitSummary(synthesis.summary, {
555
+ maxChars,
556
+ }),
460
557
  sessionStatus: status,
461
558
  statusReason: reason,
462
559
  };
@@ -501,6 +598,7 @@ export function rarebitJobIdentity({
501
598
  selection,
502
599
  policy = null,
503
600
  promptVersion,
601
+ promptIdentity = null,
504
602
  model = null,
505
603
  } = {}) {
506
604
  if (!new Set(["summary", "title"]).has(operation))
@@ -520,6 +618,7 @@ export function rarebitJobIdentity({
520
618
  selectionManifestHash: selection.manifestHash,
521
619
  policy: policy === null ? null : normalizeRarebitSummaryPolicy(policy),
522
620
  promptVersion: promptVersion ?? null,
621
+ promptIdentity: promptIdentity ?? null,
523
622
  model,
524
623
  });
525
624
  }
@@ -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
+ }