@volter/supercode-interchange 0.1.22 → 0.1.24

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 (129) hide show
  1. package/LICENSE-diffy-MIT +23 -0
  2. package/README.md +422 -2
  3. package/channel-records.mjs +71 -0
  4. package/channel-session-counts.mjs +81 -0
  5. package/channels-read.mjs +90 -0
  6. package/corpus-audit-records.mjs +192 -0
  7. package/corpus-audit-render.mjs +47 -0
  8. package/corpus-audit-schema.mjs +89 -0
  9. package/corpus-audit.d.ts +16 -0
  10. package/corpus-audit.mjs +132 -0
  11. package/file-io.mjs +21 -2
  12. package/harness-settings.mjs +19 -0
  13. package/hermes-fire-links.mjs +135 -0
  14. package/hermes-fires.mjs +106 -0
  15. package/hermes-read-home.d.ts +3 -0
  16. package/hermes-read-home.mjs +235 -0
  17. package/hermes-read-store.mjs +49 -0
  18. package/hermes-read-validation.mjs +170 -0
  19. package/hermes-read-workflow.mjs +164 -0
  20. package/hermes-read-yaml.mjs +68 -0
  21. package/hermes-store.mjs +1 -1
  22. package/homes.mjs +2 -2
  23. package/index.d.ts +14 -1
  24. package/index.mjs +1 -1
  25. package/inspection-builtin-tools.json +261 -0
  26. package/inspection-config-fields.json +765 -0
  27. package/inspection-schemas.d.ts +4 -0
  28. package/inspection-schemas.mjs +117 -0
  29. package/job-notepad-read.mjs +97 -0
  30. package/jobs-control.mjs +250 -0
  31. package/jobs-read.mjs +198 -0
  32. package/jsonl.mjs +4 -3
  33. package/memory-show.mjs +187 -0
  34. package/metadata.mjs +45 -3
  35. package/native-json.mjs +46 -3
  36. package/ontology-contracts.d.ts +5 -0
  37. package/ontology-contracts.mjs +29 -0
  38. package/ontology-operations.json +198 -0
  39. package/ontology-schemas.json +7946 -0
  40. package/openclaw-jobs-read.mjs +117 -0
  41. package/openclaw-profile-home.mjs +142 -0
  42. package/orchestrator-read-records.mjs +112 -0
  43. package/orchestrator-store.mjs +21 -9
  44. package/package.json +59 -4
  45. package/parity-ledger.json +6984 -0
  46. package/parity-orchestration.json +5538 -0
  47. package/parity-report.d.ts +7 -0
  48. package/parity-report.mjs +46 -0
  49. package/preset-profiles.mjs +27 -0
  50. package/profile-command.mjs +32 -0
  51. package/profile-orchestrator-door.mjs +104 -0
  52. package/profile-paths.mjs +25 -0
  53. package/profiles-control.mjs +98 -0
  54. package/profiles-read.mjs +119 -0
  55. package/reduction-attribution.mjs +54 -0
  56. package/reduction-diff.mjs +165 -0
  57. package/reduction-log.mjs +177 -0
  58. package/reduction-view.mjs +145 -0
  59. package/retained-projection.mjs +9 -0
  60. package/routes-read.mjs +103 -0
  61. package/runs-read.mjs +224 -0
  62. package/session-activity.d.ts +23 -0
  63. package/session-activity.mjs +124 -0
  64. package/session-append-scan.mjs +27 -17
  65. package/session-branch-handoff.mjs +115 -0
  66. package/session-claude-graph.mjs +29 -7
  67. package/session-claude-index.mjs +732 -114
  68. package/session-claude-runtime.d.ts +28 -0
  69. package/session-claude-runtime.mjs +171 -6
  70. package/session-claude-scan-store.mjs +164 -0
  71. package/session-claude.mjs +9 -0
  72. package/session-codex-activity.d.ts +6 -0
  73. package/session-codex-activity.mjs +120 -0
  74. package/session-codex-window.mjs +24 -4
  75. package/session-config-scan.mjs +141 -0
  76. package/session-convert.d.ts +33 -0
  77. package/session-convert.mjs +179 -0
  78. package/session-emission-version.json +1 -0
  79. package/session-file.mjs +11 -1
  80. package/session-follower.mjs +17 -5
  81. package/session-fresh-id.mjs +15 -0
  82. package/session-hermes-import.mjs +61 -0
  83. package/session-hermes.mjs +1 -1
  84. package/session-import.mjs +37 -0
  85. package/session-inspect.d.ts +18 -0
  86. package/session-inspect.mjs +122 -0
  87. package/session-machine-tree.d.ts +33 -0
  88. package/session-machine-tree.mjs +274 -0
  89. package/session-materialize-files.mjs +78 -0
  90. package/session-materialize-opencode.mjs +137 -0
  91. package/session-materialize.mjs +186 -0
  92. package/session-message.mjs +85 -0
  93. package/session-model.d.ts +9 -0
  94. package/session-model.mjs +1 -0
  95. package/session-opencode-store.mjs +39 -3
  96. package/session-path.mjs +6 -3
  97. package/session-pi-content.mjs +7 -2
  98. package/session-read.d.ts +134 -2
  99. package/session-read.mjs +86 -9
  100. package/session-recorded-config.mjs +3 -2
  101. package/session-records.mjs +12 -6
  102. package/session-residue-store.mjs +90 -0
  103. package/session-residue.mjs +150 -0
  104. package/session-resume-instructions.mjs +1 -1
  105. package/session-saved-read.mjs +105 -0
  106. package/session-transfer.mjs +141 -0
  107. package/session-views.mjs +1 -0
  108. package/session-write-claude.mjs +135 -0
  109. package/session-write-codex.mjs +146 -0
  110. package/session-write-gemini.mjs +77 -0
  111. package/session-write-goose.mjs +99 -0
  112. package/session-write-grok.mjs +70 -0
  113. package/session-write-native.mjs +41 -0
  114. package/session-write-opencode.mjs +197 -0
  115. package/session-write-pi.mjs +130 -0
  116. package/session-write-residue.mjs +19 -0
  117. package/session-write-utils.mjs +77 -0
  118. package/session-write.d.ts +10 -0
  119. package/session-write.mjs +37 -0
  120. package/skill-toml.mjs +80 -0
  121. package/skills-control.mjs +193 -0
  122. package/skills-list.mjs +237 -0
  123. package/source-files.mjs +12 -2
  124. package/sqlite-reader.mjs +134 -18
  125. package/support-registry.json +402 -0
  126. package/support-report.mjs +69 -0
  127. package/trigger-records.mjs +77 -0
  128. package/triggers-read.mjs +67 -0
  129. package/workflow-read.mjs +165 -0
