@volter/supercode-interchange 0.1.1 → 0.1.3

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 (44) hide show
  1. package/README.md +88 -0
  2. package/hermes-store.mjs +5 -2
  3. package/message.mjs +85 -0
  4. package/native-json.mjs +94 -7
  5. package/package.json +16 -3
  6. package/session-claude-append.mjs +107 -0
  7. package/session-claude-content.mjs +209 -0
  8. package/session-claude-files.mjs +78 -0
  9. package/session-claude-graph.mjs +285 -0
  10. package/session-claude-index.mjs +173 -0
  11. package/session-claude.mjs +73 -0
  12. package/session-codec-utils.mjs +115 -0
  13. package/session-codecs.d.ts +64 -0
  14. package/session-codecs.mjs +19 -0
  15. package/session-codex-context.mjs +68 -0
  16. package/session-codex-display-items.mjs +89 -0
  17. package/session-codex-display.mjs +81 -0
  18. package/session-codex-items.mjs +117 -0
  19. package/session-codex-user-history.mjs +62 -0
  20. package/session-codex.mjs +135 -0
  21. package/session-display.mjs +135 -0
  22. package/session-document.mjs +19 -0
  23. package/session-file.mjs +27 -0
  24. package/session-follower.mjs +181 -0
  25. package/session-gemini.mjs +93 -0
  26. package/session-goose.mjs +139 -0
  27. package/session-grok.mjs +72 -0
  28. package/session-hermes.mjs +116 -0
  29. package/session-lines.mjs +20 -0
  30. package/session-model.d.ts +114 -0
  31. package/session-model.mjs +70 -0
  32. package/session-native-residue.mjs +109 -0
  33. package/session-native.mjs +62 -0
  34. package/session-opencode-content.mjs +115 -0
  35. package/session-opencode-store.mjs +101 -0
  36. package/session-opencode.mjs +115 -0
  37. package/session-path.mjs +129 -0
  38. package/session-pi-content.mjs +129 -0
  39. package/session-pi.mjs +86 -0
  40. package/session-read.d.ts +12 -0
  41. package/session-read.mjs +104 -0
  42. package/session-source.mjs +30 -0
  43. package/session-time.mjs +46 -0
  44. package/session-views.mjs +171 -0
