@hypercarrier/rarebit 0.1.0-alpha.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +24 -2
  2. package/README.md +96 -20
  3. package/RUNBOOK.md +32 -9
  4. package/bin/piq.mjs +14 -0
  5. package/docs/examples/rarebit.settings.json +19 -0
  6. package/package.json +29 -3
  7. package/scripts/e2e-fork-pi.mjs +824 -0
  8. package/scripts/e2e-recap-pi.mjs +456 -0
  9. package/scripts/e2e-settings-pi.mjs +267 -0
  10. package/scripts/e2e-v020-pi.mjs +269 -0
  11. package/scripts/verify-package.mjs +20 -12
  12. package/src/extension.mjs +280 -21
  13. package/src/index.mjs +1 -0
  14. package/src/lifecycle.mjs +32 -7
  15. package/src/pi-session.mjs +237 -0
  16. package/src/piq-cli.mjs +96 -0
  17. package/src/piq.mjs +265 -0
  18. package/src/rarebit-activity.mjs +8 -2
  19. package/src/rarebit-cli.mjs +103 -5
  20. package/src/rarebit-command.mjs +29 -1
  21. package/src/rarebit-core.mjs +87 -9
  22. package/src/rarebit-fork-lineage.mjs +69 -0
  23. package/src/rarebit-fork.mjs +241 -0
  24. package/src/rarebit-recap.mjs +422 -0
  25. package/src/rarebit-service.mjs +60 -7
  26. package/src/rarebit-session.mjs +12 -160
  27. package/src/rarebit-settings-ui.mjs +479 -0
  28. package/src/rarebit-settings.mjs +90 -7
  29. package/src/types.d.ts +91 -3
  30. package/test/lifecycle-contract-independent.test.mjs +2 -0
  31. package/test/package-artifact-identity.test.mjs +54 -0
  32. package/test/piq.test.mjs +212 -0
  33. package/test/rarebit-command.test.mjs +11 -4
  34. package/test/rarebit-fork.test.mjs +124 -0
  35. package/test/rarebit-recap.test.mjs +513 -0
  36. package/test/rarebit-settings-ui.test.mjs +388 -0
  37. package/test/rarebit-v020.test.mjs +294 -0
  38. package/test/summary-attention-independent.test.mjs +1 -1
@@ -12,12 +12,19 @@ import {
12
12
  queryRarebits,
13
13
  readRarebitSession,
14
14
  } from "./rarebit-session.mjs";
15
+ import {
16
+ buildRarebitForkPlan,
17
+ createRarebitForkEntries,
18
+ sessionFilenameFor,
19
+ writeRarebitForkFile,
20
+ } from "./rarebit-fork.mjs";
15
21
 