package/session-read.mjs CHANGED
@@ -1,10 +1,12 @@
1
- // Caller-owned public read operations. Parsing never goes to a native service
1
+ // Caller-owned public session operations. Parsing never goes to a native service
2
2
  // or to a machine daemon carrying another caller's home/environment.
3
3
  import { resolve } from 'node:path';
4
- import { nativeInteger, nativeNumberKind, parseNativeJson, stringifyNativeJson, stringifyNativeJsonPretty } from './native-json.mjs';
4
+ import { nativeInteger, nativeNumberKind, parseNativeJson, stringifyNativeJson, stringifyNativeJsonMember, stringifyNativeJsonPretty } from './native-json.mjs';
5
5
  import { loadSessionLocator } from './session-path.mjs';
6
6
  import { loadSessionDisplayLocator, loadSessionDisplayFrom } from './session-display.mjs';
7
- import { withClaudeReadIndex, supportsClaudeReadIndex, loadClaudeRecordWindow } from './session-claude-index.mjs';
7
+ import { withClaudeReadIndex, supportsClaudeReadIndex, loadClaudeRecordWindow, loadClaudeHistoryWindow, loadClaudeBackgroundAgents, MARK_CHECKPOINT } from './session-claude-index.mjs';
8
+ export { MARK_CHECKPOINT, chainMark } from './session-claude-index.mjs';
9
+ export { sweepScanDirectories } from './session-claude-scan-store.mjs';
8
10
  import { loadCodexRecordWindow } from './session-codex-window.mjs';
9
11
  import { SessionFollower } from './session-follower.mjs';
10
12
  import { normalizedSessionJson } from './session-model.mjs';
