@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.
- package/LICENSE +21 -0
- package/README.md +70 -2
- package/dist/builder.d.ts +116 -0
- package/dist/builder.js +332 -0
- package/dist/capture.d.ts +44 -0
- package/dist/capture.js +210 -0
- package/dist/catalog.d.ts +163 -0
- package/dist/catalog.js +163 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +115 -0
- package/dist/formats/claude-code.d.ts +9 -0
- package/dist/formats/claude-code.js +293 -0
- package/dist/formats/codex.d.ts +8 -0
- package/dist/formats/codex.js +379 -0
- package/dist/formats/factory.d.ts +13 -0
- package/dist/formats/factory.js +155 -0
- package/dist/formats/fold.d.ts +13 -0
- package/dist/formats/fold.js +5 -0
- package/dist/formats/head.d.ts +5 -0
- package/dist/formats/head.js +23 -0
- package/dist/formats/kimi.d.ts +9 -0
- package/dist/formats/kimi.js +205 -0
- package/dist/formats/opencode.d.ts +4 -0
- package/dist/formats/opencode.js +241 -0
- package/dist/formats/pi.d.ts +8 -0
- package/dist/formats/pi.js +201 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +16 -0
- package/dist/input.d.ts +18 -0
- package/dist/input.js +46 -0
- package/dist/projections/chat.d.ts +31 -0
- package/dist/projections/chat.js +79 -0
- package/dist/projections/otlp.d.ts +12 -0
- package/dist/projections/otlp.js +91 -0
- package/dist/projections/turns.d.ts +51 -0
- package/dist/projections/turns.js +136 -0
- package/dist/registry.d.ts +11 -0
- package/dist/registry.js +39 -0
- package/dist/schema.d.ts +270 -0
- package/dist/schema.js +10 -0
- package/dist/source.d.ts +67 -0
- package/dist/source.js +254 -0
- package/dist/stores.d.ts +7 -0
- package/dist/stores.js +46 -0
- package/package.json +53 -3
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex: `~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<thread id>.jsonl`, one
|
|
3
|
+
* `{timestamp, type, payload}` record per line.
|
|
4
|
+
*
|
|
5
|
+
* `response_item` records are the conversation (messages, reasoning, tool calls and their
|
|
6
|
+
* outputs); `event_msg` records are the harness's events (`token_count` after each model response,
|
|
7
|
+
* `error`, `stream_error`, `turn_aborted`, `task_complete`). Codex records the model it was
|
|
8
|
+
* configured with in `turn_context`, not the model the provider answered with, so the served
|
|
9
|
+
* model here has source `turn-context`. Usage is per response (`last_token_usage`) and cumulative
|
|
10
|
+
* (`total_token_usage`); the cumulative counter is the session total.
|
|
11
|
+
*/
|
|
12
|
+
import { basename } from 'node:path';
|
|
13
|
+
import { SessionBuilder, argumentsOf, count, isRecord, sessionError, str, textOf, usageOrNull, } from '../builder.js';
|
|
14
|
+
import { fileRecords } from '../source.js';
|
|
15
|
+
import { runFold } from './fold.js';
|
|
16
|
+
import { EVIDENCE, STORES, mtimeMs, storeGlob, walkFiles } from '../stores.js';
|
|
17
|
+
import { headRecord } from './head.js';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
const FORMAT = 'codex.rollout-jsonl';
|
|
20
|
+
const STORE = STORES[FORMAT];
|
|
21
|
+
const HARNESS = 'codex';
|
|
22
|
+
const ID_IN_NAME = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/u;
|
|
23
|
+
/** Codex counts cached tokens inside `input_tokens`; the normalized input excludes them. */
|
|
24
|
+
function usageOf(value) {
|
|
25
|
+
if (!isRecord(value))
|
|
26
|
+
return null;
|
|
27
|
+
const input = count(value.input_tokens);
|
|
28
|
+
const cached = count(value.cached_input_tokens);
|
|
29
|
+
return usageOrNull({
|
|
30
|
+
input: input === null ? null : Math.max(0, input - (cached ?? 0)),
|
|
31
|
+
output: count(value.output_tokens),
|
|
32
|
+
reasoning: count(value.reasoning_output_tokens),
|
|
33
|
+
cacheRead: cached,
|
|
34
|
+
cacheWrite: count(value.cache_write_input_tokens),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function signature(value) {
|
|
38
|
+
return isRecord(value)
|
|
39
|
+
? ['input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens', 'total_tokens'].map((k) => String(value[k] ?? '')).join(',')
|
|
40
|
+
: '';
|
|
41
|
+
}
|
|
42
|
+
function addTotals(a, b) {
|
|
43
|
+
if (a === null)
|
|
44
|
+
return b;
|
|
45
|
+
if (b === null)
|
|
46
|
+
return a;
|
|
47
|
+
const sum = (x, y) => (x === null && y === null ? null : (x ?? 0) + (y ?? 0));
|
|
48
|
+
return { input: sum(a.input, b.input), output: sum(a.output, b.output), reasoning: sum(a.reasoning, b.reasoning), cacheRead: sum(a.cacheRead, b.cacheRead), cacheWrite: sum(a.cacheWrite, b.cacheWrite) };
|
|
49
|
+
}
|
|
50
|
+
// Text the harness puts in a user-role message: environment context, instructions, shell output.
|
|
51
|
+
const INJECTED_USER_TEXT = /^\s*(?:<[a-z_]+(?:\s[^>]*)?>|# AGENTS\.md instructions)/u;
|
|
52
|
+
function contentParts(content) {
|
|
53
|
+
const parts = [];
|
|
54
|
+
if (!Array.isArray(content))
|
|
55
|
+
return parts;
|
|
56
|
+
for (const item of content) {
|
|
57
|
+
if (!isRecord(item))
|
|
58
|
+
continue;
|
|
59
|
+
if ((item.type === 'input_text' || item.type === 'output_text' || item.type === 'text') && typeof item.text === 'string') {
|
|
60
|
+
parts.push({ type: 'text', text: item.text });
|
|
61
|
+
}
|
|
62
|
+
else if (item.type === 'input_image') {
|
|
63
|
+
parts.push({ type: 'attachment', mediaType: 'image', ref: typeof item.image_url === 'string' && !item.image_url.startsWith('data:') ? item.image_url : 'inline' });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return parts;
|
|
67
|
+
}
|
|
68
|
+
function outputOf(output) {
|
|
69
|
+
if (typeof output === 'string')
|
|
70
|
+
return { output, text: output, isError: false };
|
|
71
|
+
if (isRecord(output)) {
|
|
72
|
+
const content = output.content ?? output.output;
|
|
73
|
+
return { output, text: textOf(content) ?? (typeof content === 'string' ? content : null), isError: output.success === false };
|
|
74
|
+
}
|
|
75
|
+
return { output: output ?? null, text: textOf(output), isError: false };
|
|
76
|
+
}
|
|
77
|
+
const MODEL_OUTPUT = new Set(['message', 'reasoning']);
|
|
78
|
+
export function createFold(ref, mode, stats) {
|
|
79
|
+
const builder = new SessionBuilder(HARNESS, FORMAT, EVIDENCE[FORMAT].servedModel, mode, ref.nativeSessionId, ref.parentNativeSessionId);
|
|
80
|
+
let provider = null;
|
|
81
|
+
let model = null;
|
|
82
|
+
let open = null;
|
|
83
|
+
let openMessage = null;
|
|
84
|
+
let calls = 0;
|
|
85
|
+
let lastTotalSignature = '';
|
|
86
|
+
let lastTotal = null;
|
|
87
|
+
let lastTotalRaw = null;
|
|
88
|
+
let committedTotal = null;
|
|
89
|
+
let aborted = null;
|
|
90
|
+
let sawMeta = false;
|
|
91
|
+
let compacted = 0;
|
|
92
|
+
// A forked rollout replays the parent's history after the parent's own session_meta; that
|
|
93
|
+
// history is copied context, not this session's turns. It ends at this session's first turn,
|
|
94
|
+
// whose (time-ordered, UUIDv7) turn id sorts at or after the session id.
|
|
95
|
+
let forked = false;
|
|
96
|
+
let inherited = false;
|
|
97
|
+
let inheritedRecords = 0;
|
|
98
|
+
const foreignThreads = new Set();
|
|
99
|
+
const openCall = (at) => {
|
|
100
|
+
if (open)
|
|
101
|
+
return open;
|
|
102
|
+
calls += 1;
|
|
103
|
+
open = builder.modelCall({ id: `${builder.nativeSessionId}:response:${calls}`, at, provider, servedModel: model, requestedModel: model, usage: null, costUsd: null, stopReason: null, error: null });
|
|
104
|
+
openMessage = null;
|
|
105
|
+
return open;
|
|
106
|
+
};
|
|
107
|
+
const assistantMessage = (at, call, parts) => {
|
|
108
|
+
if (openMessage === null) {
|
|
109
|
+
openMessage = `${call.id}:message`;
|
|
110
|
+
builder.message({ id: openMessage, role: 'assistant', actor: 'agent', at, modelCallId: call.id, parts });
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
builder.appendParts(openMessage, parts);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const observe = (record) => {
|
|
117
|
+
const payload = isRecord(record.payload) ? record.payload : {};
|
|
118
|
+
if (inherited) {
|
|
119
|
+
const turnId = str(payload.turn_id);
|
|
120
|
+
if (record.type === 'event_msg' && payload.type === 'task_started' && turnId !== null && turnId >= builder.nativeSessionId) {
|
|
121
|
+
inherited = false;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
inheritedRecords += 1;
|
|
125
|
+
if (record.type === 'response_item' && payload.type === 'message') {
|
|
126
|
+
const role = str(payload.role);
|
|
127
|
+
builder.message({ id: str(payload.id) ?? `${builder.nativeSessionId}:inherited:${inheritedRecords}`, role: role === 'assistant' ? 'assistant' : role === 'user' ? 'user' : 'system', actor: 'injected', at: null, modelCallId: null, parts: contentParts(payload.content) });
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const at = builder.seen(record.timestamp);
|
|
133
|
+
switch (record.type) {
|
|
134
|
+
case 'session_meta': {
|
|
135
|
+
if (sawMeta) {
|
|
136
|
+
if (forked)
|
|
137
|
+
inherited = true;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
sawMeta = true;
|
|
141
|
+
const id = str(payload.id);
|
|
142
|
+
if (id && id !== builder.nativeSessionId)
|
|
143
|
+
builder.nativeSessionId = id;
|
|
144
|
+
builder.cwd = str(payload.cwd);
|
|
145
|
+
provider = str(payload.model_provider);
|
|
146
|
+
forked = typeof payload.forked_from_id === 'string';
|
|
147
|
+
const source = isRecord(payload.source) ? payload.source : {};
|
|
148
|
+
const spawn = isRecord(source.subagent) && isRecord(source.subagent.thread_spawn) ? source.subagent.thread_spawn : {};
|
|
149
|
+
const parent = str(payload.parent_thread_id) ?? str(spawn.parent_thread_id) ?? str(payload.forked_from_id);
|
|
150
|
+
if (parent)
|
|
151
|
+
builder.parentNativeSessionId = parent;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case 'turn_context': {
|
|
155
|
+
model = str(payload.model) ?? model;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case 'compacted': {
|
|
159
|
+
compacted += 1;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case 'response_item': {
|
|
163
|
+
const kind = str(payload.type) ?? '';
|
|
164
|
+
if (kind === 'agent_message') {
|
|
165
|
+
// A message from another agent of the same run (a task or a reply); input to this thread.
|
|
166
|
+
open = null;
|
|
167
|
+
builder.message({ id: str(payload.id) ?? `${builder.nativeSessionId}:${stats.line}`, role: 'user', actor: 'subagent-spawn', at, modelCallId: null, parts: contentParts(payload.content) });
|
|
168
|
+
}
|
|
169
|
+
else if (kind === 'message') {
|
|
170
|
+
const role = str(payload.role);
|
|
171
|
+
const parts = contentParts(payload.content);
|
|
172
|
+
if (role === 'assistant') {
|
|
173
|
+
assistantMessage(at, openCall(at), parts);
|
|
174
|
+
}
|
|
175
|
+
else if (role === 'user') {
|
|
176
|
+
open = null;
|
|
177
|
+
const text = parts.find((p) => p.type === 'text');
|
|
178
|
+
const injected = text?.type === 'text' && INJECTED_USER_TEXT.test(text.text);
|
|
179
|
+
builder.message({ id: str(payload.id) ?? `${builder.nativeSessionId}:${stats.line}`, role: 'user', actor: injected ? 'injected' : ref.parentNativeSessionId || builder.parentNativeSessionId ? 'subagent-spawn' : 'human', at, modelCallId: null, parts });
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
builder.message({ id: str(payload.id) ?? `${builder.nativeSessionId}:${stats.line}`, role: 'system', actor: 'injected', at, modelCallId: null, parts });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (kind === 'reasoning') {
|
|
186
|
+
const summary = Array.isArray(payload.summary) ? payload.summary : [];
|
|
187
|
+
const text = summary.map((s) => (isRecord(s) && typeof s.text === 'string' ? s.text : '')).filter(Boolean).join('\n');
|
|
188
|
+
const content = textOf(payload.content);
|
|
189
|
+
const visible = content ?? text;
|
|
190
|
+
assistantMessage(at, openCall(at), [{ type: 'reasoning', text: visible, redacted: visible.length === 0 && payload.encrypted_content != null }]);
|
|
191
|
+
}
|
|
192
|
+
else if (kind.endsWith('_call') && !MODEL_OUTPUT.has(kind)) {
|
|
193
|
+
const callId = str(payload.call_id) ?? str(payload.id) ?? `${builder.nativeSessionId}:call:${stats.line}`;
|
|
194
|
+
const name = str(payload.name) ?? kind.replace(/_call$/u, '');
|
|
195
|
+
const raw = kind === 'function_call' ? payload.arguments : kind === 'custom_tool_call' ? payload.input : (payload.action ?? payload.arguments ?? payload.input);
|
|
196
|
+
const call = openCall(at);
|
|
197
|
+
assistantMessage(at, call, [{ type: 'tool-call', toolCallId: callId }]);
|
|
198
|
+
builder.toolCall({ id: callId, name, ...argumentsOf(raw), at, messageId: openMessage });
|
|
199
|
+
// A web search is answered inside the response; there is no output item.
|
|
200
|
+
if (kind === 'web_search_call')
|
|
201
|
+
builder.toolResult(callId, { output: payload.action ?? null, text: null, isError: payload.status === 'failed', at, messageId: openMessage });
|
|
202
|
+
}
|
|
203
|
+
else if (kind.endsWith('_call_output')) {
|
|
204
|
+
const callId = str(payload.call_id) ?? '';
|
|
205
|
+
const result = outputOf(payload.output);
|
|
206
|
+
const id = `${callId}:output`;
|
|
207
|
+
builder.message({ id, role: 'tool', actor: 'tool-result', at, modelCallId: null, parts: [{ type: 'tool-result', toolCallId: callId }] });
|
|
208
|
+
builder.toolResult(callId, { ...result, at, messageId: id });
|
|
209
|
+
}
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case 'event_msg': {
|
|
213
|
+
const kind = str(payload.type);
|
|
214
|
+
if (kind === 'item_completed') {
|
|
215
|
+
// Items of this thread repeat its response items. Items of another thread are a child
|
|
216
|
+
// thread's activity (a review or a sub-agent) logged in this rollout; that thread is its
|
|
217
|
+
// own session.
|
|
218
|
+
const thread = str(payload.thread_id);
|
|
219
|
+
if (thread && thread !== builder.nativeSessionId && !foreignThreads.has(thread)) {
|
|
220
|
+
foreignThreads.add(thread);
|
|
221
|
+
builder.child(thread, null);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else if (kind === 'token_count') {
|
|
225
|
+
const info = isRecord(payload.info) ? payload.info : null;
|
|
226
|
+
if (info === null)
|
|
227
|
+
break;
|
|
228
|
+
const totalSignature = signature(info.total_token_usage);
|
|
229
|
+
// Codex repeats a token_count without a new response (rate-limit refreshes); a
|
|
230
|
+
// response is counted once, when the cumulative counter moves.
|
|
231
|
+
if (totalSignature !== '' && totalSignature === lastTotalSignature)
|
|
232
|
+
break;
|
|
233
|
+
if (isRecord(info.total_token_usage)) {
|
|
234
|
+
const raw = info.total_token_usage;
|
|
235
|
+
// A resumed process restarts the counter; bank what the previous process reported.
|
|
236
|
+
if (lastTotalRaw && count(raw.total_tokens) !== null && count(lastTotalRaw.total_tokens) !== null && raw.total_tokens < lastTotalRaw.total_tokens) {
|
|
237
|
+
committedTotal = addTotals(committedTotal, lastTotal);
|
|
238
|
+
}
|
|
239
|
+
lastTotalRaw = raw;
|
|
240
|
+
lastTotal = usageOf(raw);
|
|
241
|
+
lastTotalSignature = totalSignature;
|
|
242
|
+
}
|
|
243
|
+
const usage = usageOf(info.last_token_usage);
|
|
244
|
+
if (usage === null)
|
|
245
|
+
break;
|
|
246
|
+
const call = openCall(at);
|
|
247
|
+
call.usage = usage;
|
|
248
|
+
open = null;
|
|
249
|
+
}
|
|
250
|
+
else if (kind === 'error') {
|
|
251
|
+
const message = str(payload.message) ?? 'error';
|
|
252
|
+
const call = openCall(at);
|
|
253
|
+
call.error = sessionError('error', message, at, httpStatusOf(payload));
|
|
254
|
+
open = null;
|
|
255
|
+
}
|
|
256
|
+
else if (kind === 'stream_error') {
|
|
257
|
+
// A dropped stream that Codex retries. It is a model request that returned no answer.
|
|
258
|
+
calls += 1;
|
|
259
|
+
builder.modelCall({ id: `${builder.nativeSessionId}:response:${calls}`, at, provider, servedModel: model, requestedModel: model, usage: null, costUsd: null, stopReason: null, error: sessionError('stream_error', str(payload.message) ?? 'stream error', at, httpStatusOf(payload)) });
|
|
260
|
+
}
|
|
261
|
+
else if (kind === 'turn_aborted') {
|
|
262
|
+
aborted = { at, reason: str(payload.reason) ?? 'aborted' };
|
|
263
|
+
open = null;
|
|
264
|
+
}
|
|
265
|
+
else if (kind === 'task_started') {
|
|
266
|
+
aborted = null;
|
|
267
|
+
open = null;
|
|
268
|
+
}
|
|
269
|
+
else if (kind === 'task_complete') {
|
|
270
|
+
aborted = null;
|
|
271
|
+
open = null;
|
|
272
|
+
// Codex 0.145 records a turn that ended on a failed request only here: the request that
|
|
273
|
+
// failed returned no answer, so it is a model call with that error and no served model.
|
|
274
|
+
if (isRecord(payload.error)) {
|
|
275
|
+
const failure = payload.error;
|
|
276
|
+
calls += 1;
|
|
277
|
+
builder.modelCall({ id: `${builder.nativeSessionId}:response:${calls}`, at, provider, servedModel: null, requestedModel: model, usage: null, costUsd: null, stopReason: null, error: sessionError(str(failure.codex_error_info) ?? 'error', str(failure.message) ?? 'error', at, httpStatusOf(failure)) });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
default:
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const finish = () => {
|
|
287
|
+
builder.source(stats);
|
|
288
|
+
if (compacted > 0)
|
|
289
|
+
builder.gap(`${compacted} compaction records replaced earlier context`);
|
|
290
|
+
if (inheritedRecords > 0)
|
|
291
|
+
builder.gap(`${inheritedRecords} records replay the history of the session this one was forked from`);
|
|
292
|
+
if (foreignThreads.size > 0)
|
|
293
|
+
builder.gap(`${foreignThreads.size} child threads logged their items in this rollout; they are not read as this session`);
|
|
294
|
+
const total = addTotals(committedTotal, lastTotal);
|
|
295
|
+
if (total !== null)
|
|
296
|
+
builder.setSessionUsage(total);
|
|
297
|
+
const last = builder.modelCalls[builder.modelCalls.length - 1];
|
|
298
|
+
builder.inferEnding(aborted && !last?.error ? { at: aborted.at, error: sessionError('turn_aborted', aborted.reason, aborted.at, null) } : undefined);
|
|
299
|
+
return builder;
|
|
300
|
+
};
|
|
301
|
+
return { builder, observe, finish };
|
|
302
|
+
}
|
|
303
|
+
export async function fold(ref, mode, options, source = fileRecords(ref.path, { strict: options.corruption === 'strict', signal: options.signal })) {
|
|
304
|
+
return runFold(createFold(ref, mode, source.stats), source);
|
|
305
|
+
}
|
|
306
|
+
function httpStatusOf(payload) {
|
|
307
|
+
const info = payload.codex_error_info;
|
|
308
|
+
if (isRecord(info)) {
|
|
309
|
+
for (const value of Object.values(info)) {
|
|
310
|
+
if (isRecord(value)) {
|
|
311
|
+
const status = count(value.http_status_code);
|
|
312
|
+
if (status !== null)
|
|
313
|
+
return status;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
async function locate(home, opts) {
|
|
320
|
+
const root = join(home, STORE.root);
|
|
321
|
+
const pattern = storeGlob(STORE.files[0], opts.nativeSessionId);
|
|
322
|
+
const refs = [];
|
|
323
|
+
for (const rel of await walkFiles(root)) {
|
|
324
|
+
if (!pattern.test(rel))
|
|
325
|
+
continue;
|
|
326
|
+
const path = join(root, rel);
|
|
327
|
+
const mtime = await mtimeMs(path);
|
|
328
|
+
if (opts.sinceMs && mtime < opts.sinceMs)
|
|
329
|
+
continue;
|
|
330
|
+
const meta = await headRecord(path, (r) => r.type === 'session_meta', 4);
|
|
331
|
+
const payload = meta && isRecord(meta.payload) ? meta.payload : {};
|
|
332
|
+
const cwd = str(payload.cwd);
|
|
333
|
+
if (opts.cwd && (cwd === null || !cwd.startsWith(opts.cwd)))
|
|
334
|
+
continue;
|
|
335
|
+
const id = str(payload.id) ?? ID_IN_NAME.exec(rel)?.[1] ?? basename(rel, '.jsonl');
|
|
336
|
+
const source = isRecord(payload.source) ? payload.source : {};
|
|
337
|
+
const spawn = isRecord(source.subagent) && isRecord(source.subagent.thread_spawn) ? source.subagent.thread_spawn : {};
|
|
338
|
+
refs.push({
|
|
339
|
+
harness: HARNESS,
|
|
340
|
+
format: FORMAT,
|
|
341
|
+
nativeSessionId: id,
|
|
342
|
+
path,
|
|
343
|
+
files: [path],
|
|
344
|
+
home,
|
|
345
|
+
cwd,
|
|
346
|
+
mtimeMs: mtime,
|
|
347
|
+
parentNativeSessionId: str(payload.parent_thread_id) ?? str(spawn.parent_thread_id),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return refs.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
351
|
+
}
|
|
352
|
+
export const codexReader = {
|
|
353
|
+
harness: HARNESS,
|
|
354
|
+
aliases: ['codex-acp', 'codex-cli'],
|
|
355
|
+
formats: [FORMAT],
|
|
356
|
+
stores: [STORE],
|
|
357
|
+
globs: { session: STORE.files[0] },
|
|
358
|
+
evidence: EVIDENCE[FORMAT],
|
|
359
|
+
locate: (home, opts = {}) => locate(home, opts),
|
|
360
|
+
async read(ref, opts = {}) {
|
|
361
|
+
return (await fold(ref, 'full', opts)).build();
|
|
362
|
+
},
|
|
363
|
+
async summarize(ref, opts = {}) {
|
|
364
|
+
return (await fold(ref, 'summary', opts)).summary();
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
export function codexRefForFile(path) {
|
|
368
|
+
return {
|
|
369
|
+
harness: HARNESS,
|
|
370
|
+
format: FORMAT,
|
|
371
|
+
nativeSessionId: ID_IN_NAME.exec(path)?.[1] ?? basename(path, '.jsonl'),
|
|
372
|
+
path,
|
|
373
|
+
files: [path],
|
|
374
|
+
home: null,
|
|
375
|
+
cwd: null,
|
|
376
|
+
mtimeMs: 0,
|
|
377
|
+
parentNativeSessionId: null,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { SessionBuilder, type BuildMode } from '../builder.js';
|
|
2
|
+
import type { HarnessSessionReader, ReadOptions, SessionRef, TokenUsage } from '../schema.js';
|
|
3
|
+
import { type RecordSource, type SourceStats } from '../source.js';
|
|
4
|
+
import { type SessionFold } from './fold.js';
|
|
5
|
+
interface FactorySettings {
|
|
6
|
+
model: string | null;
|
|
7
|
+
usage: TokenUsage | null;
|
|
8
|
+
}
|
|
9
|
+
export declare function createFold(ref: SessionRef, mode: BuildMode, stats: SourceStats, settings?: FactorySettings): SessionFold;
|
|
10
|
+
export declare function fold(ref: SessionRef, mode: BuildMode, options: ReadOptions, source?: RecordSource): Promise<SessionBuilder>;
|
|
11
|
+
export declare const factoryReader: HarnessSessionReader;
|
|
12
|
+
export declare function factoryRefForFile(path: string): SessionRef;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factory Droid: `~/.factory/sessions/<encoded cwd>/<session id>.jsonl` plus a
|
|
3
|
+
* `<session id>.settings.json` beside it.
|
|
4
|
+
*
|
|
5
|
+
* The JSONL holds a `session_start` line (id, cwd) and `message` lines whose `message.content[]`
|
|
6
|
+
* carries Anthropic-style blocks (`text`, `thinking`, `tool_use`, `tool_result`). Each assistant
|
|
7
|
+
* line is one model response. The transcript records no model and no per-response usage: the
|
|
8
|
+
* settings file holds the configured model and the session's token totals, so the model here has
|
|
9
|
+
* source `turn-context` (configured, not served) and usage is the session total.
|
|
10
|
+
*/
|
|
11
|
+
import { readFile } from 'node:fs/promises';
|
|
12
|
+
import { basename, join } from 'node:path';
|
|
13
|
+
import { SessionBuilder, count, isRecord, str, textOf, usageOrNull } from '../builder.js';
|
|
14
|
+
import { fileRecords, isMissing } from '../source.js';
|
|
15
|
+
import { EVIDENCE, STORES, mtimeMs, storeGlob, walkFiles } from '../stores.js';
|
|
16
|
+
import { runFold } from './fold.js';
|
|
17
|
+
import { headRecord } from './head.js';
|
|
18
|
+
const FORMAT = 'factory.session-jsonl';
|
|
19
|
+
const STORE = STORES[FORMAT];
|
|
20
|
+
const HARNESS = 'factory-droids';
|
|
21
|
+
function settingsOf(value) {
|
|
22
|
+
if (!isRecord(value))
|
|
23
|
+
return { model: null, usage: null };
|
|
24
|
+
const tokens = isRecord(value.tokenUsage) ? value.tokenUsage : null;
|
|
25
|
+
return {
|
|
26
|
+
model: str(value.model),
|
|
27
|
+
usage: tokens === null ? null : usageOrNull({
|
|
28
|
+
input: count(tokens.inputTokens),
|
|
29
|
+
output: count(tokens.outputTokens),
|
|
30
|
+
reasoning: count(tokens.thinkingTokens),
|
|
31
|
+
cacheRead: count(tokens.cacheReadTokens),
|
|
32
|
+
cacheWrite: count(tokens.cacheCreationTokens),
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const INJECTED = /^\s*(?:<system-reminder>|<[a-z-]+>)/u;
|
|
37
|
+
export function createFold(ref, mode, stats, settings = { model: null, usage: null }) {
|
|
38
|
+
const builder = new SessionBuilder(HARNESS, FORMAT, EVIDENCE[FORMAT].servedModel, mode, ref.nativeSessionId, ref.parentNativeSessionId);
|
|
39
|
+
const observe = (record) => {
|
|
40
|
+
if (record.type === 'session_start') {
|
|
41
|
+
if (typeof record.id === 'string')
|
|
42
|
+
builder.nativeSessionId = record.id;
|
|
43
|
+
builder.cwd = str(record.cwd);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (record.type !== 'message' || !isRecord(record.message))
|
|
47
|
+
return;
|
|
48
|
+
const at = builder.seen(record.timestamp);
|
|
49
|
+
const id = str(record.id) ?? `${builder.nativeSessionId}:${stats.line}`;
|
|
50
|
+
const message = record.message;
|
|
51
|
+
const content = Array.isArray(message.content) ? message.content.filter(isRecord) : [];
|
|
52
|
+
if (message.role === 'assistant') {
|
|
53
|
+
const parts = [];
|
|
54
|
+
builder.modelCall({ id, at, provider: null, servedModel: settings.model, requestedModel: settings.model, usage: null, costUsd: null, stopReason: null, error: null });
|
|
55
|
+
for (const block of content) {
|
|
56
|
+
if (block.type === 'text' && typeof block.text === 'string')
|
|
57
|
+
parts.push({ type: 'text', text: block.text });
|
|
58
|
+
else if (block.type === 'thinking')
|
|
59
|
+
parts.push({ type: 'reasoning', text: str(block.thinking) ?? '', redacted: !block.thinking });
|
|
60
|
+
else if (block.type === 'tool_use' && typeof block.id === 'string') {
|
|
61
|
+
parts.push({ type: 'tool-call', toolCallId: block.id });
|
|
62
|
+
builder.toolCall({ id: block.id, name: str(block.name) ?? 'unknown', input: block.input ?? null, inputText: null, at, messageId: id });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
builder.message({ id, role: 'assistant', actor: 'agent', at, modelCallId: id, parts });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const toolParts = [];
|
|
69
|
+
const textParts = [];
|
|
70
|
+
for (const block of content) {
|
|
71
|
+
if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
|
|
72
|
+
toolParts.push({ type: 'tool-result', toolCallId: block.tool_use_id });
|
|
73
|
+
builder.toolResult(block.tool_use_id, { output: block.content ?? null, text: textOf(block.content), isError: block.is_error === true, at, messageId: `${id}:tool` });
|
|
74
|
+
}
|
|
75
|
+
else if (block.type === 'text' && typeof block.text === 'string') {
|
|
76
|
+
textParts.push({ type: 'text', text: block.text });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (toolParts.length > 0)
|
|
80
|
+
builder.message({ id: `${id}:tool`, role: 'tool', actor: 'tool-result', at, modelCallId: null, parts: toolParts });
|
|
81
|
+
if (textParts.length > 0) {
|
|
82
|
+
const first = textParts[0];
|
|
83
|
+
const injected = id.startsWith('context-') || (first?.type === 'text' && INJECTED.test(first.text));
|
|
84
|
+
builder.message({ id, role: 'user', actor: injected ? 'injected' : ref.parentNativeSessionId ? 'subagent-spawn' : 'human', at, modelCallId: null, parts: textParts });
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const finish = () => {
|
|
88
|
+
builder.source(stats);
|
|
89
|
+
// Factory records only the session's token totals, in its settings file.
|
|
90
|
+
if (settings.usage !== null)
|
|
91
|
+
builder.setSessionUsage(settings.usage);
|
|
92
|
+
builder.inferEnding();
|
|
93
|
+
return builder;
|
|
94
|
+
};
|
|
95
|
+
return { builder, observe, finish };
|
|
96
|
+
}
|
|
97
|
+
async function readSettings(path) {
|
|
98
|
+
try {
|
|
99
|
+
return settingsOf(JSON.parse(await readFile(path.replace(/\.jsonl$/u, '.settings.json'), 'utf8')));
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (isMissing(error) || error instanceof SyntaxError)
|
|
103
|
+
return { model: null, usage: null };
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export async function fold(ref, mode, options, source = fileRecords(ref.path, { strict: options.corruption === 'strict', signal: options.signal })) {
|
|
108
|
+
return runFold(createFold(ref, mode, source.stats, await readSettings(ref.path)), source);
|
|
109
|
+
}
|
|
110
|
+
async function locate(home, opts) {
|
|
111
|
+
const root = join(home, STORE.root);
|
|
112
|
+
const pattern = storeGlob(STORE.files[0], opts.nativeSessionId);
|
|
113
|
+
const refs = [];
|
|
114
|
+
for (const rel of await walkFiles(root)) {
|
|
115
|
+
if (!pattern.test(rel))
|
|
116
|
+
continue;
|
|
117
|
+
const path = join(root, rel);
|
|
118
|
+
const mtime = await mtimeMs(path);
|
|
119
|
+
if (opts.sinceMs && mtime < opts.sinceMs)
|
|
120
|
+
continue;
|
|
121
|
+
const settings = path.replace(/\.jsonl$/u, '.settings.json');
|
|
122
|
+
refs.push({ harness: HARNESS, format: FORMAT, nativeSessionId: basename(path, '.jsonl'), path, files: (await mtimeMs(settings)) > 0 ? [path, settings] : [path], home, cwd: null, mtimeMs: mtime, parentNativeSessionId: null });
|
|
123
|
+
}
|
|
124
|
+
const filtered = [];
|
|
125
|
+
for (const ref of refs) {
|
|
126
|
+
if (opts.cwd) {
|
|
127
|
+
// The directory name encodes the cwd with `/` as `-`; the session_start line holds it exactly.
|
|
128
|
+
const head = await headRecord(ref.path, (r) => r.type === 'session_start', 1);
|
|
129
|
+
const cwd = head ? str(head.cwd) : null;
|
|
130
|
+
ref.cwd = cwd;
|
|
131
|
+
if (!cwd || !cwd.startsWith(opts.cwd))
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
filtered.push(ref);
|
|
135
|
+
}
|
|
136
|
+
return filtered.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
137
|
+
}
|
|
138
|
+
export const factoryReader = {
|
|
139
|
+
harness: HARNESS,
|
|
140
|
+
aliases: ['factory', 'droid'],
|
|
141
|
+
formats: [FORMAT],
|
|
142
|
+
stores: [STORE],
|
|
143
|
+
globs: { session: STORE.files[0] },
|
|
144
|
+
evidence: EVIDENCE[FORMAT],
|
|
145
|
+
locate: (home, opts = {}) => locate(home, opts),
|
|
146
|
+
async read(ref, opts = {}) {
|
|
147
|
+
return (await fold(ref, 'full', opts)).build();
|
|
148
|
+
},
|
|
149
|
+
async summarize(ref, opts = {}) {
|
|
150
|
+
return (await fold(ref, 'summary', opts)).summary();
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
export function factoryRefForFile(path) {
|
|
154
|
+
return { harness: HARNESS, format: FORMAT, nativeSessionId: basename(path, '.jsonl'), path, files: [path], home: null, cwd: null, mtimeMs: 0, parentNativeSessionId: null };
|
|
155
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SessionBuilder } from '../builder.js';
|
|
2
|
+
import type { RecordSource } from '../source.js';
|
|
3
|
+
/**
|
|
4
|
+
* One format's fold, driven by whoever has the records: `observe` takes each record in order and
|
|
5
|
+
* `finish` closes the session. Reading a file drives it asynchronously; text or parsed records in
|
|
6
|
+
* memory drive it synchronously. Either way it is the same fold.
|
|
7
|
+
*/
|
|
8
|
+
export interface SessionFold {
|
|
9
|
+
builder: SessionBuilder;
|
|
10
|
+
observe(record: Record<string, unknown>): void;
|
|
11
|
+
finish(): SessionBuilder;
|
|
12
|
+
}
|
|
13
|
+
export declare function runFold(fold: SessionFold, source: RecordSource): Promise<SessionBuilder>;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The first record among a session file's first `limit` records that satisfies `match`, read
|
|
3
|
+
* without consuming the rest of the file. Used to learn a session's id and cwd while locating.
|
|
4
|
+
*/
|
|
5
|
+
export declare function headRecord(path: string, match: (record: Record<string, unknown>) => boolean, limit?: number): Promise<Record<string, unknown> | null>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { newSourceStats, readJsonlRecords } from '../source.js';
|
|
2
|
+
/**
|
|
3
|
+
* The first record among a session file's first `limit` records that satisfies `match`, read
|
|
4
|
+
* without consuming the rest of the file. Used to learn a session's id and cwd while locating.
|
|
5
|
+
*/
|
|
6
|
+
export async function headRecord(path, match, limit = 64) {
|
|
7
|
+
let seen = 0;
|
|
8
|
+
try {
|
|
9
|
+
for await (const record of readJsonlRecords(path, newSourceStats(path))) {
|
|
10
|
+
if (match(record))
|
|
11
|
+
return record;
|
|
12
|
+
seen += 1;
|
|
13
|
+
if (seen >= limit)
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error?.code === 'ENOENT')
|
|
19
|
+
return null;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
@@ -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 kimiReader: HarnessSessionReader;
|
|
8
|
+
/** A session read from its `wire.jsonl` outside a HOME layout. */
|
|
9
|
+
export declare function kimiRefForFile(path: string): SessionRef;
|