@tangle-network/harness-sessions 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -2
  3. package/dist/builder.d.ts +116 -0
  4. package/dist/builder.js +332 -0
  5. package/dist/capture.d.ts +44 -0
  6. package/dist/capture.js +210 -0
  7. package/dist/catalog.d.ts +163 -0
  8. package/dist/catalog.js +163 -0
  9. package/dist/cli.d.ts +2 -0
  10. package/dist/cli.js +115 -0
  11. package/dist/formats/claude-code.d.ts +9 -0
  12. package/dist/formats/claude-code.js +293 -0
  13. package/dist/formats/codex.d.ts +8 -0
  14. package/dist/formats/codex.js +379 -0
  15. package/dist/formats/factory.d.ts +13 -0
  16. package/dist/formats/factory.js +155 -0
  17. package/dist/formats/fold.d.ts +13 -0
  18. package/dist/formats/fold.js +5 -0
  19. package/dist/formats/head.d.ts +5 -0
  20. package/dist/formats/head.js +23 -0
  21. package/dist/formats/kimi.d.ts +9 -0
  22. package/dist/formats/kimi.js +205 -0
  23. package/dist/formats/opencode.d.ts +4 -0
  24. package/dist/formats/opencode.js +241 -0
  25. package/dist/formats/pi.d.ts +8 -0
  26. package/dist/formats/pi.js +201 -0
  27. package/dist/index.d.ts +27 -0
  28. package/dist/index.js +16 -0
  29. package/dist/input.d.ts +18 -0
  30. package/dist/input.js +46 -0
  31. package/dist/projections/chat.d.ts +31 -0
  32. package/dist/projections/chat.js +79 -0
  33. package/dist/projections/otlp.d.ts +12 -0
  34. package/dist/projections/otlp.js +91 -0
  35. package/dist/projections/turns.d.ts +51 -0
  36. package/dist/projections/turns.js +136 -0
  37. package/dist/registry.d.ts +11 -0
  38. package/dist/registry.js +39 -0
  39. package/dist/schema.d.ts +270 -0
  40. package/dist/schema.js +10 -0
  41. package/dist/source.d.ts +67 -0
  42. package/dist/source.js +254 -0
  43. package/dist/stores.d.ts +7 -0
  44. package/dist/stores.js +46 -0
  45. package/package.json +53 -3
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Reading the native sessions in a capture.
3
+ *
4
+ * The sidecar's raw-evidence archive v2 lists each execution's native session copies in
5
+ * `nativeSessions[]`, with the harness, the session id recorded at spawn, the copy kind and the
6
+ * files. A capture taken before v2 (provider-tangle's retention layout, `tangle-native-session-
7
+ * evidence.v1`) has no such list: it holds each execution's private runtime HOME under
8
+ * `__retention__/sessions/<sandbox session>/native/<source>/<root scope>/`, and its manifest names
9
+ * the harness and, once an attempt settled, the native session ids. Both are read through the
10
+ * store layouts the readers declare; no consumer matches session paths itself.
11
+ */
12
+ import { readdir, readFile } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { isRecord, str } from './builder.js';
15
+ import { opencodeSessionsInStore } from './formats/opencode.js';
16
+ import { maybeReaderFor, readerFor, readerForFormat } from './registry.js';
17
+ import { isMissing } from './source.js';
18
+ import { storeGlob } from './stores.js';
19
+ export const RETENTION_SESSIONS = join('__retention__', 'sessions');
20
+ export const RETENTION_MANIFEST_KIND = 'tangle-native-session-evidence.v1';
21
+ async function readJson(path) {
22
+ try {
23
+ return JSON.parse(await readFile(path, 'utf8'));
24
+ }
25
+ catch (error) {
26
+ if (isMissing(error))
27
+ return null;
28
+ throw error;
29
+ }
30
+ }
31
+ async function dirs(path) {
32
+ try {
33
+ return (await readdir(path, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
34
+ }
35
+ catch (error) {
36
+ if (isMissing(error))
37
+ return [];
38
+ throw error;
39
+ }
40
+ }
41
+ /** Sessions listed by a v2 archive manifest. Files are resolved against `archiveDir`. */
42
+ async function fromV2(archiveDir, manifest, listing) {
43
+ for (const copy of manifest.nativeSessions) {
44
+ const reader = maybeReaderFor(copy.harness) ?? readerForFormatSafe(copy.format);
45
+ if (!reader) {
46
+ listing.missing.push({ sandboxSessionId: null, harness: copy.harness, reason: 'no_reader_for_harness', nativeSessionId: copy.nativeSessionId });
47
+ continue;
48
+ }
49
+ const store = reader.stores.find((s) => s.format === copy.format) ?? reader.stores[0];
50
+ const inStore = (path) => {
51
+ const marker = `${store.root}/`;
52
+ const index = path.indexOf(marker);
53
+ return index === -1 ? null : path.slice(index + marker.length);
54
+ };
55
+ const truncated = copy.files.some((f) => f.truncated);
56
+ if (store.shared) {
57
+ const db = copy.files.find((f) => inStore(storePathOf(f)) === store.files[0]);
58
+ if (!db) {
59
+ listing.missing.push({ sandboxSessionId: null, harness: copy.harness, reason: 'store_file_missing', nativeSessionId: copy.nativeSessionId });
60
+ continue;
61
+ }
62
+ const path = join(archiveDir, db.path);
63
+ const refs = await opencodeSessionsInStore(path, null, {});
64
+ const ids = new Set([copy.nativeSessionId]);
65
+ // Child sessions of the execution's session live in the same store.
66
+ for (const ref of refs)
67
+ if (ref.parentNativeSessionId && ids.has(ref.parentNativeSessionId))
68
+ ids.add(ref.nativeSessionId);
69
+ for (const ref of refs.filter((r) => ids.has(r.nativeSessionId))) {
70
+ listing.sessions.push(captured({ ...ref, truncated }, copy, ref.nativeSessionId === copy.nativeSessionId, archiveDir, path));
71
+ }
72
+ if (!refs.some((r) => r.nativeSessionId === copy.nativeSessionId)) {
73
+ listing.missing.push({ sandboxSessionId: null, harness: copy.harness, reason: 'native_session_not_in_store', nativeSessionId: copy.nativeSessionId });
74
+ }
75
+ continue;
76
+ }
77
+ const main = storeGlob(reader.globs.session, copy.nativeSessionId);
78
+ const nested = reader.globs.children ? [storeGlob(reader.globs.children, copy.nativeSessionId)] : [];
79
+ const mainFile = copy.files.find((f) => { const rel = inStore(storePathOf(f)); return rel !== null && main.test(rel); });
80
+ if (!mainFile) {
81
+ listing.missing.push({ sandboxSessionId: null, harness: copy.harness, reason: 'native_session_file_missing', nativeSessionId: copy.nativeSessionId });
82
+ continue;
83
+ }
84
+ const mainPath = join(archiveDir, mainFile.path);
85
+ const nestedFiles = copy.files.filter((f) => { const rel = inStore(storePathOf(f)); return rel !== null && nested.some((n) => n.test(rel)); }).map((f) => join(archiveDir, f.path));
86
+ listing.sessions.push(captured({ harness: reader.harness, format: store.format, nativeSessionId: copy.nativeSessionId, path: mainPath, files: [mainPath, ...nestedFiles], home: null, cwd: null, mtimeMs: Date.parse(copy.capturedAt) || 0, parentNativeSessionId: null, truncated: mainFile.truncated }, copy, true, archiveDir, mainPath));
87
+ for (const file of nestedFiles) {
88
+ const child = file.split('/').pop().replace(/\.jsonl$/u, '').replace(/^agent-/u, '');
89
+ listing.sessions.push(captured({ harness: reader.harness, format: store.format, nativeSessionId: child, path: file, files: [file], home: null, cwd: null, mtimeMs: Date.parse(copy.capturedAt) || 0, parentNativeSessionId: copy.nativeSessionId, truncated }, copy, false, archiveDir, file));
90
+ }
91
+ }
92
+ }
93
+ /**
94
+ * The file's path relative to the runtime HOME, which the store globs match. The sidecar's archive
95
+ * stores payloads under flat member names (`path`) and names the HOME path in `storePath`; a
96
+ * capture laid out like the HOME carries it in `path` itself.
97
+ */
98
+ function storePathOf(file) {
99
+ return file.storePath ?? file.path;
100
+ }
101
+ function readerForFormatSafe(format) {
102
+ try {
103
+ return readerForFormat(format);
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ function captured(ref, copy, recordedId, archiveDir, path) {
110
+ return {
111
+ ref,
112
+ harness: ref.harness,
113
+ sandboxSessionId: null,
114
+ executionId: copy.executionId,
115
+ attempt: copy.attempt,
116
+ copy: copy.copy,
117
+ recordedId,
118
+ root: path.startsWith(archiveDir) ? path.slice(archiveDir.length + 1) : path,
119
+ sourceId: null,
120
+ rootScope: null,
121
+ };
122
+ }
123
+ /** Sessions in provider-tangle's retention layout, found through each reader's declared stores. */
124
+ async function fromRetention(captureDir, listing, fallbackHarness) {
125
+ const sessionsDir = join(captureDir, RETENTION_SESSIONS);
126
+ for (const sandboxSessionId of await dirs(sessionsDir)) {
127
+ const dir = join(sessionsDir, sandboxSessionId);
128
+ const manifest = await readJson(join(dir, 'raw-manifest.json'));
129
+ const source = isRecord(manifest) && isRecord(manifest.source) ? manifest.source : null;
130
+ if (isRecord(manifest) && Array.isArray(manifest.nativeSessions)) {
131
+ await fromV2(dir, manifest, listing);
132
+ continue;
133
+ }
134
+ // The manifest names the harness; a capture whose manifest does not falls back to the
135
+ // harness the caller ran (the profile's), never to guessing from paths.
136
+ const harness = (source ? str(source.backendType) : null) ?? fallbackHarness;
137
+ const reader = harness ? maybeReaderFor(harness) : undefined;
138
+ if (!reader) {
139
+ listing.missing.push({ sandboxSessionId, harness, reason: harness ? 'no_reader_for_harness' : 'harness_not_recorded' });
140
+ continue;
141
+ }
142
+ const attempts = source && Array.isArray(source.attempts) ? source.attempts.filter(isRecord) : [];
143
+ const recorded = new Map();
144
+ for (const attempt of attempts) {
145
+ for (const id of Array.isArray(attempt.nativeSessionIds) ? attempt.nativeSessionIds : []) {
146
+ if (typeof id === 'string')
147
+ recorded.set(id, { executionId: str(attempt.executionId), attempt: typeof attempt.ordinal === 'number' ? attempt.ordinal : null });
148
+ }
149
+ }
150
+ if (source && typeof source.nativeSessionId === 'string' && !recorded.has(source.nativeSessionId)) {
151
+ recorded.set(source.nativeSessionId, { executionId: null, attempt: null });
152
+ }
153
+ const lastAttempt = attempts[attempts.length - 1];
154
+ const found = new Set();
155
+ // native/<source id>/<root scope>/ is one execution's runtime HOME (or its workspace).
156
+ for (const sourceId of await dirs(join(dir, 'native'))) {
157
+ for (const rootScope of await dirs(join(dir, 'native', sourceId))) {
158
+ const home = join(dir, 'native', sourceId, rootScope);
159
+ let refs;
160
+ try {
161
+ refs = await reader.locate(home);
162
+ }
163
+ catch (error) {
164
+ // A store that cannot be opened (a corrupt SQLite file) proves nothing about the turn.
165
+ const store = reader.stores[0];
166
+ listing.missing.push({ sandboxSessionId, harness: reader.harness, reason: 'store_unreadable', root: home.slice(captureDir.length + 1), path: store.shared ? `${store.root}/${store.files[0]}` : store.root, detail: error instanceof Error ? error.message : String(error) });
167
+ continue;
168
+ }
169
+ for (const ref of refs) {
170
+ const join_ = recorded.get(ref.nativeSessionId) ?? (ref.parentNativeSessionId ? recorded.get(ref.parentNativeSessionId) : undefined);
171
+ found.add(ref.nativeSessionId);
172
+ listing.sessions.push({
173
+ ref,
174
+ harness: reader.harness,
175
+ sandboxSessionId,
176
+ executionId: join_?.executionId ?? (lastAttempt ? str(lastAttempt.executionId) : null) ?? sourceId,
177
+ attempt: join_?.attempt ?? (lastAttempt && typeof lastAttempt.ordinal === 'number' ? lastAttempt.ordinal : null),
178
+ copy: 'retained',
179
+ recordedId: recorded.has(ref.nativeSessionId),
180
+ root: home.slice(captureDir.length + 1),
181
+ sourceId,
182
+ rootScope,
183
+ });
184
+ }
185
+ }
186
+ }
187
+ for (const id of recorded.keys()) {
188
+ if (!found.has(id))
189
+ listing.missing.push({ sandboxSessionId, harness: reader.harness, reason: 'native_session_not_in_copy', nativeSessionId: id });
190
+ }
191
+ }
192
+ }
193
+ /**
194
+ * Every native session in a capture: from `manifest.nativeSessions` when a v2 manifest is given
195
+ * (files relative to `dir`), otherwise from the retention layout under `dir`.
196
+ */
197
+ export async function listCapture(dir, manifest, options = {}) {
198
+ const listing = { sessions: [], missing: [] };
199
+ if (manifest && Array.isArray(manifest.nativeSessions))
200
+ await fromV2(dir, manifest, listing);
201
+ else
202
+ await fromRetention(dir, listing, options.harness ?? null);
203
+ return listing;
204
+ }
205
+ /** Every native session in a capture, read in full. */
206
+ export async function* readCapture(dir, manifest, options = {}) {
207
+ const listing = await listCapture(dir, manifest, options);
208
+ for (const session of listing.sessions)
209
+ yield await readerFor(session.harness).read(session.ref);
210
+ }
@@ -0,0 +1,163 @@
1
+ export declare const SESSION_FORMAT_IDS: readonly ["claude-code.projects-jsonl", "codex.rollout-jsonl", "opencode.sqlite", "pi.session-jsonl", "kimi.session-dir", "factory.session-jsonl", "gemini.chat-json", "amp.thread-json", "forge.sqlite", "hermes.state-sqlite", "prime.session-jsonl", "openclaw.session-jsonl"];
2
+ /** Store layouts, relative to the HOME a harness ran with. */
3
+ export declare const STORES: {
4
+ readonly 'claude-code.projects-jsonl': {
5
+ readonly root: '.claude/projects';
6
+ readonly files: readonly ["*/{id}.jsonl", "*/{id}/subagents/**/*.jsonl"];
7
+ readonly write: 'jsonl-append';
8
+ readonly shared: false;
9
+ readonly format: 'claude-code.projects-jsonl';
10
+ };
11
+ readonly 'codex.rollout-jsonl': {
12
+ readonly root: '.codex/sessions';
13
+ readonly files: readonly ["*/*/*/rollout-*-{id}.jsonl"];
14
+ readonly write: 'jsonl-append';
15
+ readonly shared: false;
16
+ readonly format: 'codex.rollout-jsonl';
17
+ };
18
+ readonly 'opencode.sqlite': {
19
+ readonly root: '.local/share/opencode';
20
+ readonly files: readonly ["opencode.db", "opencode.db-wal", "opencode.db-shm"];
21
+ readonly write: 'sqlite-wal';
22
+ readonly shared: true;
23
+ readonly format: 'opencode.sqlite';
24
+ };
25
+ readonly 'pi.session-jsonl': {
26
+ readonly root: '.pi/agent/sessions';
27
+ readonly files: readonly ["**/*_{id}.jsonl"];
28
+ readonly write: 'jsonl-append';
29
+ readonly shared: false;
30
+ readonly format: 'pi.session-jsonl';
31
+ };
32
+ readonly 'kimi.session-dir': {
33
+ readonly root: '.kimi/sessions';
34
+ readonly files: readonly ["*/{id}/**", "*/{id}.jsonl"];
35
+ readonly write: 'jsonl-append';
36
+ readonly shared: false;
37
+ readonly format: 'kimi.session-dir';
38
+ };
39
+ readonly 'factory.session-jsonl': {
40
+ readonly root: '.factory/sessions';
41
+ readonly files: readonly ["*/{id}.jsonl", "*/{id}.settings.json"];
42
+ readonly write: 'jsonl-append';
43
+ readonly shared: false;
44
+ readonly format: 'factory.session-jsonl';
45
+ };
46
+ readonly 'gemini.chat-json': {
47
+ readonly root: '.gemini/tmp';
48
+ readonly files: readonly ["*/chats/session-*{id}*.json"];
49
+ readonly write: 'json-rewrite';
50
+ readonly shared: false;
51
+ readonly format: 'gemini.chat-json';
52
+ };
53
+ readonly 'amp.thread-json': {
54
+ readonly root: '.local/share/amp/threads';
55
+ readonly files: readonly ["{id}.json"];
56
+ readonly write: 'json-rewrite';
57
+ readonly shared: false;
58
+ readonly format: 'amp.thread-json';
59
+ };
60
+ readonly 'forge.sqlite': {
61
+ readonly root: '.forge';
62
+ readonly files: readonly [".forge.db", ".forge.db-wal", ".forge.db-shm"];
63
+ readonly write: 'sqlite-wal';
64
+ readonly shared: true;
65
+ readonly format: 'forge.sqlite';
66
+ };
67
+ readonly 'hermes.state-sqlite': {
68
+ readonly root: '.hermes';
69
+ readonly files: readonly ["state.db", "state.db-wal", "state.db-shm"];
70
+ readonly write: 'sqlite-wal';
71
+ readonly shared: true;
72
+ readonly format: 'hermes.state-sqlite';
73
+ };
74
+ readonly 'prime.session-jsonl': {
75
+ readonly root: '.prime-provider/prime-agent-dirs';
76
+ readonly files: readonly ["**/*_{id}.jsonl"];
77
+ readonly write: 'jsonl-append';
78
+ readonly shared: false;
79
+ readonly format: 'prime.session-jsonl';
80
+ };
81
+ readonly 'openclaw.session-jsonl': {
82
+ readonly root: '.openclaw/agents';
83
+ readonly files: readonly ["**/{id}.jsonl"];
84
+ readonly write: 'jsonl-append';
85
+ readonly shared: false;
86
+ readonly format: 'openclaw.session-jsonl';
87
+ };
88
+ };
89
+ /**
90
+ * Where each format's served model, usage and ending error come from. `turn-context` means the
91
+ * model the harness was configured or asked for (Codex `turn_context`, OpenCode's message
92
+ * `modelID`, Factory's settings); `none` means the session records no model at all.
93
+ */
94
+ export declare const EVIDENCE: {
95
+ readonly 'claude-code.projects-jsonl': {
96
+ readonly servedModel: 'provider-response';
97
+ readonly usage: 'per-response';
98
+ readonly endingError: 'session-record';
99
+ };
100
+ readonly 'codex.rollout-jsonl': {
101
+ readonly servedModel: 'turn-context';
102
+ readonly usage: 'cumulative';
103
+ readonly endingError: 'session-record';
104
+ };
105
+ readonly 'opencode.sqlite': {
106
+ readonly servedModel: 'turn-context';
107
+ readonly usage: 'per-response';
108
+ readonly endingError: 'session-record';
109
+ };
110
+ readonly 'pi.session-jsonl': {
111
+ readonly servedModel: 'session-record';
112
+ readonly usage: 'per-response';
113
+ readonly endingError: 'session-record';
114
+ };
115
+ readonly 'kimi.session-dir': {
116
+ readonly servedModel: 'none';
117
+ readonly usage: 'per-response';
118
+ readonly endingError: 'session-record';
119
+ };
120
+ readonly 'factory.session-jsonl': {
121
+ readonly servedModel: 'turn-context';
122
+ readonly usage: 'session-total';
123
+ readonly endingError: 'none';
124
+ };
125
+ readonly 'gemini.chat-json': {
126
+ readonly servedModel: 'session-record';
127
+ readonly usage: 'per-response';
128
+ readonly endingError: 'none';
129
+ };
130
+ readonly 'amp.thread-json': {
131
+ readonly servedModel: 'session-record';
132
+ readonly usage: 'per-response';
133
+ readonly endingError: 'none';
134
+ };
135
+ readonly 'forge.sqlite': {
136
+ readonly servedModel: 'session-record';
137
+ readonly usage: 'per-response';
138
+ readonly endingError: 'none';
139
+ };
140
+ readonly 'hermes.state-sqlite': {
141
+ readonly servedModel: 'session-record';
142
+ readonly usage: 'session-total';
143
+ readonly endingError: 'none';
144
+ };
145
+ readonly 'prime.session-jsonl': {
146
+ readonly servedModel: 'session-record';
147
+ readonly usage: 'per-response';
148
+ readonly endingError: 'session-record';
149
+ };
150
+ readonly 'openclaw.session-jsonl': {
151
+ readonly servedModel: 'session-record';
152
+ readonly usage: 'per-response';
153
+ readonly endingError: 'none';
154
+ };
155
+ };
156
+ /**
157
+ * Compile one store glob. `*` matches within a path segment, `**` matches any number of
158
+ * segments, and `{id}` matches the native session id: the literal id when one is given, any
159
+ * segment text otherwise (the reader then takes the id from the session's own header).
160
+ */
161
+ export declare function storeGlob(glob: string, nativeSessionId?: string): RegExp;
162
+ /** A store root must be a normalized path relative to HOME: not absolute, no `.`/`..` segments. */
163
+ export declare function assertStoreRoot(root: string): void;
@@ -0,0 +1,163 @@
1
+ export const SESSION_FORMAT_IDS = [
2
+ 'claude-code.projects-jsonl',
3
+ 'codex.rollout-jsonl',
4
+ 'opencode.sqlite',
5
+ 'pi.session-jsonl',
6
+ 'kimi.session-dir',
7
+ 'factory.session-jsonl',
8
+ 'gemini.chat-json',
9
+ 'amp.thread-json',
10
+ 'forge.sqlite',
11
+ 'hermes.state-sqlite',
12
+ 'prime.session-jsonl',
13
+ 'openclaw.session-jsonl',
14
+ ];
15
+ /** Store layouts, relative to the HOME a harness ran with. */
16
+ export const STORES = {
17
+ 'claude-code.projects-jsonl': {
18
+ root: '.claude/projects',
19
+ files: ['*/{id}.jsonl', '*/{id}/subagents/**/*.jsonl'],
20
+ write: 'jsonl-append',
21
+ shared: false,
22
+ format: 'claude-code.projects-jsonl',
23
+ },
24
+ 'codex.rollout-jsonl': {
25
+ root: '.codex/sessions',
26
+ files: ['*/*/*/rollout-*-{id}.jsonl'],
27
+ write: 'jsonl-append',
28
+ shared: false,
29
+ format: 'codex.rollout-jsonl',
30
+ },
31
+ 'opencode.sqlite': {
32
+ root: '.local/share/opencode',
33
+ files: ['opencode.db', 'opencode.db-wal', 'opencode.db-shm'],
34
+ write: 'sqlite-wal',
35
+ shared: true,
36
+ format: 'opencode.sqlite',
37
+ },
38
+ 'pi.session-jsonl': {
39
+ root: '.pi/agent/sessions',
40
+ files: ['**/*_{id}.jsonl'],
41
+ write: 'jsonl-append',
42
+ shared: false,
43
+ format: 'pi.session-jsonl',
44
+ },
45
+ // A session directory: wire.jsonl (the event stream read here), context.jsonl and its rotated
46
+ // context_N.jsonl copies, and state.json.
47
+ 'kimi.session-dir': {
48
+ root: '.kimi/sessions',
49
+ files: ['*/{id}/**', '*/{id}.jsonl'],
50
+ write: 'jsonl-append',
51
+ shared: false,
52
+ format: 'kimi.session-dir',
53
+ },
54
+ 'factory.session-jsonl': {
55
+ root: '.factory/sessions',
56
+ files: ['*/{id}.jsonl', '*/{id}.settings.json'],
57
+ write: 'jsonl-append',
58
+ shared: false,
59
+ format: 'factory.session-jsonl',
60
+ },
61
+ 'gemini.chat-json': {
62
+ root: '.gemini/tmp',
63
+ files: ['*/chats/session-*{id}*.json'],
64
+ write: 'json-rewrite',
65
+ shared: false,
66
+ format: 'gemini.chat-json',
67
+ },
68
+ 'amp.thread-json': {
69
+ root: '.local/share/amp/threads',
70
+ files: ['{id}.json'],
71
+ write: 'json-rewrite',
72
+ shared: false,
73
+ format: 'amp.thread-json',
74
+ },
75
+ 'forge.sqlite': {
76
+ root: '.forge',
77
+ files: ['.forge.db', '.forge.db-wal', '.forge.db-shm'],
78
+ write: 'sqlite-wal',
79
+ shared: true,
80
+ format: 'forge.sqlite',
81
+ },
82
+ 'hermes.state-sqlite': {
83
+ root: '.hermes',
84
+ files: ['state.db', 'state.db-wal', 'state.db-shm'],
85
+ write: 'sqlite-wal',
86
+ shared: true,
87
+ format: 'hermes.state-sqlite',
88
+ },
89
+ 'prime.session-jsonl': {
90
+ root: '.prime-provider/prime-agent-dirs',
91
+ files: ['**/*_{id}.jsonl'],
92
+ write: 'jsonl-append',
93
+ shared: false,
94
+ format: 'prime.session-jsonl',
95
+ },
96
+ 'openclaw.session-jsonl': {
97
+ root: '.openclaw/agents',
98
+ files: ['**/{id}.jsonl'],
99
+ write: 'jsonl-append',
100
+ shared: false,
101
+ format: 'openclaw.session-jsonl',
102
+ },
103
+ };
104
+ /**
105
+ * Where each format's served model, usage and ending error come from. `turn-context` means the
106
+ * model the harness was configured or asked for (Codex `turn_context`, OpenCode's message
107
+ * `modelID`, Factory's settings); `none` means the session records no model at all.
108
+ */
109
+ export const EVIDENCE = {
110
+ 'claude-code.projects-jsonl': { servedModel: 'provider-response', usage: 'per-response', endingError: 'session-record' },
111
+ 'codex.rollout-jsonl': { servedModel: 'turn-context', usage: 'cumulative', endingError: 'session-record' },
112
+ 'opencode.sqlite': { servedModel: 'turn-context', usage: 'per-response', endingError: 'session-record' },
113
+ 'pi.session-jsonl': { servedModel: 'session-record', usage: 'per-response', endingError: 'session-record' },
114
+ 'kimi.session-dir': { servedModel: 'none', usage: 'per-response', endingError: 'session-record' },
115
+ 'factory.session-jsonl': { servedModel: 'turn-context', usage: 'session-total', endingError: 'none' },
116
+ 'gemini.chat-json': { servedModel: 'session-record', usage: 'per-response', endingError: 'none' },
117
+ 'amp.thread-json': { servedModel: 'session-record', usage: 'per-response', endingError: 'none' },
118
+ 'forge.sqlite': { servedModel: 'session-record', usage: 'per-response', endingError: 'none' },
119
+ 'hermes.state-sqlite': { servedModel: 'session-record', usage: 'session-total', endingError: 'none' },
120
+ 'prime.session-jsonl': { servedModel: 'session-record', usage: 'per-response', endingError: 'session-record' },
121
+ 'openclaw.session-jsonl': { servedModel: 'session-record', usage: 'per-response', endingError: 'none' },
122
+ };
123
+ const REGEX_SPECIALS = /[.+^$()|[\]\\]/gu;
124
+ /**
125
+ * Compile one store glob. `*` matches within a path segment, `**` matches any number of
126
+ * segments, and `{id}` matches the native session id: the literal id when one is given, any
127
+ * segment text otherwise (the reader then takes the id from the session's own header).
128
+ */
129
+ export function storeGlob(glob, nativeSessionId) {
130
+ let pattern = '';
131
+ for (let i = 0; i < glob.length; i += 1) {
132
+ const rest = glob.slice(i);
133
+ if (rest.startsWith('{id}')) {
134
+ pattern += nativeSessionId === undefined ? '[^/]+' : nativeSessionId.replace(REGEX_SPECIALS, '\\$&').replace(/[*?{}]/gu, '\\$&');
135
+ i += 3;
136
+ }
137
+ else if (rest.startsWith('**/')) {
138
+ pattern += '(?:[^/]+/)*';
139
+ i += 2;
140
+ }
141
+ else if (rest.startsWith('**')) {
142
+ pattern += '.*';
143
+ i += 1;
144
+ }
145
+ else if (glob[i] === '*') {
146
+ pattern += '[^/]*';
147
+ }
148
+ else if (glob[i] === '?') {
149
+ pattern += '[^/]';
150
+ }
151
+ else {
152
+ pattern += glob[i].replace(REGEX_SPECIALS, '\\$&');
153
+ }
154
+ }
155
+ return new RegExp(`^${pattern}$`, 'u');
156
+ }
157
+ /** A store root must be a normalized path relative to HOME: not absolute, no `.`/`..` segments. */
158
+ export function assertStoreRoot(root) {
159
+ const segments = root.split('/');
160
+ if (root.startsWith('/') || segments.some((s) => s === '' || s === '.' || s === '..')) {
161
+ throw new Error(`store root must be a normalized path relative to HOME: ${root}`);
162
+ }
163
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * harness-sessions read <file|home|capture> [--harness h] [--session id] [--cwd dir] [--summary] [--json]
4
+ *
5
+ * Reads native harness sessions into the normalized form, for callers that are not JavaScript.
6
+ * A file is one session file (or an OpenCode store); a directory is a capture when it holds
7
+ * `__retention__/sessions`, otherwise a HOME searched through every reader's store layout.
8
+ * `--json` prints one JSON document per session, one per line.
9
+ */
10
+ import { stat } from 'node:fs/promises';
11
+ import { join } from 'node:path';
12
+ import { listCapture, RETENTION_SESSIONS } from './capture.js';
13
+ import { claudeCodeRefForFile } from './formats/claude-code.js';
14
+ import { codexRefForFile } from './formats/codex.js';
15
+ import { opencodeSessionsInStore } from './formats/opencode.js';
16
+ import { piRefForFile } from './formats/pi.js';
17
+ import { kimiRefForFile } from './formats/kimi.js';
18
+ import { factoryRefForFile } from './formats/factory.js';
19
+ import { listReaders, readerFor } from './registry.js';
20
+ function usage() {
21
+ process.stderr.write('usage: harness-sessions read <file|home|capture> [--harness h] [--session id] [--cwd dir] [--summary] [--json]\n');
22
+ process.exit(2);
23
+ }
24
+ async function isDir(path) {
25
+ try {
26
+ return (await stat(path)).isDirectory();
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ function harnessOfFile(path) {
33
+ if (/\.db$/u.test(path))
34
+ return 'opencode';
35
+ if (/(^|\/)\.claude\/projects\//u.test(path))
36
+ return 'claude-code';
37
+ if (/(^|\/)\.codex\/sessions\//u.test(path) || /rollout-[^/]*\.jsonl$/u.test(path))
38
+ return 'codex';
39
+ if (/(^|\/)\.pi\/agent\/sessions\//u.test(path))
40
+ return 'pi';
41
+ if (/(^|\/)\.kimi\/sessions\/.+\/wire\.jsonl$/u.test(path))
42
+ return 'kimi-code';
43
+ if (/(^|\/)\.factory\/sessions\//u.test(path))
44
+ return 'factory-droids';
45
+ return null;
46
+ }
47
+ async function refsForFile(path, harness, session) {
48
+ const h = harness ?? harnessOfFile(path);
49
+ if (h === null)
50
+ throw new Error(`cannot tell which harness wrote ${path}; pass --harness`);
51
+ const reader = readerFor(h);
52
+ if (reader.harness === 'opencode')
53
+ return opencodeSessionsInStore(path, null, session ? { nativeSessionId: session } : {});
54
+ const ref = reader.harness === 'claude-code'
55
+ ? claudeCodeRefForFile(path, /\/subagents\//u.test(path) ? 'parent' : null)
56
+ : reader.harness === 'codex' ? codexRefForFile(path) : reader.harness === 'kimi-code' ? kimiRefForFile(path) : reader.harness === 'factory-droids' ? factoryRefForFile(path) : piRefForFile(path);
57
+ return [ref];
58
+ }
59
+ async function main(argv) {
60
+ const [command, target, ...rest] = argv;
61
+ if (command !== 'read' || !target)
62
+ usage();
63
+ let harness = null;
64
+ let session = null;
65
+ let cwd = null;
66
+ let summary = false;
67
+ let json = false;
68
+ for (let i = 0; i < rest.length; i += 1) {
69
+ const arg = rest[i];
70
+ if (arg === '--harness')
71
+ harness = rest[++i] ?? usage();
72
+ else if (arg === '--session')
73
+ session = rest[++i] ?? usage();
74
+ else if (arg === '--cwd')
75
+ cwd = rest[++i] ?? usage();
76
+ else if (arg === '--summary')
77
+ summary = true;
78
+ else if (arg === '--json')
79
+ json = true;
80
+ else
81
+ usage();
82
+ }
83
+ const refs = [];
84
+ if (await isDir(target)) {
85
+ if (await isDir(join(target, RETENTION_SESSIONS))) {
86
+ const listing = await listCapture(target);
87
+ for (const missing of listing.missing)
88
+ process.stderr.write(`missing: ${JSON.stringify(missing)}\n`);
89
+ refs.push(...listing.sessions.map((s) => s.ref).filter((r) => !harness || readerFor(harness).harness === r.harness));
90
+ }
91
+ else {
92
+ const readers = harness ? [readerFor(harness)] : listReaders();
93
+ for (const reader of readers)
94
+ refs.push(...(await reader.locate(target, session ? { nativeSessionId: session } : {})));
95
+ }
96
+ }
97
+ else {
98
+ refs.push(...(await refsForFile(target, harness, session)));
99
+ }
100
+ const selected = refs.filter((r) => (!session || r.nativeSessionId === session) && (!cwd || r.cwd === cwd));
101
+ for (const ref of selected) {
102
+ const reader = readerFor(ref.harness);
103
+ const value = summary ? await reader.summarize(ref) : await reader.read(ref);
104
+ if (json) {
105
+ process.stdout.write(`${JSON.stringify(value)}\n`);
106
+ continue;
107
+ }
108
+ const s = summary ? value : await reader.summarize(ref);
109
+ process.stdout.write(`${s.harness}\t${s.nativeSessionId}\tcalls=${s.modelCalls}\treplies=${s.replies}\ttools=${s.toolCalls}\tending=${s.ending.status}\tmodels=${Object.keys(s.servedModels).join(',')}\t${ref.path}\n`);
110
+ }
111
+ }
112
+ main(process.argv.slice(2)).catch((error) => {
113
+ process.stderr.write(`harness-sessions: ${error instanceof Error ? error.message : String(error)}\n`);
114
+ process.exit(1);
115
+ });
@@ -0,0 +1,9 @@
1
+ import { SessionBuilder, type BuildMode } from '../builder.js';
2
+ import type { HarnessSessionReader, ReadOptions, SessionRef } from '../schema.js';
3
+ import { type RecordSource, type SourceStats } from '../source.js';
4
+ import { type SessionFold } from './fold.js';
5
+ export declare function createFold(ref: SessionRef, mode: BuildMode, stats: SourceStats): SessionFold;
6
+ export declare function fold(ref: SessionRef, mode: BuildMode, options: ReadOptions, source?: RecordSource): Promise<SessionBuilder>;
7
+ export declare const claudeCodeReader: HarnessSessionReader;
8
+ /** A reader for one transcript file outside a HOME layout (the CLI, a retained copy). */
9
+ export declare function claudeCodeRefForFile(path: string, parentNativeSessionId?: string | null): SessionRef;