@@ -12,17 +14,48 @@ import { boundSessionView, projectedMessageWindow, projectedSessionJson, project
12
14
  import { recordedConfigurationFold, recordedSessionConfiguration } from './session-recorded-config.mjs';
13
15
  import { claudeRuntimeFold } from './session-claude-runtime.mjs';
14
16
  import { withSessionRecords, sessionMetaFold } from './session-records.mjs';
17
+ import { loadRecordedConfigurationRecords } from './session-config-scan.mjs';
15
18
  import { recordedLaunchAgent } from './session-launch-agent.mjs';
16
19
  import { recordedSessionUsage, sessionUsageFold } from './session-usage.mjs';
17
20
  import { loadMachineSpend } from './session-spend.mjs';
18
21
  import { sessionResumeLaunch } from './session-resume-instructions.mjs';
22
+ export { importSessionDocument } from './session-import.mjs';
23
+ export { branchSession, handoffSession } from './session-branch-handoff.mjs';
24
+ export { materializeSession } from './session-materialize.mjs';
25
+ export { transferSession } from './session-transfer.mjs';
26
+ export { supportRegistryReport } from './support-report.mjs';
27
+ export { loadHarnessSettings } from './harness-settings.mjs';
28
+ export { listInstalledSkills } from './skills-list.mjs';
29
+ export { mutateInstalledSkill } from './skills-control.mjs';
30
+ export { showMemory } from './memory-show.mjs';
31
+ export { listRuns, getRun } from './runs-read.mjs';
32
+ export { listJobs, getJob } from './jobs-read.mjs';
33
+ export { mutateJob, mutateJobNotepad, CONTROLLED_JOB_HARNESSES } from './jobs-control.mjs';
34
+ export { listTriggers } from './triggers-read.mjs';
35
+ export { listChannels, channelStatus } from './channels-read.mjs';
36
+ export { loadWorkflow } from './workflow-read.mjs';
37
+ export { listRoutes } from './routes-read.mjs';
38
+ export { readJobNotepad } from './job-notepad-read.mjs';
39
+ export { listProfiles, getProfile } from './profiles-read.mjs';
40
+ export { mutateProfile } from './profiles-control.mjs';
19
41
  import { parseUsageSince, usageMoment } from './session-usage-time.mjs';
20
42
 
21
- export const SESSION_READ_METHODS = Object.freeze(['harness.v1.sessions.load', 'harness.v1.sessions.follow', 'harness.v1.sessions.unfollow', 'harness.v1.sessions.recorded_config', 'harness.v1.sessions.launch_agent', 'harness.v1.sessions.usage', 'harness.v1.sessions.spend', 'harness.v1.sessions.resume_instructions']);
43
+ export const SESSION_READ_METHODS = Object.freeze(['harness.v1.workflow.load', 'harness.v1.channels.list', 'harness.v1.channels.status', 'harness.v1.triggers.list', 'harness.v1.routes.list', 'harness.v1.jobs.list', 'harness.v1.jobs.get', 'harness.v1.jobs.create', 'harness.v1.jobs.update', 'harness.v1.jobs.pause', 'harness.v1.jobs.resume', 'harness.v1.jobs.run', 'harness.v1.jobs.delete', 'harness.v1.jobs.notepad_set', 'harness.v1.jobs.notepad_delete', 'harness.v1.runs.list', 'harness.v1.runs.get', 'harness.v1.jobs.notepad', 'harness.v1.memory.show', 'harness.v1.profiles.list', 'harness.v1.profiles.get', 'harness.v1.profiles.create', 'harness.v1.profiles.delete', 'harness.v1.skills.list', 'harness.v1.skills.install', 'harness.v1.skills.remove', 'harness.v1.harnesses.settings', 'harness.v1.support.report', 'harness.v1.sessions.import', 'harness.v1.sessions.export', 'harness.v1.sessions.translate', 'harness.v1.sessions.branch', 'harness.v1.sessions.handoff', 'harness.v1.sessions.materialize', 'harness.v1.sessions.load', 'harness.v1.sessions.follow', 'harness.v1.sessions.unfollow', 'harness.v1.sessions.recorded_config', 'harness.v1.sessions.launch_agent', 'harness.v1.sessions.usage', 'harness.v1.sessions.spend', 'harness.v1.sessions.resume_instructions', 'harness.v1.sessions.tree', 'harness.v1.sessions.activity_under', 'harness.v1.sessions.history', 'harness.v1.sessions.activity.subscribe', 'harness.v1.sessions.activity.unsubscribe', 'harness.v1.sessions.inbox', 'harness.v1.sessions.message']);
22
44
  export const SESSION_READ_EVENT = 'harness.v1.sessions.event';
23
- export const SESSION_READ_CAPABILITY = Object.freeze({ version: 1, owner: 'caller', language: 'typescript', native_parser: false, recorded_config: true, launch_agent: true, usage: true, spend: true, resume_instructions: true });
45
+ export const SESSION_READ_CAPABILITY = Object.freeze({ version: 1, owner: 'caller', language: 'typescript', native_parser: false, recorded_config: true, launch_agent: true, usage: true, spend: true, resume_instructions: true, tree: true, activity_under: true, history: true, activity: true, inbox: true, message: true, import: true, export: true, translate: true, branch: true, handoff: true, materialize: true, support: true, settings: true, skills: true, skills_control: true, profiles: true, profiles_control: true, memory_show: true, job_notepad: true, runs: true, jobs: true, jobs_control: true, routes: true, triggers: true, channels: true, workflow: true });
46
+ export const SESSION_READ_OPERATIONS = Object.freeze(['export', 'skills_list', 'skills_install', 'skills_remove', 'profiles_list', 'profiles_get', 'profiles_create', 'profiles_delete', 'jobs_notepad', 'runs_list', 'runs_get', 'jobs_list', 'jobs_get', 'jobs_create', 'jobs_update', 'jobs_pause', 'jobs_resume', 'jobs_run', 'jobs_delete', 'jobs_notepad_set', 'jobs_notepad_delete', 'routes_list', 'triggers_list', 'channels_list', 'channels_status', 'workflow_load']);
24
47
  export { loadMachineSpend as loadSessionSpend };
48
+ export { parseSessionMessageParams, sessionMessageInboundControls } from './session-message.mjs';
49
+ export { nativeInteger as sessionWireInteger };
25
50
  export { parseNativeJson as parseSessionWire, stringifyNativeJson as stringifySessionWire };
51
+ /** Rebuild a reply without rounding the caller's native numeric ID. */
52
+ export function sessionWireReply(request, answer) {
53
+ const id = request != null && typeof request === 'object'
54
+ ? stringifyNativeJsonMember(request, 'id') : undefined;
55
+ const reply = parseNativeJson(`{"jsonrpc":"2.0","id":${id ?? 'null'}}`);
56
+ for (const key of Object.keys(answer)) if (key !== 'id' && key !== 'jsonrpc') reply[key] = answer[key];
57
+ return reply;
58
+ }
26
59
  export { SESSION_UNREADABLE } from './session-file.mjs';
27
60
  export { stringifyNativeJsonPretty as prettySessionWire };
28
61
  const invalid = (message) => { throw Object.assign(new Error(message), { rpcCode: -32602 }); };
@@ -45,6 +78,18 @@ function unsigned(parent, key, maximum = Number.MAX_SAFE_INTEGER) {
45
78
  return Math.min(parent[key], maximum);
46
79
  }
47
80
 
81
+ /** The marks a windowed read's caller holds, named as their chain (loadClaudeRecordWindow, loadCodexRecordWindow):
82
+ * their count, the digest of the chain through them and the digest at each MARK_CHECKPOINT-th of them. */
83
+ function knownMarks(value) {
84
+ if (value == null) return null;
85
+ object(value, 'view.known_marks');
86
+ const count = unsigned(value, 'count'), digest = value.digest, checkpoints = value.checkpoints;
87
+ if (count == null) invalid('known_marks.count must be an unsigned 64-bit integer');
88
+ string(digest, 'known_marks.digest');
89
+ if (!Array.isArray(checkpoints) || checkpoints.length !== Math.floor(count / MARK_CHECKPOINT) || checkpoints.some((each) => typeof each !== 'string')) invalid('known_marks.checkpoints must be the chain digest at each MARK_CHECKPOINT-th mark');
90
+ return { count, digest, checkpoints };
91
+ }
92
+
48
93
  export function parseSessionReadParams(params, { cwd = process.cwd() } = {}) {
49
94
  object(params, 'session read params'); const supplied = object(params.locator, 'locator'), storage = object(supplied.storage, 'locator.storage');
50
95
  string(supplied.harness, 'locator.harness'); // HarnessId is extensible, including third-party names.
@@ -60,7 +105,8 @@ export function parseSessionReadParams(params, { cwd = process.cwd() } = {}) {
60
105
  view = { includeSubagents: boolean(suppliedView, 'include_subagents', false), displayHistory: boolean(suppliedView, 'display_history', false),
61
106
  messageLimit: unsigned(suppliedView, 'tail_messages', 5000), maxMessageChars: unsigned(suppliedView, 'max_message_chars', 64000),
62
107
  fromByte: unsigned(suppliedView, 'from_byte'), fromMessage: unsigned(suppliedView, 'from_message'),
63
- windowBytes: unsigned(suppliedView, 'window_bytes'), windowMessages: unsigned(suppliedView, 'window_messages'), marks: boolean(suppliedView, 'marks', false) };
108
+ windowBytes: unsigned(suppliedView, 'window_bytes'), windowMessages: unsigned(suppliedView, 'window_messages'), marks: boolean(suppliedView, 'marks', false),
109
+ knownMarks: knownMarks(suppliedView.known_marks) };
64
110
  if (view.messageLimit != null) view.messageLimit = Math.max(1, view.messageLimit);
65
111
  if (view.maxMessageChars != null) view.maxMessageChars = Math.max(256, view.maxMessageChars);
66
112
  }
@@ -101,12 +147,12 @@ export async function loadSessionRead(params, context = {}) {
101
147
  const readOptions = { ...context, fidelity, includeSubagents: view?.includeSubagents ?? true };
102
148
  // A view from a message on is a window of the session read record by record, where its reader has one.
103
149
  if (view?.fromMessage != null && !view.includeSubagents && !view.displayHistory) {
104
- const windowed = await loadClaudeRecordWindow(locator, { from: view.fromMessage, bytes: view.windowBytes, messages: view.windowMessages, marks: view.marks }, readOptions);
150
+ const windowed = await loadClaudeRecordWindow(locator, { from: view.fromMessage, bytes: view.windowBytes, messages: view.windowMessages, marks: view.marks, known: view.knownMarks }, readOptions);
105
151
  if (windowed != null) return windowed;
106
152
  }
107
153
  // So is a view from a message on of a Codex rollout's display history read from an anchor.
108
154
  if (view?.fromMessage != null && !view.includeSubagents && view.displayHistory && view.fromByte != null) {
109
- const windowed = await loadCodexRecordWindow(locator, view.fromByte, { from: view.fromMessage, bytes: view.windowBytes, messages: view.windowMessages, marks: view.marks }, readOptions);
155
+ const windowed = await loadCodexRecordWindow(locator, view.fromByte, { from: view.fromMessage, bytes: view.windowBytes, messages: view.windowMessages, marks: view.marks, known: view.knownMarks }, readOptions);
110
156
  if (windowed != null) return windowed;
111
157
  }
112
158
  const session = view?.displayHistory
@@ -114,8 +160,37 @@ export async function loadSessionRead(params, context = {}) {
114
160
  : await loadSessionDisplayLocator(locator, view.messageLimit ?? 500, readOptions)
115
161
  : await loadSessionLocator(locator, readOptions);
116
162
  if (view?.fromByte == null) boundSessionView(session, view?.messageLimit, view?.maxMessageChars);
163
+ // A view is a window of the transcript; its background agents are of the whole file (a launch long before it,
164
+ // or before a compaction, included). Only a Claude Code transcript records them.
165
+ const backgroundAgents = locator.harness === 'claude-code' ? await loadClaudeBackgroundAgents(locator, readOptions).catch(() => null) : null;
117
166
  return { session: { ...normalizedSessionJson(session), resolved_profile: recordedSessionProfile(session),
118
- resolved_profile_history: recordedSessionProfileHistory(session) } };
167
+ resolved_profile_history: recordedSessionProfileHistory(session), ...(backgroundAgents ? { background_agents: backgroundAgents } : {}) } };
168
+ }
169
+ /** The most messages one history page holds (harness.v1.sessions.history), and the most transcript it reads them from. */
170
+ export const SESSION_HISTORY_MESSAGES = 200, SESSION_HISTORY_BYTES = 8 * 1024 * 1024;
171
+ /**
172
+ * A page of a session's history read with every segment before its compactions (`harness.v1.sessions.history`):
173
+ * `{ locator, before, messages, bytes }`, the messages before the place `before` of that read, or, with `before` null,
174
+ * before where a read from the latest compaction on begins (loadClaudeHistoryWindow). Answers the page as a window read
175
+ * does (`session.messages`, `window.offset`, `window.segment_start`, `window.reading` while its transcript is still being
176
+ * read through); a session whose reader keeps no such history (any but a Claude Code transcript) answers
177
+ * `{ session: null, window: null, unsupported }`.
178
+ */
179
+ export async function loadSessionHistory(params, context = {}) {
180
+ object(params, 'session history params');
181
+ const { locator } = parseSessionReadParams({ locator: params.locator }, context);
182
+ const before = unsigned(params, 'before'), messages = unsigned(params, 'messages', SESSION_HISTORY_MESSAGES) ?? 50;
183
+ const bytes = unsigned(params, 'bytes', SESSION_HISTORY_BYTES) ?? SESSION_HISTORY_BYTES, textUnits = unsigned(params, 'text_units');
184
+ const page = await loadClaudeHistoryWindow(locator, { before, messages: Math.max(1, messages), bytes }, context);
185
+ if (page == null) return { session: null, window: null, unsupported: 'only a Claude Code transcript keeps its history before a compaction apart from its capture' };
186
+ // `text_units`: each string of the page cut to that many UTF-16 units here, in the reading worker, so what its asker
187
+ // takes over is bounded whatever a message holds
188
+ if (textUnits != null && page.session.messages) {
189
+ const cut = (value) => typeof value === 'string' ? (value.length > textUnits ? `${value.slice(0, textUnits)}… (shortened)` : value)
190
+ : Array.isArray(value) ? value.map(cut) : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cut(item)])) : value;
191
+ page.session.messages = page.session.messages.map(cut);
192
+ }
193
+ return page;
119
194
  }