@@ -0,0 +1,115 @@
1
+ import { createSession } from './session-model.mjs';
2
+ import { parseNativeJson, nativeInteger, stringifyNativeJson, stringifyNativeJsonMember } from './native-json.mjs';
3
+ import { splitLinesVerbatim } from './session-lines.mjs';
4
+ import { field, stringField, ensureToolResultsPaired } from './session-codec-utils.mjs';
5
+ import { restoreTopLevelResidue } from './session-native-residue.mjs';
6
+ import { nativeTrim } from './text.mjs';
7
+ import { OPENCODE_ORIGINAL_POSITION, pushOpenCodeUser, pushOpenCodeAssistant } from './session-opencode-content.mjs';
8
+
9
+ const utf8Order = (left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right));
10
+ const byInteger = (left, right) => left < right ? -1 : left > right ? 1 : 0;
11
+ export const opencodeEnvelope = (key, value) => stringifyNativeJson({ key, value });
12
+ const record = (id, value, parts = []) => ({ id, value, parts, created: nativeInteger(field(value, 'time'), 'created', { signed: true }) ?? 0n });
13
+
14
+ function guard(nonempty, info, records, sides) {
15
+ if (nonempty && info == null && !records.length && !sides.length) throw Object.assign(new Error('opencode input was recognized as an OpenCode source (envelope or export-document form) but no session/message/part record could be parsed from it — refusing to silently return an empty session'), { code: 'invalid_session' });
16
+ }
17
+ function captureInfo(value, meta) {
18
+ restoreTopLevelResidue(value, meta);
19
+ for (const [source, target] of [['id', 'session_id'], ['directory', 'cwd'], ['agent', 'agent_id']]) { const text = stringField(value, source); if (text != null) meta[target] = text; }
20
+ const model = field(value, 'model'), provider = stringField(model, 'providerID'), id = stringField(model, 'id');
21
+ if (provider != null && id != null) meta.model = `${provider}/${id}`;
22
+ for (const key of ['projectID', 'slug', 'workspaceID']) { const text = stringField(value, key); if (text != null) meta.lineage[key] = text; }
23
+ const parent = stringField(value, 'parentID'); if (parent != null) { meta.lineage.parent_session_id = parent; meta.lineage.parent_thread_id = parent; }
24
+ if (!Object.hasOwn(meta.lineage, 'claude_fork_context_ref_raw') && field(value, 'claude_fork_context_ref') !== undefined) meta.lineage.claude_fork_context_ref_raw = stringifyNativeJsonMember(value, 'claude_fork_context_ref');
25
+ }
26
+
27
+ function fromRecords(info, sides, records, raw, trailing, verbatim, errors) {
28
+ const session = createSession('opencode'), positions = new Map(records.map((item, at) => [item.id, at]));
29
+ // Native compaction references use pre-sort positions; retain that contract.
30
+ records.sort((left, right) => byInteger(left.created, right.created) || utf8Order(left.id, right.id));
31
+ for (const item of records) item.parts.sort((left, right) => utf8Order(stringField(left, 'id') ?? '', stringField(right, 'id') ?? ''));
32
+ session.meta.opencode_headers.push(info ?? null); for (const side of sides) session.meta.opencode_headers.push(side);
33
+ if (info != null) captureInfo(info, session.meta);
34
+ let tail = null;
35
+ for (const item of records) for (const part of item.parts) if (stringField(part, 'type') === 'compaction') {
36
+ const at = positions.get(stringField(part, 'tail_start_id')); if (at !== undefined) tail = Math.max(tail ?? at, at);
37
+ }
38
+ session.firstSystemSeen = false;
39
+ records.forEach((item, at) => {
40
+ const before = session.messages.length, role = stringField(item.value, 'role');
41
+ if (role === 'user') pushOpenCodeUser(item.value, item.parts, session);
42
+ else if (role === 'assistant') pushOpenCodeAssistant(item.value, item.parts, session);
43
+ const original = nativeInteger(item.value, '_supercode_message_position');
44
+ if (original != null) {
45
+ const message = session.messages.slice(before).find((message) => message.role !== 'tool');
46
+ if (message != null) message.metadata[OPENCODE_ORIGINAL_POSITION] = String(original);
47
+ }
48
+ for (let index = before; index < session.messages.length; index++) if (tail != null && at < tail && session.messages[index].metadata.is_summary !== 'true') session.messages[index].metadata.compacted_out = 'true';
49
+ });
50
+ delete session.firstSystemSeen;
51
+ const slots = [], marked = [];
52
+ session.messages.forEach((message, at) => { if (Object.hasOwn(message.metadata, OPENCODE_ORIGINAL_POSITION)) { slots.push(at); marked.push(message); } });
53
+ const position = (message) => {
54
+ const text = message.metadata[OPENCODE_ORIGINAL_POSITION], maximum = 18446744073709551615n;
55
+ if (typeof text !== 'string' || !/^\+?\d+$/.test(text)) return maximum;
56
+ const value = BigInt(text); return value <= maximum ? value : maximum;
57
+ };
58
+ marked.sort((left, right) => byInteger(position(left), position(right)));
59
+ slots.forEach((at, index) => { session.messages[at] = marked[index]; });
60
+ for (const message of session.messages) delete message.metadata[OPENCODE_ORIGINAL_POSITION];
61
+ ensureToolResultsPaired(session.messages);
62
+ session.raw = raw; session.raw_trailing_newline = trailing; session.raw_is_verbatim = verbatim;
63
+ session.parse_error_lines = errors; session.imported_message_count = session.messages.length;
64
+ return session;
65
+ }
66
+
67
+ function fromExport(document) {
68
+ const info = field(document, 'info') ?? null, id = stringField(info, 'id') ?? 'ses_unknown', project = stringField(info, 'projectID') ?? 'global', raw = [], records = [];
69
+ if (info != null) raw.push(opencodeEnvelope(['session', project, id], info));
70
+ for (const entry of document.messages) {
71
+ const value = field(entry, 'info'), messageId = stringField(value, 'id'); if (messageId == null) continue;
72
+ const parts = Array.isArray(field(entry, 'parts')) ? entry.parts.slice() : [];
73
+ raw.push(opencodeEnvelope(['message', id, messageId], value));
74
+ for (const part of parts) raw.push(opencodeEnvelope(['part', messageId, stringField(part, 'id') ?? ''], part));
75
+ records.push(record(messageId, value, parts));
76
+ }
77
+ guard(true, info, records, []); return fromRecords(info, [], records, raw, true, false, 0);
78
+ }
79
+
80
+ /** Both native read surfaces share one canonicalization and residue boundary. */
81
+ export function fromOpenCodeStr(text) {
82
+ const trimmed = nativeTrim(text);
83
+ let document; try { document = parseNativeJson(trimmed); } catch { /* The envelope form is parsed per line. */ }
84
+ if (field(document, 'info') !== undefined && Array.isArray(field(document, 'messages'))) return fromExport(document);
85
+ const capture = splitLinesVerbatim(text), records = [], byId = new Map(), sides = [];
86
+ let info = null, errors = 0;
87
+ for (const line of capture.lines) {
88
+ if (nativeTrim(line) === '') continue;
89
+ let envelope; try { envelope = parseNativeJson(nativeTrim(line)); } catch { errors++; continue; }
90
+ const key = field(envelope, 'key'); if (!Array.isArray(key)) continue;
91
+ const value = field(envelope, 'value') ?? null;
92
+ if (key[0] === 'session') info = value;
93
+ else if (key[0] === 'message') {
94
+ const id = stringField(value, 'id'); if (id == null) continue;
95
+ byId.set(id, records.length); records.push(record(id, value));
96
+ } else if (key[0] === 'part') {
97
+ const at = byId.get(stringField(value, 'messageID')); if (at !== undefined) records[at].parts.push(value);
98
+ } else if (key[0] === 'session_diff' || key[0] === 'todo') sides.push({ key, value });
99
+ }
100
+ guard(trimmed !== '', info, records, sides);
101
+ return fromRecords(info, sides, records, capture.lines, capture.trailingNewline, true, errors);
102
+ }
103
+
104
+ /** Task linkage is provenance only; nesting is a separate session-tree step. */
105
+ export function resolveOpenCodeParentToolUseIds(sessions) {
106
+ const ids = sessions.map((session) => session.meta.session_id);
107
+ for (const session of sessions) {
108
+ const child = session.meta.session_id, parent = session.meta.lineage.parent_session_id;
109
+ if (child == null || parent == null) continue;
110
+ const at = ids.indexOf(parent); if (at === -1) continue;
111
+ for (const message of sessions[at].messages) for (const key of Object.keys(message.metadata).sort(utf8Order)) {
112
+ if (key.startsWith('oc_task_child_session_id__') && message.metadata[key] === child) session.meta.parent_tool_use_id = key.slice('oc_task_child_session_id__'.length);
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,129 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { basename, dirname, extname, join, sep } from 'node:path';
3
+ import { promisify } from 'node:util';
4
+ import * as zlib from 'node:zlib';
5
+ import { withSessionFile, fileFingerprint } from './file-io.mjs';
6
+ import { sourceStat } from './store-registry.mjs';
7
+ import { optionalFile, verifySources, decodeWorkspaceFolder } from './source-files.mjs';
8
+ import { readStore, statement } from './sqlite-reader.mjs';
9
+ import { decodeSessionUtf8 } from './session-file.mjs';
10
+ import { field, stringField, valueString } from './session-codec-utils.mjs';
11
+ import { parseNativeJson } from './native-json.mjs';
12
+ import { nonEmptyLines } from './session-lines.mjs';
13
+ import { deserializeMessage } from './message.mjs';
14
+ import { fromNativeMessages, fromSidecarStr } from './session-native.mjs';
15
+ import { readSessionDocument } from './session-document.mjs';
16
+ import { attachClaudeSubagents } from './session-claude-files.mjs';
17
+ import { readOpenCodeSqlite } from './session-opencode-store.mjs';
18
+ import { readGooseSqlite } from './session-goose.mjs';
19
+ import { readHermesSqlite } from './session-hermes.mjs';
20
+
21
+ export const isCompressedSessionPath = (path) => extname(path) === '.zst';
22
+ export async function sessionReaderText(reader) {
23
+ let bytes = await reader.read(0, reader.size);
24
+ if (isCompressedSessionPath(reader.path)) {
25
+ if (typeof zlib.zstdDecompress !== 'function') throw new Error('compressed transcripts require Node 22.15 or later with zstdDecompress');
26
+ try { bytes = await promisify(zlib.zstdDecompress)(bytes); }
27
+ catch (error) { throw new Error(`${reader.path} is not a readable zstd stream: ${error.message}`, { cause: error }); }
28
+ }
29
+ try { return decodeSessionUtf8(bytes); }
30
+ catch (error) { throw new Error(`${reader.path} is not valid UTF-8 text, and is not a recognized OpenCode SQLite store (no \`SQLite format 3\` header) — supercode reads session logs as UTF-8 JSONL, or an OpenCode \`opencode*.db\` SQLite file`, { cause: error }); }
31
+ }
32
+
33
+ export async function looksLikeSessionSqlite(path, metrics) {
34
+ if (!(await sourceStat(path))?.isFile()) return false;
35
+ if (extname(path) === '.db') return true;
36
+ return (await withSessionFile(path, async (reader) => (await reader.read(0, 16)).equals(Buffer.from('SQLite format 3\0')), metrics)).result;
37
+ }
38
+
39
+ /** Legacy JSON trees are classified so their supported refusal survives;
40
+ * this port does not silently add a direct-write or legacy-reader tier. */
41
+ export async function detectOpenCodeStorageSurface(root, { env = process.env } = {}) {
42
+ if (typeof env.OPENCODE_DB === 'string' && (await sourceStat(env.OPENCODE_DB))?.isFile()) return { kind: 'sqlite', path: env.OPENCODE_DB };
43
+ let names;
44
+ try { names = await readdir(root); } catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; names = []; }
45
+ const candidates = names.filter((name) => name.startsWith('opencode') && name.endsWith('.db')).sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
46
+ if (candidates.length) return { kind: 'sqlite', path: join(root, candidates.includes('opencode.db') ? 'opencode.db' : candidates[0]) };
47
+ if ((await sourceStat(join(root, 'storage', 'migration')))?.isFile()) return { kind: 'json_tree_b', path: join(root, 'storage') };
48
+ if ((await sourceStat(join(root, 'project')))?.isDirectory()) return { kind: 'json_tree_a', path: join(root, 'project') };
49
+ return null;
50
+ }
51
+
52
+ export async function captureGrokPathMetadata(session, path, metrics) {
53
+ const directory = dirname(path);
54
+ session.meta.session_id = directory === '.' || directory === sep ? null : basename(directory);
55
+ const workspace = dirname(directory);
56
+ session.meta.cwd = workspace === '.' || workspace === sep ? null : decodeWorkspaceFolder(basename(workspace));
57
+ const summary = await optionalFile(join(directory, 'summary.json'), async (reader) => {
58
+ const source = decodeSessionUtf8(await reader.read(0, reader.size));
59
+ try { return parseNativeJson(source); } catch { return null; }
60
+ }, metrics);
61
+ const model = stringField(summary.result, 'current_model_id'); if (model != null) session.meta.model = model;
62
+ for (const [source, target] of [['generated_title', 'session_name'], ['created_at', 'created_at'], ['updated_at', 'updated_at'], ['chat_format_version', 'grok_chat_format_version']]) {
63
+ if (field(summary.result, source) !== undefined) session.meta.lineage[target] = valueString(summary.result, source);
64
+ }
65
+ return summary;
66
+ }
67
+
68
+ /** Metadata-file presence chooses the native family; its content is not a
69
+ * caller claim. The sidecar, transcript and metadata identities are verified. */
70
+ export async function loadNativeStoreFamily(path, { metrics } = {}) {
71
+ const name = basename(path, extname(path)), directory = dirname(path), metaPath = join(directory, `${name}.meta.json`), sidecarPath = join(directory, `${name}.sidecar.jsonl`);
72
+ const meta = await sourceStat(metaPath); if (!meta?.isFile()) return null;
73
+ const sidecar = await sourceStat(sidecarPath), sources = [{ path: metaPath, fingerprint: fileFingerprint(meta) }];
74
+ sources.push(sidecar ? { path: sidecarPath, fingerprint: fileFingerprint(sidecar) } : { path: sidecarPath, missing: true });
75
+ const session = (await withSessionFile(sidecar?.isFile() ? sidecarPath : path, async (reader) => {
76
+ const text = decodeSessionUtf8(await reader.read(0, reader.size));
77
+ return sidecar?.isFile() ? fromSidecarStr(text) : fromNativeMessages([...nonEmptyLines(text)].map((line) => deserializeMessage(parseNativeJson(line))));
78
+ }, metrics)).result;
79
+ await verifySources(sources); session.meta.session_id = name; return session;
80
+ }
81
+
82
+ export async function loadSessionPath(path, { fidelity = 'byte_lossless', includeSubagents = true, metrics, env = process.env } = {}) {
83
+ if (!['byte_lossless', 'value_lossless', 'semantic'].includes(fidelity)) throw new TypeError(`unknown session fidelity: ${fidelity}`);
84
+ if ((await sourceStat(path))?.isDirectory()) {
85
+ const surface = await detectOpenCodeStorageSurface(path, { env });
86
+ if (surface?.kind === 'sqlite') return (await readOpenCodeSqlite(surface.path, null, { metrics })).result;
87
+ if (surface) throw new Error(`${path} is an OpenCode data root using a legacy JSON storage tree, which supercode does not load directly — point inspect/convert/resume at the store's opencode*.db SQLite file if this install has one, or use audit --format opencode ${path} instead`);
88
+ throw new Error(`${path} is a directory, but no session file or OpenCode store was found in it (expected an opencode*.db SQLite file, or an OpenCode legacy JSON storage tree)`);
89
+ }
90
+ if (await looksLikeSessionSqlite(path, metrics)) {
91
+ const probe = await readStore(path, (db) => {
92
+ const names = new Set(statement(db, "SELECT name FROM sqlite_master WHERE type='table'").all().map((row) => row.name));
93
+ return ['sessions', 'messages', 'schema_version'].every((name) => names.has(name)) && !names.has('schema_meta');
94
+ }, metrics);
95
+ const session = (await (probe.result ? readHermesSqlite(path, null, { metrics }) : readOpenCodeSqlite(path, null, { metrics }))).result;
96
+ await verifySources(probe.sources); return session;
97
+ }
98
+ return (await withSessionFile(path, async (reader) => {
99
+ const text = await sessionReaderText(reader), session = readSessionDocument(text, { fidelity });
100
+ const sources = [];
101
+ if (session.meta.source === 'claude_code' && includeSubagents) await attachClaudeSubagents(session, path, text, fidelity, metrics);
102
+ if (session.meta.source === 'grok') sources.push(await captureGrokPathMetadata(session, path, metrics));
103
+ if (session.meta.source === 'openclaw' && session.meta.profile == null) {
104
+ const parts = path.split(sep), at = parts.indexOf('agents'); if (at !== -1 && at + 1 < parts.length) session.meta.profile = parts[at + 1];
105
+ }
106
+ await verifySources(sources); return session;
107
+ }, metrics)).result;
108
+ }
109
+
110
+ /** Catalog routing pins a store selector instead of accidentally following
111
+ * its newest row. The locator's other harness spelling does not force a codec. */
112
+ export async function loadSessionLocator(locator, options = {}) {
113
+ const { path, kind, selector } = locator.storage;
114
+ if (locator.harness === 'hermes' && kind === 'file') return (await readHermesSqlite(path, locator.session_id, options)).result;
115
+ if (kind === 'sqlite') return (await (locator.harness === 'goose' ? readGooseSqlite(path, selector, options) : readOpenCodeSqlite(path, selector, options))).result;
116
+ return await loadNativeStoreFamily(path, options) ?? loadSessionPath(path, options);
117
+ }
118
+
119
+ export async function loadLocatorDisplayStore(locator, messageLimit, options = {}) {
120
+ let session;
121
+ if (locator.harness === 'hermes' && locator.storage.kind === 'file') session = (await readHermesSqlite(locator.storage.path, locator.session_id, options)).result;
122
+ else if (locator.storage.kind === 'sqlite') session = (await (locator.harness === 'goose' ? readGooseSqlite(locator.storage.path, locator.storage.selector, { ...options, messageLimit }) : readOpenCodeSqlite(locator.storage.path, locator.storage.selector, options))).result;
123
+ else session = await loadNativeStoreFamily(locator.storage.path, options);
124
+ if (session == null) return null;
125
+ // Catalog store/native display paths use a plain tail; file display paths
126
+ // apply the native two-human-anchor projector separately.
127
+ if (session.messages.length > Math.max(1, messageLimit)) session.messages.splice(0, session.messages.length - Math.max(1, messageLimit));
128
+ return session;
129
+ }
@@ -0,0 +1,129 @@
1
+ import { createMessage, markToolError } from './message.mjs';
2
+ import { nativeInteger, stringifyNativeJsonMember } from './native-json.mjs';
3
+ import { field, stringField, valueString, functionCall } from './session-codec-utils.mjs';
4
+ import { nativeTrim } from './text.mjs';
5
+
6
+ const append = (text, part) => typeof part === 'string' ? text + (text === '' ? '' : '\n') + part : text;
7
+ function stampNativeClock(value, message) {
8
+ const timestamp = nativeInteger(value, 'timestamp');
9
+ if (timestamp != null) message.metadata.pi_msg_timestamp = String(timestamp);
10
+ }
11
+ function retainJson(value, message, source, target) {
12
+ if (field(value, source) != null) message.metadata[target] = stringifyNativeJsonMember(value, source);
13
+ }
14
+
15
+ function contentParts(content) {
16
+ if (typeof content === 'string') return { text: content, parts: null, unknown: false };
17
+ if (!Array.isArray(content)) return { text: '', parts: null, unknown: false };
18
+ let text = '', hasImage = false;
19
+ const parts = [];
20
+ for (const block of content) {
21
+ const kind = stringField(block, 'type');
22
+ if (kind === 'text') text = append(text, stringField(block, 'text'));
23
+ else if (kind === 'image') {
24
+ hasImage = true;
25
+ const mime = stringField(block, 'mimeType'), data = stringField(block, 'data');
26
+ if (mime == null || mime === '' || data == null || data === '' || !/^[A-Za-z0-9+/=]+$/.test(data)) return { text: '', parts: null, unknown: true };
27
+ parts.push({ type: 'image_url', image_url: { url: `data:${mime};base64,${data}` } });
28
+ }
29
+ }
30
+ if (hasImage && nativeTrim(text) !== '') parts.unshift({ type: 'text', text });
31
+ return { text, parts: hasImage ? parts : null, unknown: false };
32
+ }
33
+
34
+ function userContent(value) {
35
+ const content = contentParts(field(value, 'content'));
36
+ if (content.unknown || (nativeTrim(content.text) === '' && content.parts == null)) return null;
37
+ const message = createMessage('user', content.parts == null ? content.text : null);
38
+ message.content_parts = content.parts; return message;
39
+ }
40
+
41
+ function pushAssistant(value, out) {
42
+ let text = '', thinking = '', thinkingSeen = false, thinkingSignature = null, redacted = false, textSignature = null, thoughtSignature = null;
43
+ const calls = [], content = field(value, 'content');
44
+ if (Array.isArray(content)) for (const block of content) {
45
+ const kind = stringField(block, 'type');
46
+ if (kind === 'text') {
47
+ text = append(text, stringField(block, 'text'));
48
+ if (field(block, 'textSignature') !== undefined) textSignature = valueString(block, 'textSignature');
49
+ } else if (kind === 'thinking') {
50
+ thinkingSeen = true; thinking = append(thinking, stringField(block, 'thinking'));
51
+ thinkingSignature = stringField(block, 'thinkingSignature') ?? thinkingSignature;
52
+ if (field(block, 'redacted') === true) redacted = true;
53
+ } else if (kind === 'toolCall') {
54
+ calls.push(functionCall(stringField(block, 'id') ?? '', stringField(block, 'name') ?? '', field(block, 'arguments') === undefined ? '{}' : stringifyNativeJsonMember(block, 'arguments')));
55
+ thoughtSignature = stringField(block, 'thoughtSignature') ?? thoughtSignature;
56
+ }
57
+ }
58
+ const hasText = nativeTrim(text) !== '';
59
+ if (!hasText && !calls.length && !thinkingSeen && stringField(value, 'stopReason') === 'error') return;
60
+ const message = createMessage('assistant', hasText ? text : null); message.tool_calls = calls.length ? calls : null;
61
+ if (!hasText && !calls.length && !thinkingSeen) message.metadata.empty_assistant_record = 'true';
62
+ if (thinkingSeen) message.metadata.thinking = thinking;
63
+ if (thinkingSignature != null) message.metadata.thinking_signature = thinkingSignature;
64
+ if (redacted) message.metadata.pi_thinking_redacted = 'true';
65
+ if (textSignature != null) message.metadata.pi_text_signature = textSignature;
66
+ if (thoughtSignature != null) message.metadata.pi_thought_signature = thoughtSignature;
67
+ for (const [key, source] of [['pi_api', 'api'], ['pi_provider', 'provider'], ['pi_response_model', 'responseModel'], ['pi_response_id', 'responseId'], ['pi_stop_reason', 'stopReason'], ['pi_error_message', 'errorMessage']]) {
68
+ const text = stringField(value, source); if (text != null) message.metadata[key] = text;
69
+ }
70
+ retainJson(value, message, 'diagnostics', 'pi_diagnostics'); retainJson(value, message, 'usage', 'pi_usage');
71
+ stampNativeClock(value, message); out.push(message);
72
+ }
73
+
74
+ function pushTool(value, out) {
75
+ const content = contentParts(field(value, 'content')); if (content.unknown) return;
76
+ const message = createMessage('tool', content.text); message.content_parts = content.parts;
77
+ message.tool_call_id = stringField(value, 'toolCallId') ?? ''; message.name = stringField(value, 'toolName') ?? '';
78
+ retainJson(value, message, 'details', 'pi_tool_details');
79
+ const error = field(value, 'isError') === true; message.metadata.pi_is_error = String(error);
80
+ if (error) markToolError(message);
81
+ stampNativeClock(value, message); out.push(message);
82
+ }
83
+
84
+ function pushBash(value, out) {
85
+ const command = stringField(value, 'command') ?? '', output = stringField(value, 'output') ?? '', exit = nativeInteger(value, 'exitCode', { signed: true });
86
+ const cancelled = field(value, 'cancelled') === true, truncated = field(value, 'truncated') === true;
87
+ let text = `$ ${command}\n${output}`;
88
+ if (exit != null && exit !== 0n) text += `\n[exit code: ${exit}]`;
89
+ if (cancelled) text += '\n[cancelled]'; if (truncated) text += '\n[truncated]';
90
+ const message = createMessage('user', text);
91
+ message.metadata.pi_bash_command = command; message.metadata.pi_bash_output = output;
92
+ if (exit != null) message.metadata.pi_bash_exit_code = String(exit);
93
+ message.metadata.pi_bash_cancelled = String(cancelled); message.metadata.pi_bash_truncated = String(truncated);
94
+ const path = stringField(value, 'fullOutputPath'); if (path != null) message.metadata.pi_bash_full_output_path = path;
95
+ if (field(value, 'excludeFromContext') === true) message.metadata.pi_exclude_from_context = 'true';
96
+ stampNativeClock(value, message); out.push(message);
97
+ }
98
+
99
+ export function pushPiCustom(value, out) {
100
+ if (stringField(value, 'customType') === 'supercode_claude_system') {
101
+ const content = stringField(value, 'content') ?? ''; if (nativeTrim(content) === '') return;
102
+ const message = createMessage('system', content); message.metadata.systemSubtype = stringField(field(value, 'details'), 'claude_system_subtype') ?? 'local_command'; out.push(message); return;
103
+ }
104
+ const message = userContent(value); if (message == null) return;
105
+ const kind = stringField(value, 'customType'); if (kind != null) message.metadata.pi_custom_type = kind;
106
+ const display = field(value, 'display'); if (typeof display === 'boolean') message.metadata.pi_display = String(display);
107
+ retainJson(value, message, 'details', 'pi_details'); stampNativeClock(value, message); out.push(message);
108
+ }
109
+
110
+ export function pushPiMessage(value, out) {
111
+ const role = stringField(value, 'role');
112
+ if (role === 'user') { const message = userContent(value); if (message != null) { stampNativeClock(value, message); out.push(message); } }
113
+ else if (role === 'assistant') pushAssistant(value, out);
114
+ else if (role === 'toolResult') pushTool(value, out);
115
+ else if (role === 'bashExecution') pushBash(value, out);
116
+ else if (role === 'custom') pushPiCustom(value, out);
117
+ }
118
+
119
+ export function pushPiSummary(value, out, kind) {
120
+ const summary = stringField(value, 'summary') ?? ''; if (nativeTrim(summary) === '') return;
121
+ const compaction = kind === 'compaction', message = createMessage('user', `[${compaction ? 'compaction' : 'branch'} summary]\n${summary}`);
122
+ message.metadata.pi_type = kind;
123
+ const key = compaction ? 'firstKeptEntryId' : 'fromId', id = stringField(value, key);
124
+ if (id != null) message.metadata[compaction ? 'pi_first_kept_entry_id' : 'pi_from_id'] = id;
125
+ if (compaction) { const tokens = nativeInteger(value, 'tokensBefore'); if (tokens != null) message.metadata.pi_tokens_before = String(tokens); }
126
+ retainJson(value, message, 'details', 'pi_details');
127
+ if (field(value, 'fromHook') === true) message.metadata.pi_from_hook = 'true';
128
+ out.push(message);
129
+ }
package/session-pi.mjs ADDED
@@ -0,0 +1,86 @@
1
+ import { createSession } from './session-model.mjs';
2
+ import { parseNativeJson, nativeInteger, nativeObjectKeys, stringifyNativeJsonMember } from './native-json.mjs';
3
+ import { splitLinesVerbatim } from './session-lines.mjs';
4
+ import { field, stringField, isObject, valueString, restoreGrokMessageExtension, ensureToolResultsPaired } from './session-codec-utils.mjs';
5
+ import { isToolError, markToolOutcomeUnknown } from './message.mjs';
6
+ import { restoreTopLevelResidue } from './session-native-residue.mjs';
7
+ import { messageMilliseconds } from './session-time.mjs';
8
+ import { openclawNouns } from './openclaw.mjs';
9
+ import { pushPiMessage, pushPiCustom, pushPiSummary } from './session-pi-content.mjs';
10
+ import { nativeTrim } from './text.mjs';
11
+
12
+ function captureHeader(value, meta, openclaw) {
13
+ restoreTopLevelResidue(value, meta);
14
+ meta.session_id = stringField(value, 'id'); meta.cwd = stringField(value, 'cwd');
15
+ meta.lineage.pi_version = String(nativeInteger(value, 'version') ?? 1n);
16
+ const time = stringField(value, 'timestamp'), parent = stringField(value, 'parentSession');
17
+ if (time != null) meta.lineage.created_at = time;
18
+ if (parent != null) meta.lineage.parent_session_path = parent;
19
+ if (!Object.hasOwn(meta.lineage, 'claude_fork_context_ref_raw') && field(value, 'claude_fork_context_ref') !== undefined) meta.lineage.claude_fork_context_ref_raw = stringifyNativeJsonMember(value, 'claude_fork_context_ref');
20
+ if (openclaw && isObject(value)) {
21
+ const nouns = openclawNouns(value, '');
22
+ if (nouns.profile != null) meta.profile ??= nouns.profile;
23
+ if (nouns.trigger != null) { meta.surface = nouns.surface; meta.trigger = nouns.trigger; meta.recurrence = nouns.recurrence ?? null; }
24
+ }
25
+ }
26
+
27
+ function fromPiDialect(text, openclaw) {
28
+ const session = createSession(openclaw ? 'openclaw' : 'pi'), { meta, messages } = session, capture = splitLinesVerbatim(text), values = [], records = new Map();
29
+ session.raw = capture.lines; session.raw_trailing_newline = capture.trailingNewline; session.raw_is_verbatim = true;
30
+ capture.lines.forEach((line, index) => {
31
+ if (nativeTrim(line) === '') return;
32
+ let value; try { value = parseNativeJson(nativeTrim(line)); } catch { session.parse_error_lines++; return; }
33
+ values.push(value);
34
+ const id = stringField(value, 'id'); if (id != null) records.set(id, index);
35
+ });
36
+ if (values.length) captureHeader(values[0], meta, openclaw);
37
+ const entries = [], byId = new Map();
38
+ for (const value of values.slice(1)) {
39
+ const id = stringField(value, 'id'); if (id == null) continue;
40
+ byId.set(id, entries.length); entries.push({ id, parent: stringField(value, 'parentId'), value });
41
+ }
42
+ if (!entries.length) { session.imported_message_count = 0; return session; }
43
+ const defaultLeaf = openclaw ? entries.findLastIndex(({ value }) => stringField(value, 'type') !== 'leaf' && stringField(value, 'appendMode') !== 'side') : entries.length - 1;
44
+ const redirect = openclaw ? entries.findLast(({ value }) => stringField(value, 'type') === 'leaf') : null;
45
+ const target = byId.get(stringField(redirect?.value, 'targetId'));
46
+ const leaf = target ?? (defaultLeaf === -1 ? entries.length - 1 : defaultLeaf), active = [];
47
+ let cursor = entries[leaf].id;
48
+ while (cursor != null) {
49
+ const index = byId.get(cursor); if (index === undefined) break;
50
+ active.push(index); cursor = entries[index].parent;
51
+ if (active.length > entries.length + 1) break;
52
+ }
53
+ active.reverse();
54
+ const positions = new Map(); active.forEach((index, position) => positions.set(entries[index].id, position));
55
+ let keptFrom = 0, model = null;
56
+ for (const index of active) {
57
+ const value = entries[index].value;
58
+ if (stringField(value, 'type') === 'compaction') keptFrom = Math.max(keptFrom, positions.get(stringField(value, 'firstKeptEntryId')) ?? 0);
59
+ }
60
+ active.forEach((index, position) => {
61
+ const entry = entries[index], value = entry.value, kind = stringField(value, 'type'), nested = field(value, 'message'), before = messages.length;
62
+ if (kind === 'message') { pushPiMessage(nested, messages); if (stringField(nested, 'role') === 'assistant') model = stringField(nested, 'model') ?? model; }
63
+ else if (kind === 'custom_message') pushPiCustom(value, messages);
64
+ else if (kind === 'compaction' || kind === 'branch_summary') pushPiSummary(value, messages, kind);
65
+ else if (kind === 'model_change') model = stringField(value, 'modelId') ?? model;
66
+ else if (kind === 'session_info') { const name = stringField(value, 'name'); if (name != null && name !== '') meta.lineage.session_name = name; }
67
+ const timestamp = stringField(value, 'timestamp'), nativeTimestamp = nativeInteger(nested, 'timestamp'), vendor = field(nested, '__openclaw');
68
+ for (let at = before; at < messages.length; at++) {
69
+ const message = messages[at]; message.metadata.pi_entry_id = entry.id;
70
+ if (openclaw && isObject(vendor)) for (const key of nativeObjectKeys(vendor)) message.metadata[`openclaw_${key}`] = valueString(vendor, key);
71
+ if (entry.parent != null) message.metadata.pi_parent_id = entry.parent;
72
+ if (timestamp != null && !Object.hasOwn(message.metadata, 'timestamp')) message.metadata.timestamp = timestamp;
73
+ if (nativeTimestamp != null && !Object.hasOwn(message.metadata, 'timestamp')) message.metadata.timestamp = messageMilliseconds(BigInt.asIntN(64, nativeTimestamp));
74
+ if (!['compaction', 'branch_summary'].includes(kind) && position < keptFrom) message.metadata.compacted_out = 'true';
75
+ }
76
+ if (messages.length === before + 1) restoreGrokMessageExtension(value, messages[before]);
77
+ for (let at = before; at < messages.length; at++) if (!isToolError(messages[at]) && stringField(value, '_supercode_tool_outcome') === 'unknown') markToolOutcomeUnknown(messages[at]);
78
+ });
79
+ meta.model = model; ensureToolResultsPaired(messages);
80
+ meta.message_records = messages.map((message) => records.get(message.metadata.pi_entry_id) ?? null); session.imported_message_count = messages.length;
81
+ return session;
82
+ }
83
+
84
+ export function fromPiStr(text) { return fromPiDialect(text, false); }
85
+ /** OpenClaw's read-only Pi dialect; session-key references remain inert. */
86
+ export function fromOpenClawStr(text) { return fromPiDialect(text, true); }
@@ -0,0 +1,12 @@
1
+ import type { ReadLocator, SessionFollowOptions } from './session-codecs.js';
2
+ import { SessionFollower } from './session-codecs.js';
3
+ import type { ProjectedSessionResult, NormalizedSession, RecordedSessionProfile, RecordedSessionProfileChange } from './session-model.js';
4
+ export const SESSION_READ_METHODS: readonly string[];
5
+ export const SESSION_READ_EVENT: 'harness.v1.sessions.event';
6
+ export const SESSION_READ_CAPABILITY: Readonly<{ version: 1; owner: 'caller'; language: 'typescript'; native_parser: false }>;
7
+ export interface SessionReadContext { cwd?: string; env?: Record<string, string | undefined> }
8
+ export function parseSessionWire(text: string): unknown;
9
+ export function stringifySessionWire(value: unknown): string;
10
+ export function parseSessionReadParams(params: unknown, context?: SessionReadContext): { locator: ReadLocator; fidelity: string; view: (SessionFollowOptions & { fromByte: number | null }) | null; options: import('./session-model.js').SessionLoadOptions | null };
11
+ export function loadSessionRead(params: unknown, context?: SessionReadContext): Promise<ProjectedSessionResult | { session: NormalizedSession & { resolved_profile: RecordedSessionProfile; resolved_profile_history: RecordedSessionProfileChange[] } }>;
12
+ export function openSessionReadFollower(params: unknown, context?: SessionReadContext): Promise<SessionFollower>;
@@ -0,0 +1,104 @@
1
+ // Caller-owned public read operations. Parsing never goes to a native service
2
+ // or to a machine daemon carrying another caller's home/environment.
3
+ import { resolve } from 'node:path';
4
+ import { nativeInteger, nativeNumberKind, parseNativeJson, stringifyNativeJson } from './native-json.mjs';
5
+ import { loadSessionLocator } from './session-path.mjs';
6
+ import { loadSessionDisplayLocator, loadSessionDisplayFrom } from './session-display.mjs';
7
+ import { withClaudeReadIndex, supportsClaudeReadIndex } from './session-claude-index.mjs';
8
+ import { SessionFollower } from './session-follower.mjs';
9
+ import { normalizedSessionJson } from './session-model.mjs';
10
+ import { boundSessionView, projectedMessageWindow, projectedSessionJson, projectedSessionResult, projectedSessionSummary, recordedSessionProfile, recordedSessionProfileHistory } from './session-views.mjs';
11
+
12
+ export const SESSION_READ_METHODS = Object.freeze(['harness.v1.sessions.load', 'harness.v1.sessions.follow', 'harness.v1.sessions.unfollow']);
13
+ export const SESSION_READ_EVENT = 'harness.v1.sessions.event';
14
+ export const SESSION_READ_CAPABILITY = Object.freeze({ version: 1, owner: 'caller', language: 'typescript', native_parser: false });
15
+ export { parseNativeJson as parseSessionWire, stringifyNativeJson as stringifySessionWire };
16
+ const invalid = (message) => { throw Object.assign(new Error(message), { rpcCode: -32602 }); };
17
+ const object = (value, name) => {
18
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) invalid(`${name} must be an object`);
19
+ return value;
20
+ };
21
+ const string = (value, name) => { if (typeof value !== 'string') invalid(`${name} must be a string`); return value; };
22
+ function boolean(parent, key, fallback, nullable = false) {
23
+ if (!Object.hasOwn(parent, key) || nullable && parent[key] == null) return fallback;
24
+ if (typeof parent[key] !== 'boolean') invalid(`${key} must be a boolean`);
25
+ return parent[key];
26
+ }
27
+ function unsigned(parent, key, maximum = Number.MAX_SAFE_INTEGER) {
28
+ if (parent[key] == null) return null;
29
+ const exact = nativeInteger(parent, key);
30
+ if (exact != null) return Number(exact > BigInt(maximum) ? BigInt(maximum) : exact);
31
+ if (nativeNumberKind(parent, key) != null) invalid(`${key} must be an unsigned 64-bit integer`);
32
+ if (!Number.isSafeInteger(parent[key]) || parent[key] < 0) invalid(`${key} must be an unsigned 64-bit integer`);
33
+ return Math.min(parent[key], maximum);
34
+ }
35
+
36
+ export function parseSessionReadParams(params, { cwd = process.cwd() } = {}) {
37
+ object(params, 'session read params'); const supplied = object(params.locator, 'locator'), storage = object(supplied.storage, 'locator.storage');
38
+ string(supplied.harness, 'locator.harness'); // HarnessId is extensible, including third-party names.
39
+ if (!['file', 'sqlite'].includes(storage.kind)) invalid('locator.storage.kind must be file or sqlite');
40
+ const locator = { harness: supplied.harness, session_id: string(supplied.session_id, 'locator.session_id'),
41
+ storage: { kind: storage.kind, path: resolve(cwd, string(storage.path, 'locator.storage.path')) } };
42
+ if (storage.kind === 'sqlite') locator.storage.selector = string(storage.selector, 'locator.storage.selector');
43
+ const fidelity = params.fidelity ?? 'semantic';
44
+ if (!['semantic', 'value_lossless', 'byte_lossless'].includes(fidelity)) invalid(`unknown fidelity: ${fidelity}`);
45
+ let view = null;
46
+ if (params.view != null) {
47
+ const suppliedView = object(params.view, 'view');
48
+ view = { includeSubagents: boolean(suppliedView, 'include_subagents', false), displayHistory: boolean(suppliedView, 'display_history', false),
49
+ messageLimit: unsigned(suppliedView, 'tail_messages', 5000), maxMessageChars: unsigned(suppliedView, 'max_message_chars', 64000),
50
+ fromByte: unsigned(suppliedView, 'from_byte') };
51
+ if (view.messageLimit != null) view.messageLimit = Math.max(1, view.messageLimit);
52
+ if (view.maxMessageChars != null) view.maxMessageChars = Math.max(256, view.maxMessageChars);
53
+ }
54
+ let options = null;
55
+ if (params.options != null) {
56
+ const suppliedOptions = object(params.options, 'options');
57
+ const inline = Object.hasOwn(suppliedOptions, 'inline_media') ? suppliedOptions.inline_media : 'full'; if (!['full', 'metadata'].includes(inline)) invalid('inline_media must be full or metadata');
58
+ options = { include_subagents: boolean(suppliedOptions, 'include_subagents', null, true), inline_media: inline };
59
+ for (const key of ['max_inline_media_bytes', 'message_limit', 'message_offset', 'message_tail']) options[key] = unsigned(suppliedOptions, key);
60
+ if (options.message_tail != null && (options.message_limit != null || options.message_offset != null)) invalid('sessions.load options.message_tail cannot be combined with message_limit or message_offset');
61
+ }
62
+ return { locator, fidelity, view, options };
63
+ }
64
+
65
+ async function indexedWindow(locator, options, context) {
66
+ if (locator.harness !== 'claude-code' || options.include_subagents !== false || locator.storage.kind !== 'file'
67
+ || !await supportsClaudeReadIndex(locator.storage.path, context)) return null;
68
+ return (await withClaudeReadIndex(locator.storage.path, async (index) => {
69
+ const total = index.length, [offset, end] = projectedMessageWindow(total, options);
70
+ const indices = (function* () { for (let at = offset; at < end; at++) yield at; })();
71
+ const session = await index.readMessages(indices), summary = await index.readSummary();
72
+ const selected = projectedSessionJson(session, { ...options, message_offset: null, message_limit: null, message_tail: null });
73
+ selected.raw_record_count = index.rawRecordCount;
74
+ return { session: selected, summary: projectedSessionSummary(summary, options),
75
+ window: { has_more: offset > 0 || end < total, has_newer: end < total, has_older: offset > 0,
76
+ newer_items: index.itemCount(end, total), offset, older_items: index.itemCount(0, offset), returned: end - offset, total_messages: total } };
77
+ }, { ...context, fidelity: 'byte_lossless' })).result;
78
+ }
79
+
80
+ export async function loadSessionRead(params, context = {}) {
81
+ const { locator, fidelity, view, options } = parseSessionReadParams(params, context);
82
+ if (options != null) {
83
+ const indexed = await indexedWindow(locator, options, context); if (indexed != null) return indexed;
84
+ // The exact options lane is the historical lossless load, independently
85
+ // of the read-view fidelity knob. Projection happens after reconstruction.
86
+ return projectedSessionResult(await loadSessionLocator(locator, { ...context, fidelity: 'byte_lossless' }), options);
87
+ }
88
+ const readOptions = { ...context, fidelity, includeSubagents: view?.includeSubagents ?? true };
89
+ const session = view?.displayHistory
90
+ ? view.fromByte != null ? await loadSessionDisplayFrom(locator, view.fromByte, readOptions)
91
+ : await loadSessionDisplayLocator(locator, view.messageLimit ?? 500, readOptions)
92
+ : await loadSessionLocator(locator, readOptions);
93
+ if (view?.fromByte == null) boundSessionView(session, view?.messageLimit, view?.maxMessageChars);
94
+ return { session: { ...normalizedSessionJson(session), resolved_profile: recordedSessionProfile(session),
95
+ resolved_profile_history: recordedSessionProfileHistory(session) } };
96
+ }
97
+ export async function openSessionReadFollower(params, context = {}) {
98
+ object(params, 'session read params');
99
+ // Follow's native grammar ignores load-only options, including unknown
100
+ // fields. Only its LocatorParams fields participate in admission.
101
+ const { locator, fidelity, view } = parseSessionReadParams({ locator: params.locator, fidelity: params.fidelity, view: params.view }, context);
102
+ return SessionFollower.openLocator(locator, { ...context, fidelity, includeSubagents: view?.includeSubagents ?? true,
103
+ displayHistory: view?.displayHistory ?? false, messageLimit: view?.messageLimit, maxMessageChars: view?.maxMessageChars });
104
+ }
@@ -0,0 +1,30 @@
1
+ import { parseNativeJson } from './native-json.mjs';
2
+ import { field, stringField } from './session-codec-utils.mjs';
3
+ import { nonEmptyLines } from './session-lines.mjs';
4
+ import { nativeTrim } from './text.mjs';
5
+
6
+ /** Source provenance is recovered structurally; discussing a harness never
7
+ * selects its dialect. Corrupt lines are tolerated by the native detector. */
8
+ export function detectSessionSource(text) {
9
+ let document; try { document = parseNativeJson(nativeTrim(text)); } catch { /* JSONL is considered below. */ }
10
+ if (Array.isArray(field(document, 'conversation')) && (field(document, 'working_dir') !== undefined || field(document, 'workingDir') !== undefined)) return 'goose';
11
+ if (field(document, 'info') !== undefined && Array.isArray(field(document, 'messages'))) return 'opencode';
12
+ for (const line of nonEmptyLines(text)) {
13
+ let value; try { value = parseNativeJson(line); } catch { continue; }
14
+ if (Array.isArray(field(value, 'key')) && field(value, 'value') !== undefined) return 'opencode';
15
+ if (field(value, 'payload') !== undefined) return 'codex';
16
+ const kind = stringField(value, 'type');
17
+ if (stringField(value, 'sessionId') != null && ['projectHash', 'startTime', 'lastUpdated'].some((key) => field(value, key) !== undefined) && field(value, 'type') === undefined) return 'gemini';
18
+ if (kind === 'gemini' && field(value, 'content') !== undefined) return 'gemini';
19
+ if (['message', 'uuid', 'sessionId'].every((key) => field(value, key) === undefined) && ['system', 'user', 'assistant', 'tool_result', 'reasoning', 'backend_tool_call'].includes(kind) && ['content', 'tool_calls', 'tool_call_id', 'encrypted_content', 'kind'].some((key) => field(value, key) !== undefined)) return 'grok';
20
+ if (kind === 'session' && stringField(value, 'id') != null && field(value, 'message') === undefined && field(value, 'uuid') === undefined) {
21
+ for (const body of nonEmptyLines(text)) {
22
+ let entry; try { entry = parseNativeJson(body); } catch { continue; }
23
+ if ((stringField(entry, 'type') === 'leaf' && field(entry, 'targetId') !== undefined) || (stringField(entry, 'type') === 'message' && field(field(entry, 'message'), '__openclaw') !== undefined)) return 'openclaw';
24
+ }
25
+ return 'pi';
26
+ }
27
+ if (field(value, 'type') !== undefined || field(value, 'message') !== undefined) return 'claude_code';
28
+ }
29
+ return null;
30
+ }