16
22
  export const RAREBIT_CLI_USAGE = `Usage:
17
23
  rarebit query --session <exact-path-or-id> --json
18
24
  rarebit extract --session <exact-path-or-id> --json
19
25
  rarebit summarize --session <exact-path-or-id> [--force] [--model-command <executable> [--model-arg <arg>]] --json
20
26
  rarebit title --session <exact-path-or-id> [--date YYYY-MM-DD] [--model-command <executable> [--model-arg <arg>]] --json
27
+ rarebit fork <exact-source-path-or-id> [--max-token-length <tokens>] [--no-launch] [--json]
21
28
 
22
29
  Normal summarize/title resolve rarebit.model from Pi settings. The optional model-command
23
30
  adapter receives one prompt on stdin and must write only model text to stdout.
@@ -33,11 +40,11 @@ function requiredValue(argv, index, flag) {
33
40
  export function parseRarebitCliArgs(argv) {
34
41
  if (argv.includes("--help") || argv.includes("-h")) return { help: true };
35
42
  const [command, ...rest] = argv;
36
- if (!new Set(["query", "extract", "summarize", "title"]).has(command))
43
+ if (!new Set(["query", "extract", "summarize", "title", "fork"]).has(command))
37
44
  throw new Error(
38
45
  "First argument must be query, extract, summarize, or title",
39
46
  );
40
- const options = { command, modelArgs: [], json: false, force: false };
47
+ const options = { command, modelArgs: [], json: false, force: false, noLaunch: false };
41
48
  for (let index = 0; index < rest.length; index += 1) {
42
49
  const argument = rest[index];
43
50
  switch (argument) {
@@ -45,6 +52,17 @@ export function parseRarebitCliArgs(argv) {
45
52
  options.session = requiredValue(rest, index, argument);
46
53
  index += 1;
47
54
  break;
55
+ case "--max-token-length":
56
+ options.maxTokenLength = Number(requiredValue(rest, index, argument));
57
+ index += 1;
58
+ break;
59
+ case "--model":
60
+ options.model = requiredValue(rest, index, argument);
61
+ index += 1;
62
+ break;
63
+ case "--no-launch":
64
+ options.noLaunch = true;
65
+ break;
48
66
  case "--model-command":
49
67
  options.modelCommand = requiredValue(rest, index, argument);
50
68
  index += 1;
@@ -64,11 +82,13 @@ export function parseRarebitCliArgs(argv) {
64
82
  options.force = true;
65
83
  break;
66
84
  default:
67
- throw new Error(`Unknown argument: ${argument}`);
85
+ if (command === "fork" && !argument.startsWith("--") && !options.session) options.session = argument;
86
+ else throw new Error(`Unknown argument: ${argument}`);
68
87
  }
69
88
  }
70
- if (!options.session) throw new Error("--session is required");
71
- if (!options.json) throw new Error("--json is required");
89
+ if (!options.session) throw new Error(command === "fork" ? "fork requires an exact source Session path or ID" : "--session is required");
90
+ if (options.maxTokenLength !== undefined && (!Number.isSafeInteger(options.maxTokenLength) || options.maxTokenLength < 1)) throw new Error("--max-token-length must be a positive integer");
91
+ if (!options.json && command !== "fork") throw new Error("--json is required");
72
92
  if (options.force && command !== "summarize")
73
93
  throw new Error("--force only applies to summarize");
74
94
  return options;
@@ -185,6 +205,7 @@ export async function readRarebitCliSettings({
185
205
  rawRefs: [globalPath, projectPath],
186
206
  },
187
207
  summaryPolicy: resolved.summaryPolicy,
208
+ summaryPrompt: resolved.summaryPrompt,
188
209
  autoTitle: resolved.autoTitle,
189
210
  };
190
211
  }
@@ -235,6 +256,79 @@ async function modelRuntime(options, dependencies) {
235
256
  };
236
257
  }
237
258
 
259
+ async function resolveForkTarget({ cwd, explicitModel, agentDir, resolveTarget }) {
260
+ if (resolveTarget) return resolveTarget({ cwd, explicitModel, agentDir });
261
+ const {
262
+ ModelRuntime,
263
+ SessionManager,
264
+ SettingsManager,
265
+ createAgentSession,
266
+ } = await import("@earendil-works/pi-coding-agent");
267
+ const settings = SettingsManager.create(cwd, agentDir);
268
+ const runtime = await ModelRuntime.create({ allowModelNetwork: false });
269
+ let targetModel;
270
+ if (explicitModel) {
271
+ const [provider, ...idParts] = explicitModel.split("/");
272
+ const id = idParts.join("/");
273
+ targetModel = runtime.getModel(provider, id);
274
+ } else {
275
+ const provider = settings.getDefaultProvider();
276
+ const id = settings.getDefaultModel();
277
+ targetModel = provider && id ? runtime.getModel(provider, id) : undefined;
278
+ if (!targetModel) targetModel = (await runtime.getAvailable())[0];
279
+ }
280
+ if (!targetModel?.provider || !targetModel?.id || !Number.isFinite(targetModel.contextWindow))
281
+ throw new Error("Cannot resolve the target Pi model and context window before forking; set Pi's default model or pass --model provider/model");
282
+ const global = settings.getGlobalSettings();
283
+ const project = settings.getProjectSettings();
284
+ const key = `${targetModel.provider}/${targetModel.id}`;
285
+ const overrides = { ...(global.compaction?.modelOverrides ?? {}), ...(project.compaction?.modelOverrides ?? {}) };
286
+ const reserveTokens = overrides[key]?.reserveTokens ?? settings.getCompactionReserveTokens();
287
+ const { session } = await createAgentSession({
288
+ cwd,
289
+ agentDir,
290
+ model: targetModel,
291
+ modelRuntime: runtime,
292
+ settingsManager: settings,
293
+ sessionManager: SessionManager.inMemory(cwd),
294
+ });
295
+ try {
296
+ const promptOverheadTokens = Math.ceil(String(session.agent.state.systemPrompt ?? "").length / 4);
297
+ const toolOverheadTokens = Math.ceil(JSON.stringify(session.agent.state.tools ?? []).length / 4);
298
+ return { model: targetModel, reserveTokens, promptOverheadTokens, toolOverheadTokens };
299
+ } finally {
300
+ session.dispose();
301
+ }
302
+ }
303
+
304
+ export async function runPiFork({ session, maxTokenLength, cwd = process.cwd(), piCommand = "pi", noLaunch = false, model, agentDir, resolveTarget, spawnProcess = spawn } = {}) {
305
+ const loaded = await readRarebitSession(session);
306
+ const target = await resolveForkTarget({ cwd, explicitModel: model, agentDir, resolveTarget });
307
+ const targetModel = target.model;
308
+ const plan = buildRarebitForkPlan({
309
+ header: loaded.parsed.header,
310
+ sessionFile: loaded.sessionFile,
311
+ cwd,
312
+ branch: loaded.branch,
313
+ targetCwd: cwd,
314
+ maxTokenLength,
315
+ targetModel,
316
+ reserveTokens: target.reserveTokens,
317
+ promptOverheadTokens: target.promptOverheadTokens,
318
+ toolOverheadTokens: target.toolOverheadTokens,
319
+ });
320
+ const { SessionManager } = await import("@earendil-works/pi-coding-agent");
321
+ const destinationManager = SessionManager.create(cwd);
322
+ const destination = sessionFilenameFor({ sessionDir: destinationManager.getSessionDir(), forkCreatedAt: plan.forkCreatedAt, sessionId: destinationManager.getSessionId() });
323
+ const built = createRarebitForkEntries(plan, { sessionId: destinationManager.getSessionId() });
324
+ await writeRarebitForkFile(destination, built);
325
+ const result = { operation: "fork", session: destination, sourceSessionId: loaded.session.id, ...plan.omission, headroom: plan.headroom, launched: false };
326
+ if (noLaunch) return result;
327
+ const child = spawnProcess(piCommand, ["--session", destination], { cwd, stdio: "inherit", shell: false, env: process.env });
328
+ const exit = await new Promise((resolve, reject) => { child.on("error", reject); child.on("close", (code, signal) => resolve({ code, signal })); });
329
+ return { ...result, launched: true, exitCode: exit.code, signal: exit.signal };
330
+ }
331
+
238
332
  export async function runRarebitCli(options, dependencies = {}) {
239
333
  const runtimeDependencies = {
240
334
  readSettings: dependencies.readSettings ?? readRarebitCliSettings,
@@ -245,6 +339,9 @@ export async function runRarebitCli(options, dependencies = {}) {
245
339
  };
246
340
  if (options.command === "query") return queryRarebits(options.session);
247
341
  if (options.command === "extract") return extractRarebits(options.session);
342
+ if (options.command === "fork") {
343
+ return runPiFork({ session: options.session, maxTokenLength: options.maxTokenLength, noLaunch: options.noLaunch, piCommand: dependencies.piCommand, model: options.model, agentDir: dependencies.agentDir, resolveTarget: dependencies.resolveTarget, spawnProcess: dependencies.spawnProcess });
344
+ }
248
345
 
249
346
  const loaded = await readRarebitSession(options.session);
250
347
  const runtime = await modelRuntime(options, runtimeDependencies);
@@ -255,6 +352,7 @@ export async function runRarebitCli(options, dependencies = {}) {
255
352
  model: runtime.model,
256
353
  modelProvenance: runtime.modelProvenance,
257
354
  summaryPolicy: runtime.summaryPolicy,
355
+ summaryPrompt: runtime.summaryPrompt,
258
356
  forceSynthesis: options.force === true,
259
357
  allowExternalSession: true,
260
358
  rarebitRoot: dependencies.rarebitRoot,
@@ -17,8 +17,23 @@ const rest = (usage) => ({
17
17
 
18
18
  export const RAREBIT_COMMAND_GRAMMAR = Object.freeze({
19
19
  name: "rarebit",
20
- defaultSubcommand: "status",
20
+ defaultSubcommand: "menu",
21
21
  subcommands: Object.freeze([
22
+ {
23
+ name: "menu",
24
+ description: "Open the interactive Rarebit command palette",
25
+ forms: [[]],
26
+ },
27
+ {
28
+ name: "settings",
29
+ description: "Open interactive Rarebit settings",
30
+ forms: [[], [literal("global", "Edit global Pi settings")], [literal("project", "Edit trusted project settings")]],
31
+ },
32
+ {
33
+ name: "help",
34
+ description: "Show Rarebit command help",
35
+ forms: [[]],
36
+ },
22
37
  {
23
38
  name: "status",
24
39
  description: "Show effective Rarebit configuration",
@@ -29,6 +44,14 @@ export const RAREBIT_COMMAND_GRAMMAR = Object.freeze({
29
44
  description: "Recall active-branch Rarebit messages with a prompt",
30
45
  forms: [[rest("<prompt...>")]],
31
46
  },
47
+ {
48
+ name: "fork",
49
+ description: "Open a bounded Rarebit fork without starting a model turn",
50
+ forms: [
51
+ [],
52
+ [literal("--max-token-length", "Imported Rarebit token budget"), number("<positive tokens>", (value) => Number.isSafeInteger(value) && value > 0)],
53
+ ],
54
+ },
32
55
  {
33
56
  name: "config",
34
57
  description: "Show or override summary policy for this process",
@@ -65,6 +88,11 @@ export const RAREBIT_COMMAND_GRAMMAR = Object.freeze({
65
88
  usageNote: "use Pi's native /name for a literal title",
66
89
  forms: [[]],
67
90
  },
91
+ {
92
+ name: "recap",
93
+ description: "Show the current Recap in the TUI",
94
+ forms: [[]],
95
+ },
68
96
  {
69
97
  name: "summarize",
70
98
  description: "Force a Rarebit Summary materialization",
@@ -7,6 +7,16 @@ import { createHash } from "node:crypto";
7
7
  export const RAREBIT_SELECTOR_VERSION = "rarebit-selector-v1";
8
8
  export const RAREBIT_MEASUREMENT_VERSION = "rarebit-prose-chars-div4-v1";
9
9
  export const RAREBIT_SUMMARY_PROMPT_VERSION = "rarebit-summary-v6";
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
+ "The summary is free-form prose. State when evidence is uncertain, confusing, contradictory, or importantly missing instead of inventing a coherent account.";
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,50 @@ 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,
281
327
  } = {}) {
282
328
  const ownerRequest = lifecycleBoundary === "owner_request";
329
+ const resolvedPrompt = normalizeRarebitSummaryPrompt(summaryPrompt);
330
+ const styleLine = resolvedPrompt.guidance;
283
331
  const omission =
284
332
  omittedMessageCount > 0 || omittedTextChars > 0
285
333
  ? {
@@ -294,7 +342,7 @@ function summaryPromptParts(messages, {
294
342
  "You are the HyperCarrier Rarebit summarizer.",
295
343
  "Summarize only what is explicitly stated in the ordered Rarebit evidence stream below.",
296
344
  "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.",
345
+ styleLine,
298
346
  "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
347
  "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
348
  "Absence of a tool transcript is unobservable and must never by itself imply that work was not performed.",
@@ -328,27 +376,32 @@ export function composeRarebitSummaryDerivationInput(
328
376
  promptVersion = RAREBIT_SUMMARY_PROMPT_VERSION,
329
377
  lifecycleBoundary = "manual",
330
378
  maxPromptChars = Number.MAX_SAFE_INTEGER,
379
+ summaryPrompt,
331
380
  } = {},
332
381
  ) {
333
- void promptVersion;
334
382
  if (
335
383
  !RAREBIT_SUMMARY_WRITABLE_LIFECYCLE_BOUNDARIES.includes(lifecycleBoundary)
336
384
  )
337
385
  throw new TypeError("Unsupported Summary lifecycle boundary");
338
386
  if (!Number.isInteger(maxPromptChars) || maxPromptChars < 1)
339
387
  throw new RangeError("maxPromptChars must be a positive integer");
388
+ const resolvedPrompt = normalizeRarebitSummaryPrompt(summaryPrompt);
340
389
 
341
390
  const allMessages = semanticMessages(selection);
342
391
  let messages = allMessages.slice();
343
392
  let omittedMessageCount = 0;
344
393
  let omittedTextChars = 0;
345
- let prompt = renderSummaryPrompt(messages, { lifecycleBoundary });
394
+ let prompt = renderSummaryPrompt(messages, {
395
+ lifecycleBoundary,
396
+ summaryPrompt,
397
+ });
346
398
  while (prompt.length > maxPromptChars && messages.length > 1) {
347
399
  messages.shift();
348
400
  omittedMessageCount += 1;
349
401
  prompt = renderSummaryPrompt(messages, {
350
402
  lifecycleBoundary,
351
403
  omittedMessageCount,
404
+ summaryPrompt,
352
405
  });
353
406
  }
354
407
  if (prompt.length > maxPromptChars && messages.length === 1) {
@@ -363,6 +416,7 @@ export function composeRarebitSummaryDerivationInput(
363
416
  lifecycleBoundary,
364
417
  omittedMessageCount,
365
418
  omittedTextChars: removed,
419
+ summaryPrompt,
366
420
  });
367
421
  if (candidatePrompt.length <= maxPromptChars) {
368
422
  best = { messages: candidate, prompt: candidatePrompt, removed };
@@ -377,6 +431,8 @@ export function composeRarebitSummaryDerivationInput(
377
431
  }
378
432
  return {
379
433
  prompt,
434
+ promptIdentity: resolvedPrompt.promptIdentity,
435
+ promptVersion,
380
436
  coverage: {
381
437
  totalMessageCount: allMessages.length,
382
438
  includedMessageCount: messages.length,
@@ -425,17 +481,35 @@ function compactLine(value) {
425
481
  .trim();
426
482
  }
427
483
 
428
- export function normalizeRarebitSummary(value, { maxChars = 2_000 } = {}) {
484
+ function preserveProseLayout(value) {
485
+ return String(value ?? "")
486
+ .replace(/\r\n?/g, "\n")
487
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
488
+ .replace(/[\u2028\u2029]/g, "\n")
489
+ .trim();
490
+ }
491
+
492
+ export function normalizeRarebitSummary(
493
+ value,
494
+ { maxChars = MAX_RAREBIT_SUMMARY_CHARS } = {},
495
+ ) {
429
496
  if (!Number.isInteger(maxChars) || maxChars < 1)
430
497
  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();
498
+ if (maxChars > MAX_RAREBIT_SUMMARY_CHARS)
499
+ throw new RangeError(
500
+ `maxChars must not exceed ${MAX_RAREBIT_SUMMARY_CHARS}`,
501
+ );
502
+ const normalized = preserveProseLayout(value);
503
+ if (!normalized) throw new Error("Summary model returned no usable summary");
504
+ if (normalized.length <= maxChars) return normalized;
505
+ throw new RangeError(
506
+ `Summary model returned ${normalized.length} characters; configured limit is ${maxChars}`,
507
+ );
434
508
  }
435
509
 
436
510
  export function normalizeRarebitSummarySynthesis(
437
511
  value,
438
- { maxChars = 2_000 } = {},
512
+ { maxChars = MAX_RAREBIT_SUMMARY_CHARS } = {},
439
513
  ) {
440
514
  let synthesis;
441
515
  try {
@@ -456,7 +530,9 @@ export function normalizeRarebitSummarySynthesis(
456
530
  if (!RAREBIT_SESSION_STATUS_REASONS[status].includes(reason))
457
531
  throw new TypeError("Summary model response has an illegal statusReason");
458
532
  return {
459
- summary: normalizeRarebitSummary(synthesis.summary, { maxChars }),
533
+ summary: normalizeRarebitSummary(synthesis.summary, {
534
+ maxChars,
535
+ }),
460
536
  sessionStatus: status,
461
537
  statusReason: reason,
462
538
  };
@@ -501,6 +577,7 @@ export function rarebitJobIdentity({
501
577
  selection,
502
578
  policy = null,
503
579
  promptVersion,
580
+ promptIdentity = null,
504
581
  model = null,
505
582
  } = {}) {
506
583
  if (!new Set(["summary", "title"]).has(operation))
@@ -520,6 +597,7 @@ export function rarebitJobIdentity({
520
597
  selectionManifestHash: selection.manifestHash,
521
598
  policy: policy === null ? null : normalizeRarebitSummaryPolicy(policy),
522
599
  promptVersion: promptVersion ?? null,
600
+ promptIdentity: promptIdentity ?? null,
523
601
  model,
524
602
  });
525
603
  }
@@ -0,0 +1,69 @@
1
+ export const RAREBIT_FORK_LINEAGE_SCHEMA_VERSION = 1;
2
+ export const RAREBIT_FORK_ENTRY_MARKER_VERSION = "rarebit-fork-entry/1";
3
+ export const RAREBIT_FORK_LINEAGE_CUSTOM_TYPE = "rarebit-fork-lineage";
4
+ export const RAREBIT_FORK_OPENING_MARKER = "rarebit-fork-opening-v1";
5
+
6
+ function validMarker(marker) {
7
+ return (
8
+ marker &&
9
+ typeof marker === "object" &&
10
+ marker.version === RAREBIT_FORK_ENTRY_MARKER_VERSION &&
11
+ (marker.kind === "seed" || marker.kind === "import")
12
+ );
13
+ }
14
+
15
+ function entriesWithIds(entries) {
16
+ return (Array.isArray(entries) ? entries : []).filter(
17
+ (entry) => typeof entry?.id === "string" && entry.id,
18
+ );
19
+ }
20
+
21
+ function lineageRecords(entries) {
22
+ return (Array.isArray(entries) ? entries : []).filter(
23
+ (entry) =>
24
+ entry?.type === "custom" &&
25
+ entry.customType === RAREBIT_FORK_LINEAGE_CUSTOM_TYPE &&
26
+ entry.data?.schemaVersion === RAREBIT_FORK_LINEAGE_SCHEMA_VERSION,
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Return only destination entry IDs present in the supplied entries. This
32
+ * intentionally does not resolve parentSession or return source ancestor IDs,
33
+ * so bounded head/tail consumers can classify a marker without a full scan.
34
+ */
35
+ export function getImportedRarebitEntryIds(entries) {
36
+ const supplied = new Set(entriesWithIds(entries).map((entry) => entry.id));
37
+ const ids = new Set();
38
+ for (const entry of entriesWithIds(entries)) {
39
+ if (validMarker(entry.rarebitFork)) ids.add(entry.id);
40
+ }
41
+ for (const record of lineageRecords(entries)) {
42
+ const data = record.data;
43
+ const imported = Array.isArray(data.importedEntryIds)
44
+ ? data.importedEntryIds
45
+ : [];
46
+ for (const id of [data.opening?.seedEntryId, ...imported]) {
47
+ if (typeof id === "string" && supplied.has(id)) ids.add(id);
48
+ }
49
+ }
50
+ return ids;
51
+ }
52
+
53
+ export function getRarebitForkSeedEntryIds(entries) {
54
+ const supplied = new Set(entriesWithIds(entries).map((entry) => entry.id));
55
+ const ids = new Set();
56
+ for (const entry of entriesWithIds(entries)) {
57
+ if (validMarker(entry.rarebitFork) && entry.rarebitFork.kind === "seed")
58
+ ids.add(entry.id);
59
+ }
60
+ for (const record of lineageRecords(entries)) {
61
+ const id = record.data.opening?.seedEntryId;
62
+ if (typeof id === "string" && supplied.has(id)) ids.add(id);
63
+ }
64
+ return ids;
65
+ }
66
+
67
+ export function getRarebitForkLineageRecords(entries) {
68
+ return lineageRecords(entries).map((entry) => entry.data);
69
+ }