120
195
  export async function openSessionReadFollower(params, context = {}) {
121
196
  object(params, 'session read params');
@@ -131,6 +206,8 @@ export async function loadRecordedSessionConfiguration(params, context = {}) {
131
206
  // LocatorParams ignores unrelated fields. This door always reads the full
132
207
  // lossless source, independent of any supplied display/load-view knobs.
133
208
  const { locator } = parseSessionReadParams({ locator: params.locator }, context);
209
+ const indexed = await loadRecordedConfigurationRecords(locator, context);
210
+ if (indexed != null) return indexed;
134
211
  const folded = await withSessionRecords(locator, async (source, records) => {
135
212
  const fold = recordedConfigurationFold(), meta = sessionMetaFold(source, fold);
136
213
  // The runtime fold takes every record's calls before any record: the file is read once more for it.
@@ -7,10 +7,11 @@ import { claudeRuntimeManifest } from './session-claude-runtime.mjs';
7
7
  const HEADER_KEYS = ['cwd', 'model', 'approval_policy', 'sandbox_policy'];
8
8
 
9
9
  /** Codex headers in recorded order, the last value of each key standing. */
10
- export function recordedConfigurationFold() {
11
- let recorded = new Map();
10
+ export function recordedConfigurationFold(saved = null) {
11
+ let recorded = new Map(saved ?? []);
12
12
  return {
13
13
  clear() { recorded = new Map(); },
14
+ clone() { return recordedConfigurationFold(recorded); },
14
15
  header(header) {
15
16
  const payload = field(header, 'payload') === undefined ? header : header.payload;
16
17
  for (const key of HEADER_KEYS) if (field(payload, key) !== undefined) recorded.set(key, stringifyNativeJsonMember(payload, key));
@@ -6,7 +6,7 @@ import { basename, dirname, extname, join } from 'node:path';
6
6
  import { withSessionFile } from './file-io.mjs';
7
7
  import { sourceStat } from './store-registry.mjs';
8
8
  import { sessionFileLines } from './session-file.mjs';
9
- import { parseNativeJson } from './native-json.mjs';
9
+ import { cloneNativeJson, parseNativeJson } from './native-json.mjs';
10
10
  import { nativeTrim } from './text.mjs';
11
11
  import { field, stringField } from './session-codec-utils.mjs';
12
12
  import { createSession } from './session-model.mjs';
@@ -50,14 +50,19 @@ async function* records(reader) {
50
50
 
51
51
  /** `work(source, records)` over the transcript a locator names, or null when
52
52
  * it is not a plain Claude Code or Codex JSONL file. */
53
- export async function withSessionRecords(locator, work, { metrics } = {}) {
53
+ export async function withSessionRecords(locator, work, context = {}) {
54
+ return withSessionRecordFile(locator, (source, reader) => work(source, () => records(reader)), context);
55
+ }
56
+
57
+ /** The verified open snapshot for an append-aware record fold. */
58
+ export async function withSessionRecordFile(locator, work, { metrics } = {}) {
54
59
  const { path, kind } = locator.storage;
55
60
  if (kind !== 'file' || locator.harness === 'hermes' || isCompressedSessionPath(path)) return null;
56
61
  if ((await sourceStat(join(dirname(path), `${basename(path, extname(path))}.meta.json`)))?.isFile()) return null;
57
62
  if (!(await sourceStat(path))?.isFile() || await looksLikeSessionSqlite(path, metrics)) return null;
58
63
  return (await withSessionFile(path, async (reader) => {
59
64
  const source = await recordedSource(reader);
60
- return ['claude_code', 'codex'].includes(source) ? { result: await work(source, () => records(reader)) } : null;
65
+ return ['claude_code', 'codex'].includes(source) ? { result: await work(source, reader) } : null;
61
66
  }, metrics)).result;
62
67
  }
63
68
 
@@ -65,15 +70,16 @@ export async function withSessionRecords(locator, work, { metrics } = {}) {
65
70
  * in the same order. `headers` takes each Codex header as it stands, and is
66
71
  * cleared when a restored provenance (in a record of either dialect)
67
72
  * replaces those read before it. */
68
- export function sessionMetaFold(source, headers = null) {
69
- const { meta } = createSession(source);
70
- let restored = false, held = meta.codex_headers;
73
+ export function sessionMetaFold(source, headers = null, saved = null) {
74
+ const meta = saved?.meta ?? createSession(source).meta;
75
+ let restored = saved?.restored ?? false, held = meta.codex_headers;
71
76
  const replaced = () => {
72
77
  if (meta.codex_headers === held) return;
73
78
  held = meta.codex_headers; headers?.clear(); for (const header of held) headers?.header(header);
74
79
  };
75
80
  return {
76
81
  meta,
82
+ clone(nextHeaders) { return sessionMetaFold(source, nextHeaders, { meta: cloneNativeJson(meta), restored }); },
77
83
  observe({ raw, value }) {
78
84
  if (source === 'claude_code') { if (value !== undefined) { captureClaudeMeta(value, meta, raw); replaced(); } return; }
79
85
  // The Codex codec parses its trimmed line.
@@ -0,0 +1,90 @@
1
+ // Best-effort cache only: validated restoration, never a second source of session truth.
2
+ import { mkdir, readFile, writeFile, rename, readdir, stat, lstat, open, unlink, rmdir } from 'node:fs/promises';
3
+ import { join, resolve, extname } from 'node:path';
4
+ import { userInfo } from 'node:os';
5
+ import { nativeDuplicateObjectKeys, parseNativeJson, stringifyNativeJson } from './native-json.mjs';
6
+ import { decodeSessionUtf8 } from './session-file.mjs';
7
+ import { RESIDUE_FORMAT_NAMES, residueSegments } from './session-residue.mjs';
8
+
9
+ const DAY = 86_400_000, MAX_AGE = 90 * DAY, MAX_BYTES = 4n * 1024n * 1024n * 1024n;
10
+ function cacheRoot({ env = process.env, cwd = process.cwd() } = {}) {
11
+ let home = env.HOME || (process.platform === 'win32' && env.USERPROFILE);
12
+ if (!home) try { home = userInfo().homedir || '.'; } catch { home = '.'; }
13
+ const instructions = env.SUPERCODE_HOME || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, 'supercode') : join(home, '.config', 'supercode'));
14
+ return resolve(cwd, instructions, 'residue');
15
+ }
16
+ function decodedSegment(text) {
17
+ const value = parseNativeJson(text);
18
+ if (value == null || typeof value !== 'object' || Array.isArray(value) || !Array.isArray(value.lines)
19
+ || !value.lines.every(line => typeof line === 'string') || typeof value.end_of_source !== 'boolean'
20
+ || typeof value.trailing_newline !== 'boolean') return null;
21
+ if (nativeDuplicateObjectKeys(value).some(key => ['lines', 'end_of_source', 'trailing_newline', 'digest', 'source', 'source_session'].includes(key))) return null;
22
+ for (const key of ['digest', 'source']) {
23
+ if (!Object.hasOwn(value, key)) value[key] = '';
24
+ if (typeof value[key] !== 'string') return null;
25
+ }
26
+ if (value.source_session == null) value.source_session = null;
27
+ if (value.source_session !== null && typeof value.source_session !== 'string') return null;
28
+ return value;
29
+ }
30
+
31
+ async function pruneIfDue(root) {
32
+ const marker = join(root, '.pruned'), now = Date.now();
33
+ let due; try { const at = (await stat(marker)).mtimeMs; due = at > now || now - at >= DAY; } catch { due = true; }
34
+ if (!due) return;
35
+ try { await mkdir(root, { recursive: true }); await writeFile(marker, ''); } catch { return; }
36
+ const pending = [root], dirs = [], segments = [];
37
+ while (pending.length) {
38
+ const directory = pending.pop(); let entries;
39
+ try { entries = await readdir(directory); } catch { continue; }
40
+ for (const name of entries) {
41
+ const path = join(directory, name); let metadata;
42
+ try { metadata = await lstat(path, { bigint: true }); } catch { continue; }
43
+ if (metadata.isDirectory()) { pending.push(path); dirs.push(path); }
44
+ else if (extname(path) === '.json') segments.push({ path, bytes: metadata.size, modified: metadata.mtimeNs });
45
+ }
46
+ }
47
+ segments.sort((a, b) => a.modified < b.modified ? -1 : a.modified > b.modified ? 1 : 0);
48
+ let total = segments.reduce((sum, entry) => sum + entry.bytes, 0n);
49
+ const wall = BigInt(Date.now()) * 1_000_000n;
50
+ for (const segment of segments) {
51
+ const stale = wall >= segment.modified && wall - segment.modified > BigInt(MAX_AGE) * 1_000_000n;
52
+ if (!stale && total <= MAX_BYTES) break;
53
+ try { await unlink(segment.path); total -= segment.bytes; } catch { /* A failed prune leaves the cache intact. */ }
54
+ }
55
+ dirs.sort((a, b) => b.split(/[\\/]/u).length - a.split(/[\\/]/u).length);
56
+ for (const path of dirs) try { await rmdir(path); } catch { /* Only empty directories can be removed. */ }
57
+ }
58
+
59
+ export function createResidueStore(context = {}) {
60
+ const root = cacheRoot(context);
61
+ const directory = (format, key) => join(root, RESIDUE_FORMAT_NAMES[format].toLowerCase(), key.slice(0, 2) || '00', key);
62
+ return {
63
+ async store(session) {
64
+ const segments = residueSegments(session); if (!segments.length) return;
65
+ await pruneIfDue(root);
66
+ for (const keyed of segments) {
67
+ const parent = directory(keyed.format, keyed.key), path = join(parent, `${keyed.segment.source}.json`);
68
+ try { await stat(path); continue; } catch { /* Missing or unreadable path: attempt the native best-effort write. */ }
69
+ try {
70
+ await mkdir(parent, { recursive: true }); await writeFile(path + '.tmp', stringifyNativeJson(keyed.segment)); await rename(path + '.tmp', path);
71
+ } catch { /* A failed cache write does not prevent translation. */ }
72
+ }
73
+ },
74
+ async lookup(format, key) {
75
+ const parent = directory(format, key), segments = []; let entries;
76
+ try { entries = await readdir(parent); } catch { return segments; }
77
+ for (const name of entries) {
78
+ const path = join(parent, name); if (extname(path) !== '.json') continue;
79
+ let segment; try { segment = decodedSegment(decodeSessionUtf8(await readFile(path))); } catch { continue; }
80
+ if (segment == null) continue;
81
+ let handle;
82
+ try { handle = await open(path, 'r+'); const metadata = await handle.stat(); await handle.utimes(metadata.atime, new Date()); }
83
+ catch { /* Read-only cache entries remain usable without an mtime refresh. */ }
84
+ finally { if (handle != null) await handle.close().catch(() => {}); }
85
+ segments.push(segment);
86
+ }
87
+ return segments;
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,150 @@
1
+ // Portable source segments complement a target projection; cached bytes are never trusted alone.
2
+ import { blake3 } from '@noble/hashes/blake3.js';
3
+ import { cloneNativeJson, stringifyNativeJson } from './native-json.mjs';
4
+ import { SESSION_FORMATS } from './session-model.mjs';
5
+ import { readSessionDocument } from './session-document.mjs';
6
+ import { toSessionJsonlSpliced } from './session-write.mjs';
7
+ import { parsedJsonField } from './session-write-utils.mjs';
8
+
9
+ export const RESIDUE_FORMAT_NAMES = Object.freeze({ claude_code: 'ClaudeCode', codex: 'Codex', opencode: 'OpenCode',
10
+ pi: 'Pi', grok: 'Grok', gemini: 'Gemini', goose: 'Goose' });
11
+ const hash = buffers => Buffer.from(blake3(Buffer.concat(buffers))).toString('hex');
12
+ const emptyKey = () => hash([Buffer.from('supercode-residue-v1')]);
13
+ function groups(messages) {
14
+ const result = [];
15
+ for (const [index, message] of messages.entries()) {
16
+ if (message.role === 'tool' && result.length) result[result.length - 1].end = index + 1;
17
+ else result.push({ start: index, end: index + 1 });
18
+ }
19
+ return result;
20
+ }
21
+ function messageText(message) {
22
+ return message.content_parts == null ? message.content ?? '' : message.content_parts
23
+ .filter(part => part?.type === 'text' && typeof part?.text === 'string').map(part => part.text).join('');
24
+ }
25
+ function canonicalGroup(messages) {
26
+ const head = messages[0], calls = head.tool_calls ?? [];
27
+ const callPosition = result => { const index = calls.findIndex(call => call.id === result.tool_call_id); return index < 0 ? Infinity : index; };
28
+ const results = messages.slice(1).sort((left, right) => callPosition(left) - callPosition(right));
29
+ return [head.role, messageText(head), calls.map(call => {
30
+ const value = [call.function.name, null]; parsedJsonField(value, '1', call.function.arguments); return value;
31
+ }), results.map(messageText)];
32
+ }
33
+
34
+ export function residueChainKeys(messages) {
35
+ let previous = emptyKey();
36
+ return groups(messages).map(group => {
37
+ previous = hash([Buffer.from(previous), Buffer.from(stringifyNativeJson(canonicalGroup(messages.slice(group.start, group.end))))]);
38
+ return { group, key: previous };
39
+ });
40
+ }
41
+
42
+ export function segmentDigest(lines) {
43
+ const buffers = [];
44
+ for (const line of lines) {
45
+ const bytes = Buffer.from(line), length = Buffer.alloc(8); length.writeBigUInt64LE(BigInt(bytes.length));
46
+ buffers.push(length, bytes);
47
+ }
48
+ return hash(buffers);
49
+ }
50
+ export const verifiedSegment = segment => segment.digest === segmentDigest(segment.lines);
51
+ function segment(lines, end, trailing, source, sessionId) {
52
+ return { lines, end_of_source: end, trailing_newline: trailing, digest: segmentDigest(lines), source, source_session: sessionId };
53
+ }
54
+
55
+ export function residueSegments(session) {
56
+ const format = session.meta.source;
57
+ if (!SESSION_FORMATS.includes(format) || !session.raw_is_verbatim || session.meta.message_records.length !== session.messages.length) return [];
58
+ const keyed = residueChainKeys(session.messages);
59
+ const source = hash([Buffer.from(RESIDUE_FORMAT_NAMES[format]), Buffer.from(session.meta.session_id ?? ''), Buffer.from(session.raw[0] ?? '')]);
60
+ const keyedSegment = (key, lines, end) => ({ key, format,
61
+ segment: segment(lines, end, session.raw_trailing_newline, source, session.meta.session_id) });
62
+ if (!keyed.length) return session.raw.length ? [keyedSegment(emptyKey(), [...session.raw], true)] : [];
63
+ const starts = [];
64
+ for (const { group } of keyed) {
65
+ const record = session.meta.message_records[group.start]; if (record == null) break;
66
+ starts.push(BigInt(record));
67
+ }
68
+ const segments = [];
69
+ for (let index = 0; index < starts.length; index++) {
70
+ const from = index ? starts[index] : 0n, end = index + 1 === keyed.length;
71
+ const to = end ? BigInt(session.raw.length) : starts[index + 1] ?? BigInt(session.raw.length);
72
+ if (from > to || to > BigInt(session.raw.length)) break;
73
+ segments.push(keyedSegment(keyed[index].key, session.raw.slice(Number(from), Number(to)), end));
74
+ }
75
+ return segments;
76
+ }
77
+
78
+ async function residueSource(session, format, lookup, keyed) {
79
+ const own = session.meta.session_id, recorded = session.meta.source === 'grok' ? null : own;
80
+ const candidates = (await lookup(format, keyed[0].key)).filter(verifiedSegment)
81
+ .filter(candidate => recorded == null || candidate.source_session == null || candidate.source_session === recorded);
82
+ let best = [];
83
+ for (const candidate of candidates) {
84
+ let matched = 0; const lines = [];
85
+ for (const { key } of keyed) {
86
+ const found = (await lookup(format, key)).find(segment => segment.source === candidate.source && verifiedSegment(segment));
87
+ if (found == null) break;
88
+ matched++; lines.push(...found.lines); if (found.end_of_source) break;
89
+ }
90
+ best.push({ source: candidate.source, session: candidate.source_session, matched, lines });
91
+ }
92
+ if (!best.length) return null;
93
+ const longest = Math.max(...best.map(candidate => candidate.matched)); best = best.filter(candidate => candidate.matched === longest);
94
+ if (best.length > 1 && own != null && best.some(candidate => candidate.session === own)) best = best.filter(candidate => candidate.session === own);
95
+ const first = best[0];
96
+ return best.every(candidate => candidate.lines.length === first.lines.length && candidate.lines.every((line, index) => line === first.lines[index]))
97
+ ? first.source : null;
98
+ }
99
+
100
+ async function restoreEmpty(session, format, lookup) {
101
+ let candidates = (await lookup(format, emptyKey())).filter(verifiedSegment);
102
+ if (candidates.length > 1 && session.meta.session_id != null) candidates = candidates.filter(segment => segment.source_session === session.meta.session_id);
103
+ if (candidates.length !== 1) return null;
104
+ const segment = candidates[0], text = segment.lines.join('\n') + (segment.trailing_newline ? '\n' : '');
105
+ let written; try { written = readSessionDocument(text, { source: format }); } catch { return null; }
106
+ return written.messages.length ? null : { content: text, report: { restored_messages: 0, rendered_messages: 0 } };
107
+ }
108
+
109
+ async function restorePrefix(session, format, lookup, keyed, limit) {
110
+ const lines = []; let restoredGroups = 0, restoredMessages = 0, whole = null;
111
+ for (const { group, key } of keyed.slice(0, limit)) {
112
+ const segment = await lookup(format, key); if (segment == null) break;
113
+ lines.push(...segment.lines); restoredGroups++; restoredMessages = group.end;
114
+ if (segment.end_of_source) { whole = segment.trailing_newline; break; }
115
+ }
116
+ if (!restoredGroups) return { kind: 'nothing' };
117
+ const report = { restored_messages: restoredMessages, rendered_messages: session.messages.length - restoredMessages };
118
+ let content;
119
+ if (whole != null && !report.rendered_messages) content = lines.join('\n') + (whole ? '\n' : '');
120
+ else {
121
+ const spliced = cloneNativeJson(session); spliced.meta.source = format;
122
+ spliced.raw = [...lines, ...Array(report.rendered_messages).fill('')]; spliced.raw_is_verbatim = true;
123
+ spliced.imported_message_count = restoredMessages; content = toSessionJsonlSpliced(spliced, format);
124
+ }
125
+ let actual;
126
+ try { actual = residueChainKeys(readSessionDocument(content, { source: format }).messages); }
127
+ catch { return { kind: 'unsound', firstMismatch: 0, restoredGroups }; }
128
+ const shorter = Math.min(actual.length, keyed.length);
129
+ let mismatch = 0; while (mismatch < shorter && actual[mismatch].key === keyed[mismatch].key) mismatch++;
130
+ if (mismatch < keyed.length || actual.length !== keyed.length) return { kind: 'unsound', firstMismatch: mismatch, restoredGroups };
131
+ return { kind: 'sound', content, report };
132
+ }
133
+
134
+ /** A cache miss, ambiguity or unsound prefix falls back to ordinary rendering. */
135
+ export async function restoreResidue(session, format, lookup) {
136
+ if (!SESSION_FORMATS.includes(format)) throw new TypeError(`unknown session format: ${format}`);
137
+ const keyed = residueChainKeys(session.messages);
138
+ if (!keyed.length) return restoreEmpty(session, format, lookup);
139
+ const source = await residueSource(session, format, lookup, keyed); if (source == null) return null;
140
+ const selected = async (format, key) => (await lookup(format, key)).find(segment => segment.source === source && verifiedSegment(segment));
141
+ let limit = keyed.length;
142
+ for (;;) {
143
+ const result = await restorePrefix(session, format, selected, keyed, limit);
144
+ if (result.kind === 'sound') return { content: result.content, report: result.report };
145
+ if (result.kind === 'nothing') return null;
146
+ const shorter = Math.min(result.firstMismatch, Math.max(0, result.restoredGroups - 1));
147
+ if (!shorter || shorter >= limit) return null;
148
+ limit = shorter;
149
+ }
150
+ }
@@ -14,7 +14,7 @@ function startupArguments(harness, cwd, unattended) {
14
14
  return arguments_;
15
15
  }
16
16
 
17
- function grokHomeEnvironment(env, cwd) {
17
+ export function grokHomeEnvironment(env, cwd) {
18
18
  if (env.GROK_HOME !== undefined) return {};
19
19
  let home = env.HOME || (process.platform === 'win32' ? env.USERPROFILE : null);
20
20
  if (!home) { try { home = userInfo().homedir; } catch { return {}; } }
@@ -0,0 +1,105 @@
1
+ // Read the native saved-session family. Individual active/archive fallback
2
+ // deliberately matches SessionStore; this is not a store migration or writer.
3
+ import { mkdir, readFile, readdir, stat } from 'node:fs/promises';
4
+ import { isAbsolute, join, sep } from 'node:path';
5
+ import { userInfo } from 'node:os';
6
+ import { decodeSessionUtf8 } from './session-file.mjs';
7
+ import { nativeDuplicateObjectKeys, nativeInteger, parseNativeJson } from './native-json.mjs';
8
+ import { deserializeMessage } from './message.mjs';
9
+ import { fromNativeMessages } from './session-native.mjs';
10
+ import { nativeTrim } from './text.mjs';
11
+ import { emptyReductionLog, readReductionLog, REDUCTION_SENTINEL } from './reduction-log.mjs';
12
+
13
+ const lexicalJoin = (root, ...parts) => process.platform === 'win32' && /^[A-Za-z]:$/u.test(root) ? root + parts.join(sep) : join(root, ...parts);
14
+ export function savedSessionRoot(env = process.env) {
15
+ if (env.SUPERCODE_HOME) return lexicalJoin(env.SUPERCODE_HOME, 'sessions');
16
+ if (env.XDG_CONFIG_HOME) return lexicalJoin(env.XDG_CONFIG_HOME, 'supercode', 'sessions');
17
+ let home = env.HOME || (process.platform === 'win32' && env.USERPROFILE);
18
+ if (!home) try { home = userInfo().homedir; } catch { home = '.'; }
19
+ return lexicalJoin(home, '.config', 'supercode', 'sessions');
20
+ }
21
+ export async function savedPathExists(path) { try { await stat(path); return true; } catch { return false; } }
22
+ const textFile = async path => decodeSessionUtf8(await readFile(path));
23
+ const byteOrder = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
24
+ function validateName(name) {
25
+ if (!name || /[/\\\0]/u.test(name) || ['.', '..'].includes(name) || isAbsolute(name) || nativeTrim(name) !== name) throw new Error(`invalid session name: \`${name}\``);
26
+ }
27
+ function metadataName(value) {
28
+ if (value == null || typeof value !== 'object' || Array.isArray(value) || typeof value.name !== 'string') return null;
29
+ const strings = ['name', 'title', 'tier'], booleans = ['archived', 'reduced'], integers = ['full_bytes', 'view_bytes', 'stub_count', 'escalations', 'format_version'];
30
+ for (const key of nativeDuplicateObjectKeys(value)) if ([...strings, ...booleans, ...integers].includes(key)) return null;
31
+ for (const key of strings) if (Object.hasOwn(value, key) && typeof value[key] !== 'string') return null;
32
+ for (const key of booleans) if (Object.hasOwn(value, key) && typeof value[key] !== 'boolean') return null;
33
+ for (const key of integers) if (Object.hasOwn(value, key)) {
34
+ const number = nativeInteger(value, key), max = ['stub_count', 'escalations', 'format_version'].includes(key) ? 0xffffffffn : (1n << 64n) - 1n;
35
+ if (number == null || number > max) return null;
36
+ }
37
+ return value.name;
38
+ }
39
+
40
+ export class SavedSessionReader {
41
+ constructor(root) { this.root = root; }
42
+ directory(archived) { return archived ? lexicalJoin(this.root, 'archived') : this.root; }
43
+ async resolve(query) {
44
+ // Native SessionStore::open creates its otherwise absent directory even
45
+ // for this name lookup; no family member is altered by the reader.
46
+ await mkdir(this.root, { recursive: true });
47
+ const names = [];
48
+ for (const archived of [false, true]) {
49
+ let entries; try { entries = await readdir(this.directory(archived)); } catch { continue; }
50
+ for (const file of entries) if (file.endsWith('.meta.json')) {
51
+ try { const name = metadataName(parseNativeJson(await textFile(lexicalJoin(this.directory(archived), file)))); if (name != null) names.push(name); } catch { /* native catalog skips unreadable/malformed metadata */ }
52
+ }
53
+ }
54
+ names.sort(byteOrder);
55
+ if (names.includes(query)) return query;
56
+ const matches = names.filter(name => name.includes(query));
57
+ if (matches.length === 1) return matches[0];
58
+ if (!matches.length) throw new Error(`no session matching \`${query}\``);
59
+ throw new Error(`\`${query}\` is ambiguous (${matches.length} matches)`);
60
+ }
61
+ async familyPath(name, suffix) {
62
+ validateName(name);
63
+ const active = lexicalJoin(this.directory(false), name + suffix);
64
+ return await savedPathExists(active) ? active : lexicalJoin(this.directory(true), name + suffix);
65
+ }
66
+ async read(name, suffix, optional = false) {
67
+ const path = await this.familyPath(name, suffix);
68
+ if (optional && !await savedPathExists(path)) return null;
69
+ return textFile(path);
70
+ }
71
+ async reductionLog(name) {
72
+ const text = await this.read(name, '.reduction.json', true);
73
+ if (text != null) return readReductionLog(text);
74
+ if ((await this.read(name, '.jsonl', true))?.includes(REDUCTION_SENTINEL)) throw new Error(`\`${name}\` has reduction stubs in its persisted working view, but its reduction log is missing — cannot safely interpret this reduced session; restore \`${name}.reduction.json\` or recreate the reduced session from its full sidecar/export`);
75
+ return emptyReductionLog();
76
+ }
77
+ async childIds(name) {
78
+ validateName(name); const ids = new Set();
79
+ for (const archived of [false, true]) {
80
+ let entries; try { entries = await readdir(lexicalJoin(this.directory(archived), `${name}.subagents`)); } catch { continue; }
81
+ for (const entry of entries) if (entry.endsWith('.sidecar.jsonl')) ids.add(entry.slice(0, -'.sidecar.jsonl'.length));
82
+ }
83
+ return [...ids].sort(byteOrder);
84
+ }
85
+ async child(name, id) {
86
+ validateName(id);
87
+ return this.read(name, `.subagents${sep}${id}.sidecar.jsonl`, true);
88
+ }
89
+ }
90
+
91
+ export function parseSavedMessages(name, text) {
92
+ const known = ['role', 'content', 'tool_calls', 'tool_call_id', 'name'];
93
+ try {
94
+ const messages = text.split('\n').filter(line => nativeTrim(line) !== '').map(line => {
95
+ const value = parseNativeJson(line);
96
+ for (const key of nativeDuplicateObjectKeys(value)) if (known.includes(key)) throw new Error(`duplicate field ${key}`);
97
+ for (const call of value?.tool_calls ?? []) {
98
+ for (const key of nativeDuplicateObjectKeys(call)) if (['id', 'type', 'function'].includes(key)) throw new Error(`duplicate tool-call field ${key}`);
99
+ for (const key of nativeDuplicateObjectKeys(call.function)) if (['name', 'arguments'].includes(key)) throw new Error(`duplicate function field ${key}`);
100
+ }
101
+ return deserializeMessage(value);
102
+ });
103
+ const session = fromNativeMessages(messages); session.meta.session_id = name; return session;
104
+ } catch (error) { throw new Error(`parsing stored session \`${name}\`: ${error.message}`, { cause: error }); }
105
+ }