@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,205 @@
1
+ /**
2
+ * Kimi Code: one directory per session, `~/.kimi/sessions/<workdir hash>/<session id>/`, holding
3
+ * `wire.jsonl` (the event stream this reader folds) and `context.jsonl` (the model context).
4
+ *
5
+ * Wire records are `{timestamp, message: {type, payload}}` with epoch-second timestamps.
6
+ * `TurnBegin` carries the user's input; each `StepBegin` is one model call, whose `ContentPart`s
7
+ * (`think`, `text`), `ToolCall`s (arguments streamed on in `ToolCallPart`s) and `StatusUpdate`
8
+ * (`token_usage`: `input_other`, `output`, `input_cache_read`, `input_cache_creation`, disjoint,
9
+ * and the provider's `message_id`) follow. `ToolResult` answers a call. `StepRetry` is a failed
10
+ * attempt with the provider's status code; `StepInterrupted` ends a step without an answer. A
11
+ * subagent's events arrive wrapped in `SubagentEvent`. The wire records no model.
12
+ */
13
+ import { basename, dirname, join } from 'node:path';
14
+ import { SessionBuilder, count, isRecord, sessionError, str, textOf, usageOrNull } from '../builder.js';
15
+ import { fileRecords } from '../source.js';
16
+ import { EVIDENCE, STORES, mtimeMs, storeGlob, walkFiles } from '../stores.js';
17
+ import { runFold } from './fold.js';
18
+ const FORMAT = 'kimi.session-dir';
19
+ const STORE = STORES[FORMAT];
20
+ const HARNESS = 'kimi-code';
21
+ /** The event stream this reader folds; the store also holds context.jsonl and state.json. */
22
+ const WIRE = '*/{id}/wire.jsonl';
23
+ function usageOf(value) {
24
+ if (!isRecord(value))
25
+ return null;
26
+ return usageOrNull({
27
+ input: count(value.input_other),
28
+ output: count(value.output),
29
+ reasoning: null,
30
+ cacheRead: count(value.input_cache_read),
31
+ cacheWrite: count(value.input_cache_creation),
32
+ });
33
+ }
34
+ export function createFold(ref, mode, stats) {
35
+ const builder = new SessionBuilder(HARNESS, FORMAT, EVIDENCE[FORMAT].servedModel, mode, ref.nativeSessionId, ref.parentNativeSessionId);
36
+ let turn = 0;
37
+ let calls = 0;
38
+ let step = null;
39
+ let lastToolCall = null;
40
+ let aborted = null;
41
+ let compactions = 0;
42
+ let subagentEvents = 0;
43
+ const openStep = (at) => {
44
+ if (step)
45
+ return step;
46
+ calls += 1;
47
+ const id = `${builder.nativeSessionId}:turn:${turn}:step:${calls}`;
48
+ const call = builder.modelCall({ id, at, provider: null, servedModel: null, requestedModel: null, usage: null, costUsd: null, stopReason: null, error: null });
49
+ builder.message({ id: `${id}:message`, role: 'assistant', actor: 'agent', at, modelCallId: id, parts: [] });
50
+ step = { call, messageId: `${id}:message` };
51
+ return step;
52
+ };
53
+ const flushToolArguments = () => {
54
+ if (lastToolCall)
55
+ builder.setToolInput(lastToolCall.id, lastToolCall.text);
56
+ lastToolCall = null;
57
+ };
58
+ const observe = (record) => {
59
+ const message = isRecord(record.message) ? record.message : null;
60
+ if (message === null)
61
+ return;
62
+ const type = str(message.type);
63
+ const payload = isRecord(message.payload) ? message.payload : {};
64
+ const at = builder.seen(record.timestamp);
65
+ if (type !== 'ToolCallPart')
66
+ flushToolArguments();
67
+ switch (type) {
68
+ case 'TurnBegin': {
69
+ turn += 1;
70
+ step = null;
71
+ aborted = null;
72
+ const input = payload.user_input;
73
+ const parts = typeof input === 'string'
74
+ ? [{ type: 'text', text: input }]
75
+ : (textOf(input) !== null ? [{ type: 'text', text: textOf(input) }] : []);
76
+ builder.message({ id: `${builder.nativeSessionId}:turn:${turn}:input`, role: 'user', actor: ref.parentNativeSessionId ? 'subagent-spawn' : 'human', at, modelCallId: null, parts });
77
+ break;
78
+ }
79
+ case 'StepBegin':
80
+ step = null;
81
+ openStep(at);
82
+ break;
83
+ case 'ContentPart': {
84
+ const current = openStep(at);
85
+ if (payload.type === 'think')
86
+ builder.appendParts(current.messageId, [{ type: 'reasoning', text: str(payload.think) ?? '', redacted: payload.encrypted != null && !payload.think }]);
87
+ else if (typeof payload.text === 'string')
88
+ builder.appendParts(current.messageId, [{ type: 'text', text: payload.text }]);
89
+ break;
90
+ }
91
+ case 'ToolCall': {
92
+ const current = openStep(at);
93
+ const fn = isRecord(payload.function) ? payload.function : {};
94
+ const id = str(payload.id) ?? `${builder.nativeSessionId}:call:${stats.line}`;
95
+ const text = typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments ?? {});
96
+ builder.appendParts(current.messageId, [{ type: 'tool-call', toolCallId: id }]);
97
+ builder.toolCall({ id, name: str(fn.name) ?? 'unknown', input: null, inputText: text, at, messageId: current.messageId });
98
+ lastToolCall = { id, text };
99
+ break;
100
+ }
101
+ case 'ToolCallPart':
102
+ if (lastToolCall && typeof payload.arguments_part === 'string')
103
+ lastToolCall.text += payload.arguments_part;
104
+ break;
105
+ case 'ToolResult': {
106
+ const id = str(payload.tool_call_id) ?? '';
107
+ const value = isRecord(payload.return_value) ? payload.return_value : {};
108
+ const resultId = `${id}:result`;
109
+ builder.message({ id: resultId, role: 'tool', actor: 'tool-result', at, modelCallId: null, parts: [{ type: 'tool-result', toolCallId: id }] });
110
+ builder.toolResult(id, { output: value.output ?? null, text: typeof value.output === 'string' ? value.output : textOf(value.output), isError: value.is_error === true, at, messageId: resultId, details: value });
111
+ break;
112
+ }
113
+ case 'StatusUpdate': {
114
+ const usage = usageOf(payload.token_usage);
115
+ if (usage === null)
116
+ break;
117
+ const current = openStep(at);
118
+ current.call.usage = usage;
119
+ break;
120
+ }
121
+ case 'StepRetry': {
122
+ // A failed attempt Kimi retried: a model call that returned no answer.
123
+ calls += 1;
124
+ builder.modelCall({
125
+ id: `${builder.nativeSessionId}:turn:${turn}:retry:${calls}`, at, provider: null, servedModel: null, requestedModel: null, usage: null, costUsd: null, stopReason: null,
126
+ error: sessionError(str(payload.error_type) ?? 'retry', `${str(payload.error_type) ?? 'error'}${payload.status_code ? ` (status ${String(payload.status_code)})` : ''}`, at, count(payload.status_code)),
127
+ });
128
+ break;
129
+ }
130
+ case 'StepInterrupted':
131
+ aborted = { at };
132
+ step = null;
133
+ break;
134
+ case 'TurnEnd':
135
+ step = null;
136
+ break;
137
+ case 'CompactionBegin':
138
+ compactions += 1;
139
+ break;
140
+ case 'SubagentEvent': {
141
+ subagentEvents += 1;
142
+ const agent = str(payload.agent_id);
143
+ if (agent)
144
+ builder.child(agent, str(payload.parent_tool_call_id));
145
+ break;
146
+ }
147
+ default:
148
+ break;
149
+ }
150
+ };
151
+ const finish = () => {
152
+ flushToolArguments();
153
+ builder.source(stats);
154
+ if (compactions > 0)
155
+ builder.gap(`${compactions} compactions replaced earlier context`);
156
+ if (subagentEvents > 0)
157
+ builder.gap(`${subagentEvents} subagent events are logged in this session; each subagent is its own session`);
158
+ builder.inferEnding(aborted ? { at: aborted.at, error: null } : undefined);
159
+ return builder;
160
+ };
161
+ return { builder, observe, finish };
162
+ }
163
+ export async function fold(ref, mode, options, source = fileRecords(ref.path, { strict: options.corruption === 'strict', signal: options.signal })) {
164
+ return runFold(createFold(ref, mode, source.stats), source);
165
+ }
166
+ async function locate(home, opts) {
167
+ const root = join(home, STORE.root);
168
+ const pattern = storeGlob(WIRE, opts.nativeSessionId);
169
+ const refs = [];
170
+ for (const rel of await walkFiles(root)) {
171
+ if (!pattern.test(rel))
172
+ continue;
173
+ const path = join(root, rel);
174
+ const mtime = await mtimeMs(path);
175
+ if (opts.sinceMs && mtime < opts.sinceMs)
176
+ continue;
177
+ // Kimi keys the session directory by a hash of its working directory and records no cwd.
178
+ if (opts.cwd)
179
+ continue;
180
+ const id = basename(dirname(path));
181
+ const context = join(dirname(path), 'context.jsonl');
182
+ const files = (await mtimeMs(context)) > 0 ? [path, context] : [path];
183
+ refs.push({ harness: HARNESS, format: FORMAT, nativeSessionId: id, path, files, home, cwd: null, mtimeMs: mtime, parentNativeSessionId: null });
184
+ }
185
+ return refs.sort((a, b) => b.mtimeMs - a.mtimeMs);
186
+ }
187
+ export const kimiReader = {
188
+ harness: HARNESS,
189
+ aliases: ['kimi', 'kimi-cli'],
190
+ formats: [FORMAT],
191
+ stores: [STORE],
192
+ globs: { session: WIRE },
193
+ evidence: EVIDENCE[FORMAT],
194
+ locate: (home, opts = {}) => locate(home, opts),
195
+ async read(ref, opts = {}) {
196
+ return (await fold(ref, 'full', opts)).build();
197
+ },
198
+ async summarize(ref, opts = {}) {
199
+ return (await fold(ref, 'summary', opts)).summary();
200
+ },
201
+ };
202
+ /** A session read from its `wire.jsonl` outside a HOME layout. */
203
+ export function kimiRefForFile(path) {
204
+ return { harness: HARNESS, format: FORMAT, nativeSessionId: basename(dirname(path)), path, files: [path], home: null, cwd: null, mtimeMs: 0, parentNativeSessionId: null };
205
+ }
@@ -0,0 +1,4 @@
1
+ import type { HarnessSessionReader, LocateOptions, SessionRef } from '../schema.js';
2
+ /** Every session in one store file, without opening the live store. */
3
+ export declare function opencodeSessionsInStore(path: string, home: string | null, opts?: LocateOptions): Promise<SessionRef[]>;
4
+ export declare const opencodeReader: HarnessSessionReader;
@@ -0,0 +1,241 @@
1
+ /**
2
+ * OpenCode 1.x: one SQLite store for every session in the HOME,
3
+ * `~/.local/share/opencode/opencode.db` (with `-wal` and `-shm` siblings while it is open).
4
+ *
5
+ * `session` rows carry the cwd, the parent session and session totals. `message` rows carry a JSON
6
+ * `data` blob: role, the provider and model the message was sent to, tokens, cost, finish and the
7
+ * error that ended it. `part` rows carry the content: `text`, `reasoning`, `tool` (the call and
8
+ * its result in one row), `step-start` and `step-finish` (one model response, with its tokens and
9
+ * cost), `retry` (a failed attempt) and bookkeeping parts. The store is read from a private copy,
10
+ * never in place: opening it can checkpoint the write-ahead log of a store a capture is keeping.
11
+ */
12
+ import { join } from 'node:path';
13
+ import { SessionBuilder, count, isRecord, isoTime, sessionError, str, usageOrNull } from '../builder.js';
14
+ import { isMissing, withSqliteCopy } from '../source.js';
15
+ import { EVIDENCE, STORES, mtimeMs } from '../stores.js';
16
+ const FORMAT = 'opencode.sqlite';
17
+ const STORE = STORES[FORMAT];
18
+ const HARNESS = 'opencode';
19
+ function usageOf(tokens) {
20
+ if (!isRecord(tokens))
21
+ return null;
22
+ const cache = isRecord(tokens.cache) ? tokens.cache : {};
23
+ return usageOrNull({
24
+ input: count(tokens.input),
25
+ output: count(tokens.output),
26
+ reasoning: count(tokens.reasoning),
27
+ cacheRead: count(cache.read),
28
+ cacheWrite: count(cache.write),
29
+ });
30
+ }
31
+ function errorOf(value, at) {
32
+ if (!isRecord(value))
33
+ return null;
34
+ const data = isRecord(value.data) ? value.data : {};
35
+ const name = str(value.name) ?? 'error';
36
+ return sessionError(name, str(data.message) ?? str(value.message) ?? name, at, count(data.statusCode));
37
+ }
38
+ function parse(json) {
39
+ if (typeof json !== 'string')
40
+ return null;
41
+ try {
42
+ const value = JSON.parse(json);
43
+ return isRecord(value) ? value : null;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ function foldSession(db, ref, mode, builder) {
50
+ const session = db.prepare('SELECT id, parent_id, directory, time_created, time_updated FROM session WHERE id = ?').get(ref.nativeSessionId);
51
+ if (!session) {
52
+ builder.gap(`session ${ref.nativeSessionId} is not in the store`);
53
+ return;
54
+ }
55
+ builder.cwd = session.directory;
56
+ builder.parentNativeSessionId = session.parent_id ?? null;
57
+ builder.seen(session.time_created);
58
+ const messages = db.prepare('SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, id').all(ref.nativeSessionId);
59
+ const partsOf = db.prepare('SELECT id, time_created, data FROM part WHERE message_id = ? ORDER BY id');
60
+ let unparsed = 0;
61
+ let aborted = null;
62
+ for (const row of messages) {
63
+ const data = parse(row.data);
64
+ if (data === null) {
65
+ unparsed += 1;
66
+ continue;
67
+ }
68
+ const time = isRecord(data.time) ? data.time : {};
69
+ const at = builder.seen(time.created ?? row.time_created);
70
+ builder.seen(time.completed);
71
+ const parts = [];
72
+ for (const partRow of partsOf.iterate(row.id)) {
73
+ const part = parse(partRow.data);
74
+ if (part === null)
75
+ unparsed += 1;
76
+ else
77
+ parts.push({ id: partRow.id, data: part });
78
+ }
79
+ if (data.role === 'user') {
80
+ aborted = null;
81
+ const content = [];
82
+ let synthetic = parts.length > 0;
83
+ for (const { data: part } of parts) {
84
+ if (part.type === 'text' && typeof part.text === 'string') {
85
+ content.push({ type: 'text', text: part.text });
86
+ if (part.synthetic !== true)
87
+ synthetic = false;
88
+ }
89
+ else if (part.type === 'file') {
90
+ content.push({ type: 'attachment', mediaType: str(part.mime) ?? 'file', ref: str(part.filename) ?? str(part.url)?.slice(0, 200) ?? row.id });
91
+ synthetic = false;
92
+ }
93
+ }
94
+ builder.message({ id: row.id, role: 'user', actor: synthetic ? 'injected' : builder.parentNativeSessionId ? 'subagent-spawn' : 'human', at, modelCallId: null, parts: content });
95
+ continue;
96
+ }
97
+ if (data.role !== 'assistant')
98
+ continue;
99
+ const provider = str(data.providerID);
100
+ const model = str(data.modelID);
101
+ const messageError = errorOf(data.error, isoTime(time.completed) ?? at);
102
+ let step = null;
103
+ let steps = 0;
104
+ const startStep = (stepAt) => {
105
+ steps += 1;
106
+ const id = `${row.id}:step:${steps}`;
107
+ const call = builder.modelCall({ id, at: stepAt, provider, servedModel: model, requestedModel: model, usage: null, costUsd: null, stopReason: null, error: null });
108
+ builder.message({ id, role: 'assistant', actor: 'agent', at: stepAt, modelCallId: id, parts: [] });
109
+ return { call, messageId: id };
110
+ };
111
+ for (const { id: partId, data: part } of parts) {
112
+ const partTime = isRecord(part.time) ? part.time : {};
113
+ const partAt = builder.seen(partTime.start) ?? at;
114
+ switch (part.type) {
115
+ case 'step-start':
116
+ step = startStep(partAt);
117
+ break;
118
+ case 'step-finish': {
119
+ const current = step ?? startStep(partAt);
120
+ current.call.usage = usageOf(part.tokens);
121
+ current.call.costUsd = count(part.cost);
122
+ current.call.stopReason = str(part.reason);
123
+ step = null;
124
+ break;
125
+ }
126
+ case 'text':
127
+ if (typeof part.text === 'string') {
128
+ const current = step ?? (step = startStep(partAt));
129
+ builder.appendParts(current.messageId, [{ type: 'text', text: part.text }]);
130
+ }
131
+ break;
132
+ case 'reasoning':
133
+ if (typeof part.text === 'string') {
134
+ const current = step ?? (step = startStep(partAt));
135
+ builder.appendParts(current.messageId, [{ type: 'reasoning', text: part.text, redacted: false }]);
136
+ }
137
+ break;
138
+ case 'tool': {
139
+ const current = step ?? (step = startStep(partAt));
140
+ const callId = str(part.callID) ?? partId;
141
+ const state = isRecord(part.state) ? part.state : {};
142
+ const stateTime = isRecord(state.time) ? state.time : {};
143
+ builder.appendParts(current.messageId, [{ type: 'tool-call', toolCallId: callId }]);
144
+ builder.toolCall({ id: callId, name: str(part.tool) ?? 'unknown', input: state.input ?? null, inputText: null, at: isoTime(stateTime.start) ?? partAt, messageId: current.messageId });
145
+ if (state.status === 'completed' || state.status === 'error') {
146
+ const endAt = builder.seen(stateTime.end) ?? partAt;
147
+ const output = state.status === 'error' ? (state.error ?? null) : (state.output ?? null);
148
+ const resultId = `${callId}:result`;
149
+ builder.message({ id: resultId, role: 'tool', actor: 'tool-result', at: endAt, modelCallId: null, parts: [{ type: 'tool-result', toolCallId: callId }] });
150
+ builder.toolResult(callId, { output, text: typeof output === 'string' ? output : null, isError: state.status === 'error', at: endAt, messageId: resultId, details: state.metadata ?? null });
151
+ }
152
+ const metadata = isRecord(state.metadata) ? state.metadata : {};
153
+ const childId = str(metadata.sessionId);
154
+ if (childId)
155
+ builder.child(childId, callId);
156
+ break;
157
+ }
158
+ case 'retry': {
159
+ // A failed request OpenCode retried: a model call that returned no answer.
160
+ builder.modelCall({ id: `${row.id}:retry:${partId}`, at: isoTime(partTime.created) ?? partAt, provider, servedModel: null, requestedModel: model, usage: null, costUsd: null, stopReason: null, error: errorOf(part.error, partAt) ?? sessionError('retry', 'retried request', partAt) });
161
+ break;
162
+ }
163
+ default:
164
+ break;
165
+ }
166
+ }
167
+ if (steps === 0) {
168
+ // No response step was recorded: the request failed or the process stopped first.
169
+ builder.modelCall({ id: `${row.id}:request`, at, provider: messageError ? null : provider, servedModel: messageError ? null : model, requestedModel: model, usage: usageOf(data.tokens), costUsd: count(data.cost), stopReason: str(data.finish), error: messageError });
170
+ if (messageError)
171
+ builder.message({ id: row.id, role: 'assistant', actor: 'injected', at, modelCallId: `${row.id}:request`, parts: [{ type: 'text', text: messageError.message }] });
172
+ }
173
+ else if (messageError) {
174
+ const last = builder.modelCalls[builder.modelCalls.length - 1];
175
+ last.error = messageError;
176
+ }
177
+ aborted = messageError?.kind === 'MessageAbortedError' ? { at: messageError.at, error: messageError } : null;
178
+ }
179
+ const children = db.prepare('SELECT id FROM session WHERE parent_id = ? ORDER BY time_created, id').all(ref.nativeSessionId);
180
+ for (const child of children)
181
+ builder.child(child.id, null);
182
+ if (unparsed > 0)
183
+ builder.source({ path: `${ref.path}#session=${ref.nativeSessionId}`, sha256: '', bytes: 0, unparsed });
184
+ const last = builder.modelCalls[builder.modelCalls.length - 1];
185
+ if (aborted && last?.error?.kind === 'MessageAbortedError')
186
+ last.error = null;
187
+ builder.inferEnding(aborted ?? undefined);
188
+ }
189
+ async function fold(ref, mode, options) {
190
+ options.signal?.throwIfAborted();
191
+ const builder = new SessionBuilder(HARNESS, FORMAT, EVIDENCE[FORMAT].servedModel, mode, ref.nativeSessionId, ref.parentNativeSessionId);
192
+ await withSqliteCopy(ref.path, (db, sources) => {
193
+ for (const source of sources)
194
+ builder.source(source);
195
+ foldSession(db, ref, mode, builder);
196
+ });
197
+ if (ref.truncated)
198
+ builder.markTruncated(`${ref.path}: the store copy was cut short`);
199
+ return builder;
200
+ }
201
+ /** Every session in one store file, without opening the live store. */
202
+ export async function opencodeSessionsInStore(path, home, opts = {}) {
203
+ const mtime = await mtimeMs(path);
204
+ const files = [path];
205
+ for (const suffix of ['-wal', '-shm'])
206
+ if ((await mtimeMs(`${path}${suffix}`)) > 0)
207
+ files.push(`${path}${suffix}`);
208
+ return withSqliteCopy(path, (db) => {
209
+ const rows = db.prepare('SELECT id, parent_id, directory, time_updated FROM session ORDER BY time_updated DESC').all();
210
+ return rows
211
+ .filter((row) => (!opts.nativeSessionId || row.id === opts.nativeSessionId)
212
+ && (!opts.cwd || row.directory.startsWith(opts.cwd))
213
+ && (!opts.sinceMs || row.time_updated >= opts.sinceMs))
214
+ .map((row) => ({ harness: HARNESS, format: FORMAT, nativeSessionId: row.id, path, files, home, cwd: row.directory, mtimeMs: row.time_updated || mtime, parentNativeSessionId: row.parent_id }));
215
+ });
216
+ }
217
+ export const opencodeReader = {
218
+ harness: HARNESS,
219
+ aliases: ['opencode-acp', 'opencode-cli'],
220
+ formats: [FORMAT],
221
+ stores: [STORE],
222
+ globs: { session: STORE.files[0] },
223
+ evidence: EVIDENCE[FORMAT],
224
+ async locate(home, opts = {}) {
225
+ const path = join(home, STORE.root, STORE.files[0]);
226
+ try {
227
+ return await opencodeSessionsInStore(path, home, opts);
228
+ }
229
+ catch (error) {
230
+ if (isMissing(error))
231
+ return [];
232
+ throw error;
233
+ }
234
+ },
235
+ async read(ref, opts = {}) {
236
+ return (await fold(ref, 'full', opts)).build();
237
+ },
238
+ async summarize(ref, opts = {}) {
239
+ return (await fold(ref, 'summary', opts)).summary();
240
+ },
241
+ };
@@ -0,0 +1,8 @@
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 piReader: HarnessSessionReader;
8
+ export declare function piRefForFile(path: string): SessionRef;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Pi: `~/.pi/agent/sessions/<encoded cwd>/<timestamp>_<session id>.jsonl`, one entry per line.
3
+ *
4
+ * The first entry is the `session` header (id, cwd). Conversation entries are `message` entries
5
+ * whose `message.role` is `user`, `assistant` or `toolResult`. Each assistant message records the
6
+ * provider and model it was sent to, its usage (input, output and cache counts are disjoint, with a
7
+ * cost breakdown), its stop reason and, on a failure, the provider's error message. Tool calls are
8
+ * `toolCall` blocks inside assistant content, answered by `toolResult` messages keyed by
9
+ * `toolCallId`. Entries form a tree through `parentId`; branches share one file.
10
+ */
11
+ import { basename, join } from 'node:path';
12
+ import { SessionBuilder, count, isRecord, sessionError, str, textOf, usageOrNull } from '../builder.js';
13
+ import { fileRecords } from '../source.js';
14
+ import { runFold } from './fold.js';
15
+ import { EVIDENCE, STORES, mtimeMs, storeGlob, walkFiles } from '../stores.js';
16
+ import { headRecord } from './head.js';
17
+ const FORMAT = 'pi.session-jsonl';
18
+ const STORE = STORES[FORMAT];
19
+ const HARNESS = 'pi';
20
+ function usageOf(value) {
21
+ if (!isRecord(value))
22
+ return { usage: null, cost: null };
23
+ const cost = isRecord(value.cost) ? count(value.cost.total) : null;
24
+ return {
25
+ usage: usageOrNull({
26
+ input: count(value.input),
27
+ output: count(value.output),
28
+ reasoning: count(value.reasoning),
29
+ cacheRead: count(value.cacheRead),
30
+ cacheWrite: count(value.cacheWrite),
31
+ }),
32
+ cost,
33
+ };
34
+ }
35
+ function contentParts(content, ref) {
36
+ if (typeof content === 'string')
37
+ return [{ type: 'text', text: content }];
38
+ const parts = [];
39
+ if (!Array.isArray(content))
40
+ return parts;
41
+ for (const block of content) {
42
+ if (!isRecord(block))
43
+ continue;
44
+ if (block.type === 'text' && typeof block.text === 'string')
45
+ parts.push({ type: 'text', text: block.text });
46
+ else if (block.type === 'thinking')
47
+ parts.push({ type: 'reasoning', text: str(block.thinking) ?? '', redacted: block.redacted === true });
48
+ else if (block.type === 'image')
49
+ parts.push({ type: 'attachment', mediaType: str(block.mimeType) ?? 'image', ref: `inline:${ref}` });
50
+ }
51
+ return parts;
52
+ }
53
+ /** Session ids are the part of the file name after the timestamp: `<timestamp>_<id>.jsonl`. */
54
+ function idFromName(path) {
55
+ return basename(path, '.jsonl').replace(/^[\dTZ.:-]+_/u, '');
56
+ }
57
+ export function createFold(ref, mode, stats) {
58
+ const builder = new SessionBuilder(HARNESS, FORMAT, EVIDENCE[FORMAT].servedModel, mode, ref.nativeSessionId, ref.parentNativeSessionId);
59
+ let previousId = null;
60
+ let branches = 0;
61
+ let compactions = 0;
62
+ let aborted = null;
63
+ const observe = (entry) => {
64
+ const entryAt = builder.seen(entry.timestamp);
65
+ const id = str(entry.id) ?? `${builder.nativeSessionId}:${stats.line}`;
66
+ const parentId = str(entry.parentId);
67
+ if (entry.type === 'session') {
68
+ if (typeof entry.id === 'string')
69
+ builder.nativeSessionId = entry.id;
70
+ builder.cwd = str(entry.cwd);
71
+ previousId = null;
72
+ return;
73
+ }
74
+ if (parentId !== null && previousId !== null && parentId !== previousId)
75
+ branches += 1;
76
+ previousId = id;
77
+ if (entry.type === 'compaction') {
78
+ compactions += 1;
79
+ if (typeof entry.summary === 'string') {
80
+ builder.message({ id, role: 'user', actor: 'injected', at: entryAt, modelCallId: null, parts: [{ type: 'text', text: entry.summary }] });
81
+ }
82
+ return;
83
+ }
84
+ if (entry.type !== 'message' || !isRecord(entry.message))
85
+ return;
86
+ const message = entry.message;
87
+ const at = entryAt ?? builder.seen(message.timestamp);
88
+ const role = str(message.role);
89
+ if (role === 'user') {
90
+ aborted = null;
91
+ builder.message({ id, role: 'user', actor: ref.parentNativeSessionId ? 'subagent-spawn' : 'human', at, modelCallId: null, parts: contentParts(message.content, id) });
92
+ return;
93
+ }
94
+ if (role === 'toolResult') {
95
+ const callId = str(message.toolCallId) ?? `${id}:result`;
96
+ builder.message({ id, role: 'tool', actor: 'tool-result', at, modelCallId: null, parts: [{ type: 'tool-result', toolCallId: callId }] });
97
+ const content = message.content;
98
+ const single = Array.isArray(content) && content.length === 1 && isRecord(content[0]) && content[0].type === 'text';
99
+ builder.toolResult(callId, {
100
+ output: single ? content[0].text : (content ?? null),
101
+ text: textOf(content),
102
+ isError: message.isError === true,
103
+ at,
104
+ name: str(message.toolName) ?? undefined,
105
+ messageId: id,
106
+ details: message.details ?? null,
107
+ });
108
+ return;
109
+ }
110
+ if (role === 'assistant') {
111
+ const stopReason = str(message.stopReason);
112
+ const errorText = str(message.errorMessage);
113
+ const failed = stopReason === 'error';
114
+ const { usage, cost } = usageOf(message.usage);
115
+ // A failed request returned no response, so no model served it; the model it was sent to
116
+ // stays in the profile and the request, not here.
117
+ builder.modelCall({
118
+ id,
119
+ at,
120
+ provider: failed ? null : str(message.provider),
121
+ // Pi 0.85 records the provider's own answer as responseModel; model is what it asked for.
122
+ servedModel: failed ? null : (str(message.responseModel) ?? str(message.model)),
123
+ requestedModel: str(message.model),
124
+ usage,
125
+ costUsd: cost,
126
+ stopReason,
127
+ error: failed ? sessionError('error', errorText ?? 'Pi assistant stopped with an error', at) : null,
128
+ });
129
+ const parts = contentParts(message.content, id);
130
+ const content = Array.isArray(message.content) ? message.content : [];
131
+ for (const block of content) {
132
+ if (!isRecord(block) || block.type !== 'toolCall')
133
+ continue;
134
+ const callId = str(block.id) ?? `${id}:call:${parts.length}`;
135
+ parts.push({ type: 'tool-call', toolCallId: callId });
136
+ const input = block.arguments ?? block.input ?? null;
137
+ builder.toolCall({ id: callId, name: str(block.name) ?? 'tool', input, inputText: typeof input === 'string' ? input : null, at, messageId: id });
138
+ }
139
+ builder.message({ id, role: 'assistant', actor: failed ? 'injected' : 'agent', at, modelCallId: id, parts: failed && parts.length === 0 && errorText ? [{ type: 'text', text: errorText }] : parts });
140
+ aborted = stopReason === 'aborted' ? { at, message: errorText } : null;
141
+ return;
142
+ }
143
+ // Harness-injected messages (system prompts, extension messages, shell executions).
144
+ if (role === 'system' || role === 'custom' || role === 'bashExecution') {
145
+ builder.message({ id, role: role === 'system' ? 'system' : 'user', actor: 'injected', at, modelCallId: null, parts: contentParts(message.content ?? message.output, id) });
146
+ }
147
+ };
148
+ const finish = () => {
149
+ builder.source(stats);
150
+ if (branches > 0)
151
+ builder.gap(`${branches} entries continue an earlier entry rather than the previous one (the session branched); all branches are read in file order`);
152
+ if (compactions > 0)
153
+ builder.gap(`${compactions} compaction entries replaced earlier context`);
154
+ builder.inferEnding(aborted ? { at: aborted.at, error: aborted.message ? sessionError('aborted', aborted.message, aborted.at, null) : null } : undefined);
155
+ return builder;
156
+ };
157
+ return { builder, observe, finish };
158
+ }
159
+ export async function fold(ref, mode, options, source = fileRecords(ref.path, { strict: options.corruption === 'strict', signal: options.signal })) {
160
+ return runFold(createFold(ref, mode, source.stats), source);
161
+ }
162
+ async function locate(home, opts) {
163
+ const root = join(home, STORE.root);
164
+ const pattern = storeGlob(STORE.files[0], opts.nativeSessionId);
165
+ const refs = [];
166
+ for (const rel of await walkFiles(root)) {
167
+ if (!pattern.test(rel))
168
+ continue;
169
+ const path = join(root, rel);
170
+ const mtime = await mtimeMs(path);
171
+ if (opts.sinceMs && mtime < opts.sinceMs)
172
+ continue;
173
+ const header = await headRecord(path, (r) => r.type === 'session', 1);
174
+ const cwd = header ? str(header.cwd) : null;
175
+ if (opts.cwd && (cwd === null || !cwd.startsWith(opts.cwd)))
176
+ continue;
177
+ const id = (header && str(header.id)) ?? idFromName(rel);
178
+ if (opts.nativeSessionId && id !== opts.nativeSessionId && idFromName(rel) !== opts.nativeSessionId)
179
+ continue;
180
+ refs.push({ harness: HARNESS, format: FORMAT, nativeSessionId: id, path, files: [path], home, cwd, mtimeMs: mtime, parentNativeSessionId: null });
181
+ }
182
+ return refs.sort((a, b) => b.mtimeMs - a.mtimeMs);
183
+ }
184
+ export const piReader = {
185
+ harness: HARNESS,
186
+ aliases: ['pi-coding-agent', 'pi-agent'],
187
+ formats: [FORMAT],
188
+ stores: [STORE],
189
+ globs: { session: STORE.files[0] },
190
+ evidence: EVIDENCE[FORMAT],
191
+ locate: (home, opts = {}) => locate(home, opts),
192
+ async read(ref, opts = {}) {
193
+ return (await fold(ref, 'full', opts)).build();
194
+ },
195
+ async summarize(ref, opts = {}) {
196
+ return (await fold(ref, 'summary', opts)).summary();
197
+ },
198
+ };
199
+ export function piRefForFile(path) {
200
+ return { harness: HARNESS, format: FORMAT, nativeSessionId: idFromName(path), path, files: [path], home: null, cwd: null, mtimeMs: 0, parentNativeSessionId: null };
201
+ }