@neta-art/cohub-cli 8.1.1 → 8.3.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/dist/commands/runtime.d.ts +0 -12
- package/dist/commands/runtime.js +129 -119
- package/dist/commands/search.js +3 -9
- package/dist/commands/spaces.js +5 -2
- package/dist/output.d.ts +6 -0
- package/dist/output.js +11 -0
- package/dist/runtime/archive-store.d.ts +4 -0
- package/dist/runtime/archive-store.js +10 -6
- package/dist/runtime/codex-usage.d.ts +0 -2
- package/dist/runtime/codex-usage.js +0 -12
- package/dist/runtime/connection.d.ts +5 -5
- package/dist/runtime/connection.js +27 -16
- package/dist/runtime/harness.d.ts +8 -16
- package/dist/runtime/harness.js +1 -366
- package/dist/runtime/instance.d.ts +8 -1
- package/dist/runtime/instance.js +15 -8
- package/dist/runtime/launch.js +10 -5
- package/dist/runtime/native/adapters.d.ts +32 -0
- package/dist/runtime/native/adapters.js +79 -0
- package/dist/runtime/{native-archive.d.ts → native/archive.d.ts} +4 -3
- package/dist/runtime/{native-archive.js → native/archive.js} +7 -14
- package/dist/runtime/native/attach.d.ts +33 -0
- package/dist/runtime/native/attach.js +103 -0
- package/dist/runtime/native/codex-link.d.ts +40 -0
- package/dist/runtime/native/codex-link.js +249 -0
- package/dist/runtime/native/config.d.ts +16 -0
- package/dist/runtime/native/config.js +24 -0
- package/dist/runtime/native/daemon.d.ts +88 -0
- package/dist/runtime/native/daemon.js +367 -0
- package/dist/runtime/native/execution.d.ts +45 -0
- package/dist/runtime/native/execution.js +337 -0
- package/dist/runtime/native/identity.d.ts +9 -0
- package/dist/runtime/native/identity.js +15 -0
- package/dist/runtime/native/ingest.d.ts +241 -0
- package/dist/runtime/native/ingest.js +823 -0
- package/dist/runtime/native/install.d.ts +9 -0
- package/dist/runtime/native/install.js +105 -0
- package/dist/runtime/native/pi-extension.d.ts +35 -0
- package/dist/runtime/native/pi-extension.js +160 -0
- package/dist/runtime/native/pi-link.d.ts +67 -0
- package/dist/runtime/native/pi-link.js +238 -0
- package/dist/runtime/native/results.d.ts +41 -0
- package/dist/runtime/native/results.js +128 -0
- package/dist/runtime/native/sessions.d.ts +45 -0
- package/dist/runtime/native/sessions.js +92 -0
- package/dist/runtime/native/transcript.d.ts +63 -0
- package/dist/runtime/native/transcript.js +594 -0
- package/dist/runtime/native/translate.d.ts +44 -0
- package/dist/runtime/native/translate.js +263 -0
- package/dist/runtime/native/transport.d.ts +7 -0
- package/dist/runtime/native/transport.js +16 -0
- package/dist/runtime/native/version.d.ts +5 -0
- package/dist/runtime/native/version.js +18 -0
- package/dist/runtime/presentation.d.ts +7 -10
- package/dist/runtime/presentation.js +35 -10
- package/dist/runtime/projection-store.d.ts +6 -13
- package/dist/runtime/projection-store.js +11 -74
- package/dist/runtime/supervisor.d.ts +2 -0
- package/dist/runtime/supervisor.js +36 -86
- package/dist/runtime/turn-projection.d.ts +1 -3
- package/dist/runtime/turn-projection.js +3 -10
- package/package.json +4 -5
- package/dist/runtime/native-attach.d.ts +0 -28
- package/dist/runtime/native-attach.js +0 -47
- package/dist/runtime/native-codex-hook.d.ts +0 -1
- package/dist/runtime/native-codex-hook.js +0 -28
- package/dist/runtime/native-install.d.ts +0 -21
- package/dist/runtime/native-install.js +0 -130
- package/dist/runtime/native-ipc.d.ts +0 -37
- package/dist/runtime/native-ipc.js +0 -118
- package/dist/runtime/native-pi-extension.d.ts +0 -20
- package/dist/runtime/native-pi-extension.js +0 -47
- package/dist/runtime/native-sync-store.d.ts +0 -102
- package/dist/runtime/native-sync-store.js +0 -368
- package/dist/runtime/native-sync.d.ts +0 -50
- package/dist/runtime/native-sync.js +0 -221
- package/dist/runtime/native-transcript.d.ts +0 -35
- package/dist/runtime/native-transcript.js +0 -310
- package/dist/runtime/session-store.d.ts +0 -84
- package/dist/runtime/session-store.js +0 -587
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
2
|
+
import { Transform } from "node:stream";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { link, mkdir, readdir, rm, stat } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { createZstdDecompress } from "node:zlib";
|
|
8
|
+
import { pipeline } from "node:stream/promises";
|
|
9
|
+
import { piContent } from "../harness.js";
|
|
10
|
+
import { codexTokenTotals, codexUsage } from "../codex-usage.js";
|
|
11
|
+
import { record } from "../json-rpc.js";
|
|
12
|
+
const text = (value) => typeof value === "string" ? value : "";
|
|
13
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
14
|
+
const iso = (value) => {
|
|
15
|
+
const date = new Date(typeof value === "number" || typeof value === "string" ? value : 0);
|
|
16
|
+
if (!Number.isFinite(date.getTime()))
|
|
17
|
+
throw new Error("Invalid native timestamp");
|
|
18
|
+
return date.toISOString();
|
|
19
|
+
};
|
|
20
|
+
const missing = (error) => error?.code === "ENOENT";
|
|
21
|
+
export const NATIVE_MAX_RECORD_BYTES = 256 * 1024 * 1024;
|
|
22
|
+
export const NATIVE_MAX_TRANSCRIPT_BYTES = 1024 * 1024 * 1024;
|
|
23
|
+
const NATIVE_HEADER_MAX_BYTES = 1024 * 1024;
|
|
24
|
+
const CODEX_HISTORY_MAX_DEPTH = 64;
|
|
25
|
+
const CODEX_ROLLOUT_INDEX_TTL_MS = 2_000;
|
|
26
|
+
const CODEX_ROLLOUT_NAME = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-f-]{36})(?:_([0-9a-f-]{36}))?\.(?:jsonl|jsonl\.zst)$/;
|
|
27
|
+
const codexHomeDirectory = () => process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
|
|
28
|
+
export const codexSessionsDirectory = () => join(codexHomeDirectory(), "sessions");
|
|
29
|
+
export const codexArchivedSessionsDirectory = () => join(codexHomeDirectory(), "archived_sessions");
|
|
30
|
+
/** Ordinary rollout names encode one id; reverted threads append `_rollout-id`. */
|
|
31
|
+
export function codexRolloutIdOf(fileName) {
|
|
32
|
+
const match = CODEX_ROLLOUT_NAME.exec(fileName);
|
|
33
|
+
return match ? match[2] ?? match[1] ?? null : null;
|
|
34
|
+
}
|
|
35
|
+
function parseCodexHistoryBase(value) {
|
|
36
|
+
if (value == null)
|
|
37
|
+
return undefined;
|
|
38
|
+
const base = record(value);
|
|
39
|
+
const rolloutId = text(base.thread_id);
|
|
40
|
+
const endOrdinal = base.end_ordinal_exclusive, endBytes = base.end_byte_offset;
|
|
41
|
+
if (!rolloutId || typeof endOrdinal !== "number" || !Number.isSafeInteger(endOrdinal) || endOrdinal < 1
|
|
42
|
+
|| typeof endBytes !== "number" || !Number.isSafeInteger(endBytes) || endBytes < 1)
|
|
43
|
+
throw new Error("Codex history reference is invalid; retain the original files");
|
|
44
|
+
return { rolloutId, endOrdinal, endBytes };
|
|
45
|
+
}
|
|
46
|
+
/** Read exactly the first JSONL line of a stream without re-concatenating per chunk. */
|
|
47
|
+
async function readFirstLine(stream) {
|
|
48
|
+
const chunks = [];
|
|
49
|
+
let buffered = 0;
|
|
50
|
+
for await (const chunk of stream) {
|
|
51
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
52
|
+
chunks.push(bytes);
|
|
53
|
+
buffered += bytes.length;
|
|
54
|
+
const newline = bytes.indexOf(10);
|
|
55
|
+
if (newline >= 0) {
|
|
56
|
+
const buffer = Buffer.concat(chunks, buffered);
|
|
57
|
+
// The newline position is chunk-relative; offset it into the concatenated line.
|
|
58
|
+
const line = buffer.subarray(0, buffered - bytes.length + newline);
|
|
59
|
+
return record(JSON.parse(line.toString("utf8")));
|
|
60
|
+
}
|
|
61
|
+
if (buffered > NATIVE_HEADER_MAX_BYTES)
|
|
62
|
+
throw new Error("Native session header is too large");
|
|
63
|
+
}
|
|
64
|
+
throw new Error("Native transcript is empty");
|
|
65
|
+
}
|
|
66
|
+
async function readCodexHeader(path, signal) {
|
|
67
|
+
const value = await readFirstLine(codexRolloutBytes(path, null, signal));
|
|
68
|
+
const payload = record(value.payload);
|
|
69
|
+
const threadId = text(payload.id), cwd = text(payload.cwd);
|
|
70
|
+
if (value.type !== "session_meta" || !threadId || !cwd)
|
|
71
|
+
throw new Error("Invalid native session header");
|
|
72
|
+
const historyBase = parseCodexHistoryBase(payload.history_base);
|
|
73
|
+
return { threadId, cwd, ...(historyBase ? { historyBase } : {}) };
|
|
74
|
+
}
|
|
75
|
+
/** Read only the session header before deciding whether a global Codex file belongs to this project. */
|
|
76
|
+
export async function readNativeTranscriptHeader(path, harness, signal) {
|
|
77
|
+
if (harness === "pi") {
|
|
78
|
+
const value = await readFirstLine(createReadStream(path, { signal }));
|
|
79
|
+
if (value.type !== "session" || !text(value.id) || !text(value.cwd))
|
|
80
|
+
throw new Error("Invalid native session header");
|
|
81
|
+
return { nativeSessionId: text(value.id), cwd: text(value.cwd) };
|
|
82
|
+
}
|
|
83
|
+
const header = await readCodexHeader(path, signal);
|
|
84
|
+
const rolloutId = codexRolloutIdOf(basename(path)) ?? undefined;
|
|
85
|
+
return { nativeSessionId: header.threadId, cwd: header.cwd, ...(rolloutId ? { rolloutId } : {}), ...(header.historyBase ? { historyBase: header.historyBase } : {}) };
|
|
86
|
+
}
|
|
87
|
+
let codexRolloutIndexCache = null;
|
|
88
|
+
/** Locate rollout files by id across active and archived storage; plain files win over `.zst`. */
|
|
89
|
+
async function scanCodexRollouts(signal) {
|
|
90
|
+
const home = codexHomeDirectory();
|
|
91
|
+
const cached = codexRolloutIndexCache;
|
|
92
|
+
if (cached && cached.home === home && Date.now() - cached.at < CODEX_ROLLOUT_INDEX_TTL_MS)
|
|
93
|
+
return cached.index;
|
|
94
|
+
const index = new Map();
|
|
95
|
+
for (const root of [codexSessionsDirectory(), codexArchivedSessionsDirectory()]) {
|
|
96
|
+
const pending = [root];
|
|
97
|
+
while (pending.length) {
|
|
98
|
+
signal?.throwIfAborted();
|
|
99
|
+
const directory = pending.pop();
|
|
100
|
+
if (!directory)
|
|
101
|
+
continue;
|
|
102
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
103
|
+
if (missing(error))
|
|
104
|
+
return [];
|
|
105
|
+
throw error;
|
|
106
|
+
});
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
const path = join(directory, entry.name);
|
|
109
|
+
if (entry.isDirectory())
|
|
110
|
+
pending.push(path);
|
|
111
|
+
else if (entry.isFile()) {
|
|
112
|
+
const id = codexRolloutIdOf(entry.name);
|
|
113
|
+
if (!id)
|
|
114
|
+
continue;
|
|
115
|
+
const existing = index.get(id);
|
|
116
|
+
if (!existing || (existing.endsWith(".zst") && !path.endsWith(".zst")))
|
|
117
|
+
index.set(id, path);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
codexRolloutIndexCache = { at: Date.now(), home, index };
|
|
123
|
+
return index;
|
|
124
|
+
}
|
|
125
|
+
/** Raw JSONL bytes of a rollout, transparently decompressing `.zst` and honoring a lineage cutoff. */
|
|
126
|
+
export async function* codexRolloutBytes(path, endBytes, signal) {
|
|
127
|
+
// compose (not pipe) so a source error propagates and early exit destroys both streams.
|
|
128
|
+
const stream = path.endsWith(".zst")
|
|
129
|
+
? createReadStream(path, { signal }).compose(createZstdDecompress())
|
|
130
|
+
: createReadStream(path, endBytes != null ? { end: endBytes - 1, signal } : { signal });
|
|
131
|
+
let total = 0, last = -1;
|
|
132
|
+
for await (const chunk of stream) {
|
|
133
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
134
|
+
let cut = bytes;
|
|
135
|
+
if (endBytes != null && total + bytes.length > endBytes)
|
|
136
|
+
cut = bytes.subarray(0, endBytes - total);
|
|
137
|
+
if (cut.length) {
|
|
138
|
+
total += cut.length;
|
|
139
|
+
last = cut.at(-1) ?? last;
|
|
140
|
+
yield cut;
|
|
141
|
+
}
|
|
142
|
+
if (endBytes != null && total >= endBytes)
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
if (endBytes != null) {
|
|
146
|
+
if (total < endBytes)
|
|
147
|
+
throw new Error("Codex history prefix is past the end of its source rollout; retain the original files");
|
|
148
|
+
if (last !== 10)
|
|
149
|
+
throw new Error("Codex history prefix does not end at a record boundary; retain the original files");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Cache location for a rollout's decompressed copy; shared by capture and archival. */
|
|
153
|
+
export function codexRolloutCachePath(source, cacheDirectory) {
|
|
154
|
+
return join(cacheDirectory, `${createHash("sha256").update(source).digest("hex")}.jsonl`);
|
|
155
|
+
}
|
|
156
|
+
/** Archives need plain JSONL bytes; decompress a cold `.zst` rollout into a cache once. */
|
|
157
|
+
export async function ensurePlainCodexRollout(path, cacheDirectory, signal, maxBytes = NATIVE_MAX_TRANSCRIPT_BYTES) {
|
|
158
|
+
if (!path.endsWith(".zst"))
|
|
159
|
+
return path;
|
|
160
|
+
const target = codexRolloutCachePath(path, cacheDirectory);
|
|
161
|
+
const exists = async () => await stat(target).then(() => true, (error) => { if (missing(error))
|
|
162
|
+
return false; throw error; });
|
|
163
|
+
if (await exists())
|
|
164
|
+
return target;
|
|
165
|
+
await mkdir(cacheDirectory, { recursive: true, mode: 0o700 });
|
|
166
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
167
|
+
try {
|
|
168
|
+
// Bound decompression: a corrupt or hostile archive must not fill the disk.
|
|
169
|
+
let written = 0;
|
|
170
|
+
const bounded = new Transform({
|
|
171
|
+
transform(chunk, _encoding, push) {
|
|
172
|
+
written += chunk.length;
|
|
173
|
+
if (written > maxBytes) {
|
|
174
|
+
push(new Error("Decompressed rollout exceeds the local limit"));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
push(null, chunk);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
await pipeline(createReadStream(path, { signal }), createZstdDecompress(), bounded, createWriteStream(temporary, { flags: "wx", mode: 0o600 }));
|
|
181
|
+
await link(temporary, target).catch((error) => { if (error.code !== "EEXIST")
|
|
182
|
+
throw error; });
|
|
183
|
+
return target;
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
await rm(temporary, { force: true });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Newline framing with exact byte offsets and rolling checksums; a partial tail stays unparsed. */
|
|
190
|
+
async function readTranscriptLines(stream) {
|
|
191
|
+
const lines = [];
|
|
192
|
+
const prefixes = new Map();
|
|
193
|
+
let fragments = [], pendingBytes = 0, offset = 0;
|
|
194
|
+
const checksum = createHash("sha256");
|
|
195
|
+
const append = (bytes) => {
|
|
196
|
+
if (!bytes.length)
|
|
197
|
+
return;
|
|
198
|
+
fragments.push(bytes);
|
|
199
|
+
pendingBytes += bytes.length;
|
|
200
|
+
checksum.update(bytes);
|
|
201
|
+
if (pendingBytes > NATIVE_MAX_RECORD_BYTES)
|
|
202
|
+
throw new Error("Native record exceeds the 256 MiB local parse limit; original retained");
|
|
203
|
+
if (offset + pendingBytes > NATIVE_MAX_TRANSCRIPT_BYTES)
|
|
204
|
+
throw new Error("Native transcript exceeds the 1 GiB local parse limit; original retained");
|
|
205
|
+
};
|
|
206
|
+
for await (const chunk of stream) {
|
|
207
|
+
let start = 0;
|
|
208
|
+
for (let end = chunk.indexOf(10); end >= 0; end = chunk.indexOf(10, start)) {
|
|
209
|
+
append(chunk.subarray(start, end + 1));
|
|
210
|
+
// Concatenate once per record, not once per read chunk (large base64 images stay linear).
|
|
211
|
+
const bytes = fragments.length === 1 ? fragments[0] : Buffer.concat(fragments, pendingBytes);
|
|
212
|
+
const line = bytes.subarray(0, bytes.length - 1);
|
|
213
|
+
const startBytes = offset;
|
|
214
|
+
offset += pendingBytes;
|
|
215
|
+
fragments = [];
|
|
216
|
+
pendingBytes = 0;
|
|
217
|
+
const sha256 = checksum.copy().digest("hex");
|
|
218
|
+
prefixes.set(offset, sha256);
|
|
219
|
+
if (line.length) {
|
|
220
|
+
let value;
|
|
221
|
+
try {
|
|
222
|
+
value = record(JSON.parse(line.toString("utf8")));
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
throw new Error("Invalid native JSON record; original retained");
|
|
226
|
+
}
|
|
227
|
+
lines.push({ value, startBytes, endBytes: offset, sha256 });
|
|
228
|
+
}
|
|
229
|
+
start = end + 1;
|
|
230
|
+
}
|
|
231
|
+
append(chunk.subarray(start));
|
|
232
|
+
}
|
|
233
|
+
return { lines, prefixes };
|
|
234
|
+
}
|
|
235
|
+
/** Partial trailing records are retried, never parsed or acknowledged as complete. */
|
|
236
|
+
export async function readNativeTranscript(path, harness, options = {}) {
|
|
237
|
+
if (harness === "pi") {
|
|
238
|
+
const read = await readTranscriptLines(createReadStream(path, { signal: options.signal }));
|
|
239
|
+
if (!read.lines.length)
|
|
240
|
+
throw new Error("Native transcript is empty");
|
|
241
|
+
return { ...parsePiTranscript(read.lines, options), prefixes: read.prefixes };
|
|
242
|
+
}
|
|
243
|
+
return await readCodexTranscript(path, options);
|
|
244
|
+
}
|
|
245
|
+
function parsePiTranscript(lines, options) {
|
|
246
|
+
const header = lines[0]?.value ?? {};
|
|
247
|
+
if (header.type !== "session" || !text(header.id))
|
|
248
|
+
throw new Error("Invalid Pi session");
|
|
249
|
+
const entries = new Map(lines.slice(1).filter((line) => text(line.value.id)).map((line) => [text(line.value.id), line]));
|
|
250
|
+
const branch = [];
|
|
251
|
+
let leaf = options.leafId ?? (lines.length > 1 ? text(lines.at(-1)?.value.id) : "");
|
|
252
|
+
const visited = new Set();
|
|
253
|
+
while (leaf) {
|
|
254
|
+
if (visited.has(leaf))
|
|
255
|
+
throw new Error("Cyclic Pi history");
|
|
256
|
+
visited.add(leaf);
|
|
257
|
+
const entry = entries.get(leaf);
|
|
258
|
+
if (!entry)
|
|
259
|
+
throw new Error("Pi parent history is missing");
|
|
260
|
+
branch.push(entry);
|
|
261
|
+
leaf = text(entry.value.parentId);
|
|
262
|
+
}
|
|
263
|
+
branch.reverse();
|
|
264
|
+
const turns = [];
|
|
265
|
+
let cloudSessionId = text(record(header.cohub).sessionId) || text(record(header.affinity).sessionId);
|
|
266
|
+
let current = null;
|
|
267
|
+
let messages = [];
|
|
268
|
+
let pendingMarker = null;
|
|
269
|
+
let completedAt = iso(header.timestamp);
|
|
270
|
+
const finish = (settled) => {
|
|
271
|
+
if (!current)
|
|
272
|
+
return;
|
|
273
|
+
const last = messages.at(-1);
|
|
274
|
+
if (settled)
|
|
275
|
+
current.result = { messages, completedAt, status: !last || ["aborted", "pending", "toolUse"].includes(last.stopReason ?? "") ? "interrupted" : last?.errorMessage || last?.stopReason === "error" ? "failed" : "completed" };
|
|
276
|
+
turns.push(current);
|
|
277
|
+
};
|
|
278
|
+
for (const line of branch) {
|
|
279
|
+
const entry = line.value;
|
|
280
|
+
if (entry.type !== "message") {
|
|
281
|
+
// Cohub writes a marker entry immediately before a user message it sends, so a Turn it
|
|
282
|
+
// started can be matched to the cloud Turn without guessing at byte offsets.
|
|
283
|
+
if (entry.type === "custom" && text(entry.customType) === "cohub.turn")
|
|
284
|
+
pendingMarker = text(record(entry.data).turnId) || null;
|
|
285
|
+
if (current) {
|
|
286
|
+
current.endBytes = line.endBytes;
|
|
287
|
+
current.sha256 = line.sha256;
|
|
288
|
+
current.boundaries[line.endBytes] = line.sha256;
|
|
289
|
+
}
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const message = record(entry.message);
|
|
293
|
+
if (!["user", "assistant", "toolResult"].includes(text(message.role))) {
|
|
294
|
+
// Native-only UI / shell records remain in raw archives; they do not rewrite a settled agent Turn.
|
|
295
|
+
if (current) {
|
|
296
|
+
current.endBytes = line.endBytes;
|
|
297
|
+
current.sha256 = line.sha256;
|
|
298
|
+
current.boundaries[line.endBytes] = line.sha256;
|
|
299
|
+
}
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const timestamp = iso(entry.timestamp ?? message.timestamp);
|
|
303
|
+
if (message.role === "user") {
|
|
304
|
+
finish(true);
|
|
305
|
+
completedAt = timestamp;
|
|
306
|
+
const metaTurnId = text(record(message.meta).turnId);
|
|
307
|
+
const cloudTurnId = metaTurnId || pendingMarker || "";
|
|
308
|
+
pendingMarker = null;
|
|
309
|
+
if (cloudTurnId && !text(record(header.cohub).sessionId) && !text(record(header.affinity).sessionId))
|
|
310
|
+
cloudSessionId = text(record(message.meta).sourceSessionId) || cloudSessionId;
|
|
311
|
+
current = { key: text(entry.id), parentKey: turns.at(-1)?.key ?? null, ...(cloudTurnId ? { cloudTurnId } : {}), userContent: typeof message.content === "string" ? [{ type: "text", text: message.content }] : piContent(message.content), messages: [], startedAt: completedAt, startBytes: line.startBytes, endBytes: line.endBytes, contentEndBytes: line.endBytes, boundaries: {}, sha256: line.sha256, result: null };
|
|
312
|
+
messages = current.messages;
|
|
313
|
+
}
|
|
314
|
+
else if (current && message.role === "assistant") {
|
|
315
|
+
messages.push({ content: piContent(message.content), provider: text(message.provider) || null, model: text(message.model) || null,
|
|
316
|
+
usage: record(message.usage), stopReason: text(message.stopReason) || null, errorMessage: text(message.errorMessage) || null });
|
|
317
|
+
}
|
|
318
|
+
else if (current && message.role === "toolResult") {
|
|
319
|
+
const assistant = messages.at(-1);
|
|
320
|
+
if (!assistant)
|
|
321
|
+
throw new Error("Pi tool result has no assistant Turn");
|
|
322
|
+
assistant.content.push({ type: "tool_result", tool_use_id: text(message.toolCallId), content: typeof message.content === "string" ? message.content : piContent(message.content), is_error: Boolean(message.isError) });
|
|
323
|
+
}
|
|
324
|
+
if (current) {
|
|
325
|
+
current.endBytes = line.endBytes;
|
|
326
|
+
current.contentEndBytes = line.endBytes;
|
|
327
|
+
current.sha256 = line.sha256;
|
|
328
|
+
current.boundaries[line.endBytes] = line.sha256;
|
|
329
|
+
}
|
|
330
|
+
completedAt = timestamp;
|
|
331
|
+
}
|
|
332
|
+
finish(Boolean(options.settled));
|
|
333
|
+
return { nativeSessionId: text(header.id), cwd: text(header.cwd), cloudSessionId: cloudSessionId || undefined, turns };
|
|
334
|
+
}
|
|
335
|
+
function codexContent(value) {
|
|
336
|
+
return list(value).flatMap((item) => {
|
|
337
|
+
const block = record(item);
|
|
338
|
+
if (["input_text", "output_text", "text"].includes(text(block.type)))
|
|
339
|
+
return [{ type: "text", text: text(block.text) }];
|
|
340
|
+
if (block.type === "input_image" && text(block.image_url)) {
|
|
341
|
+
const data = /^data:([^;]+);base64,(.*)$/s.exec(text(block.image_url));
|
|
342
|
+
return [{ type: "image", source: data ? { type: "base64", media_type: data[1] ?? "image/png", data: data[2] ?? "" } : { type: "url", url: text(block.image_url) } }];
|
|
343
|
+
}
|
|
344
|
+
return [];
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
/** Follow `history_base` pointers from a live rollout to its immutable ancestors, oldest first. */
|
|
348
|
+
async function resolveCodexLineage(leafPath, signal) {
|
|
349
|
+
const segments = [];
|
|
350
|
+
const visited = new Set();
|
|
351
|
+
let path = leafPath, endBytes = null, endOrdinal = null;
|
|
352
|
+
let leafCwd = null;
|
|
353
|
+
for (;;) {
|
|
354
|
+
if (segments.length >= CODEX_HISTORY_MAX_DEPTH)
|
|
355
|
+
throw new Error("Codex history chain is too deep; retain the original files");
|
|
356
|
+
const header = await readCodexHeader(path, signal);
|
|
357
|
+
// Every segment of one conversation must belong to the same project: a cross-project reference
|
|
358
|
+
// would import another workspace's history into this one.
|
|
359
|
+
if (header.cwd && leafCwd != null && header.cwd !== leafCwd)
|
|
360
|
+
throw new Error("Codex lineage crosses projects; retain the original files");
|
|
361
|
+
leafCwd ??= header.cwd;
|
|
362
|
+
if (!header.historyBase) {
|
|
363
|
+
// No reference: this file is the whole conversation. Non-canonical names stay readable.
|
|
364
|
+
segments.push({ path, rolloutId: codexRolloutIdOf(basename(path)) ?? "", threadId: header.threadId, cwd: header.cwd, endBytes, endOrdinal });
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
const rolloutId = codexRolloutIdOf(basename(path));
|
|
368
|
+
if (!rolloutId)
|
|
369
|
+
throw new Error("Codex rollout file name is not canonical; retain the original file");
|
|
370
|
+
if (visited.has(rolloutId))
|
|
371
|
+
throw new Error("Codex history chain is cyclic; retain the original files");
|
|
372
|
+
visited.add(rolloutId);
|
|
373
|
+
segments.push({ path, rolloutId, threadId: header.threadId, cwd: header.cwd, endBytes, endOrdinal });
|
|
374
|
+
const ancestor = (await scanCodexRollouts(signal)).get(header.historyBase.rolloutId);
|
|
375
|
+
if (!ancestor)
|
|
376
|
+
throw new Error(`Codex history source ${header.historyBase.rolloutId} is missing; retain the original files`);
|
|
377
|
+
path = ancestor;
|
|
378
|
+
endBytes = header.historyBase.endBytes;
|
|
379
|
+
endOrdinal = header.historyBase.endOrdinal;
|
|
380
|
+
}
|
|
381
|
+
return segments.reverse();
|
|
382
|
+
}
|
|
383
|
+
function parseCodexLines(lines, segment) {
|
|
384
|
+
const header = lines[0]?.value ?? {};
|
|
385
|
+
const metadata = record(header.payload);
|
|
386
|
+
if (header.type !== "session_meta" || !text(metadata.id))
|
|
387
|
+
throw new Error("Invalid Codex session");
|
|
388
|
+
if (metadata.fork_source != null)
|
|
389
|
+
throw new Error("Legacy Codex fork source is unsupported; retain the original files");
|
|
390
|
+
const turns = [];
|
|
391
|
+
let current = null;
|
|
392
|
+
let messages = [];
|
|
393
|
+
let model = null;
|
|
394
|
+
let cloudSessionId = text(record(metadata.cohub).sessionId);
|
|
395
|
+
let provider = text(metadata.model_provider) || null;
|
|
396
|
+
let userFromResponse = false;
|
|
397
|
+
const usageByTurn = new Map();
|
|
398
|
+
const assistant = () => {
|
|
399
|
+
let last = messages.at(-1);
|
|
400
|
+
if (!last) {
|
|
401
|
+
last = { content: [], model, provider };
|
|
402
|
+
messages.push(last);
|
|
403
|
+
}
|
|
404
|
+
return last;
|
|
405
|
+
};
|
|
406
|
+
for (const line of lines.slice(1)) {
|
|
407
|
+
const entry = line.value, payload = record(entry.payload);
|
|
408
|
+
if (entry.type === "turn_context") {
|
|
409
|
+
model = text(payload.model) || model;
|
|
410
|
+
provider = text(payload.model_provider) || provider;
|
|
411
|
+
}
|
|
412
|
+
if (entry.type === "event_msg" && ["turn_started", "task_started"].includes(text(payload.type))) {
|
|
413
|
+
if (current)
|
|
414
|
+
turns.push(current);
|
|
415
|
+
current = { key: text(payload.turn_id), parentKey: turns.at(-1)?.key ?? null, userContent: [], messages: [], startedAt: iso(entry.timestamp), startBytes: line.startBytes, endBytes: line.endBytes, contentEndBytes: line.endBytes, boundaries: {}, sha256: line.sha256, result: null, path: segment.path, rolloutId: segment.rolloutId };
|
|
416
|
+
if (!current.key)
|
|
417
|
+
throw new Error("Codex Turn identity is missing");
|
|
418
|
+
messages = current.messages;
|
|
419
|
+
userFromResponse = false;
|
|
420
|
+
}
|
|
421
|
+
if (entry.type === "token_usage_record") {
|
|
422
|
+
const usage = record(payload.turn_token_usage);
|
|
423
|
+
if (typeof usage.total_tokens === "number")
|
|
424
|
+
usageByTurn.set(text(payload.turn_id), codexUsage(codexTokenTotals({ inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, cachedInputTokens: usage.cached_input_tokens, cacheWriteInputTokens: usage.cache_write_input_tokens, totalTokens: usage.total_tokens })));
|
|
425
|
+
}
|
|
426
|
+
if (!current) {
|
|
427
|
+
const previous = turns.at(-1);
|
|
428
|
+
if (previous) {
|
|
429
|
+
previous.endBytes = line.endBytes;
|
|
430
|
+
previous.sha256 = line.sha256;
|
|
431
|
+
previous.boundaries[line.endBytes] = line.sha256;
|
|
432
|
+
}
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
current.endBytes = line.endBytes;
|
|
436
|
+
current.sha256 = line.sha256;
|
|
437
|
+
current.boundaries[line.endBytes] = line.sha256;
|
|
438
|
+
current.contentEndBytes = line.endBytes;
|
|
439
|
+
if (entry.type === "response_item") {
|
|
440
|
+
const cohub = record(record(entry.metadata).cohub);
|
|
441
|
+
if (typeof cohub.turnId === "string") {
|
|
442
|
+
current.cloudTurnId = cohub.turnId;
|
|
443
|
+
if (!text(record(metadata.cohub).sessionId))
|
|
444
|
+
cloudSessionId = text(cohub.sessionId) || cloudSessionId;
|
|
445
|
+
}
|
|
446
|
+
if (payload.type === "message" && payload.role === "user") {
|
|
447
|
+
if (!userFromResponse)
|
|
448
|
+
current.userContent = [];
|
|
449
|
+
current.userContent.push(...codexContent(payload.content));
|
|
450
|
+
userFromResponse = true;
|
|
451
|
+
}
|
|
452
|
+
else if (payload.type === "message" && payload.role === "assistant") {
|
|
453
|
+
const content = codexContent(payload.content);
|
|
454
|
+
if (!messages.length || messages.at(-1)?.content.length)
|
|
455
|
+
messages.push({ content, model, provider });
|
|
456
|
+
else
|
|
457
|
+
assistant().content.push(...content);
|
|
458
|
+
}
|
|
459
|
+
else if (payload.type === "reasoning") {
|
|
460
|
+
const thinking = list(payload.summary).map((item) => text(record(item).text)).join("\n");
|
|
461
|
+
if (thinking)
|
|
462
|
+
assistant().content.push({ type: "thinking", thinking });
|
|
463
|
+
}
|
|
464
|
+
else if (["function_call", "custom_tool_call"].includes(text(payload.type))) {
|
|
465
|
+
let input;
|
|
466
|
+
try {
|
|
467
|
+
input = payload.type === "custom_tool_call" ? { input: payload.input } : record(JSON.parse(text(payload.arguments)));
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
input = { raw: payload.arguments };
|
|
471
|
+
}
|
|
472
|
+
assistant().content.push({ type: "tool_use", id: text(payload.call_id), name: text(payload.name), input });
|
|
473
|
+
}
|
|
474
|
+
else if (["function_call_output", "custom_tool_call_output"].includes(text(payload.type))) {
|
|
475
|
+
assistant().content.push({ type: "tool_result", tool_use_id: text(payload.call_id), content: typeof payload.output === "string" ? payload.output : JSON.stringify(payload.output ?? null) });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (entry.type === "event_msg" && payload.type === "item_completed") {
|
|
479
|
+
// Cohub tags a Turn it starts with its own cloud Turn id, so the file itself records which
|
|
480
|
+
// Turns already belong to a cloud Turn (and must not be ingested twice).
|
|
481
|
+
const item = record(payload.item);
|
|
482
|
+
if (item.type === "UserMessage") {
|
|
483
|
+
const clientId = text(item.client_id);
|
|
484
|
+
if (clientId)
|
|
485
|
+
current.cloudTurnId = clientId;
|
|
486
|
+
// The typed input, without the environment context Codex injects as a user item.
|
|
487
|
+
const typed = codexContent(item.content);
|
|
488
|
+
if (typed.length) {
|
|
489
|
+
current.userContent = typed;
|
|
490
|
+
userFromResponse = true;
|
|
491
|
+
}
|
|
492
|
+
current.userFinal = true;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (entry.type === "event_msg" && payload.type === "user_message") {
|
|
496
|
+
if (!userFromResponse)
|
|
497
|
+
current.userContent = [{ type: "text", text: text(payload.message) }];
|
|
498
|
+
userFromResponse = true;
|
|
499
|
+
current.userFinal = true;
|
|
500
|
+
}
|
|
501
|
+
if (entry.type === "event_msg" && ["turn_complete", "task_complete", "turn_aborted"].includes(text(payload.type))) {
|
|
502
|
+
if (payload.turn_id && payload.turn_id !== current.key)
|
|
503
|
+
throw new Error("Codex Turn boundary mismatch");
|
|
504
|
+
if (!messages.length && text(payload.last_agent_message))
|
|
505
|
+
messages.push({ content: [{ type: "text", text: text(payload.last_agent_message) }], model, provider });
|
|
506
|
+
const errorMessage = text(record(payload.error).message);
|
|
507
|
+
const status = payload.type === "turn_aborted" ? "interrupted" : errorMessage ? "failed" : "completed";
|
|
508
|
+
const final = assistant();
|
|
509
|
+
final.stopReason = status === "interrupted" ? "aborted" : status === "failed" ? "error" : "stop";
|
|
510
|
+
if (errorMessage)
|
|
511
|
+
final.errorMessage = errorMessage;
|
|
512
|
+
current.result = { messages, completedAt: iso(entry.timestamp), status };
|
|
513
|
+
turns.push(current);
|
|
514
|
+
current = null;
|
|
515
|
+
messages = [];
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const settled = current === null;
|
|
519
|
+
if (current && userFromResponse)
|
|
520
|
+
turns.push(current);
|
|
521
|
+
for (const turn of turns) {
|
|
522
|
+
const last = turn.result?.messages.at(-1);
|
|
523
|
+
if (last && usageByTurn.has(turn.key))
|
|
524
|
+
last.usage = usageByTurn.get(turn.key);
|
|
525
|
+
}
|
|
526
|
+
return { turns, ...(cloudSessionId ? { cloudSessionId } : {}), settled };
|
|
527
|
+
}
|
|
528
|
+
const codexSegmentMemo = new Map();
|
|
529
|
+
// Ancestor prefixes are immutable, so the cache is a pure win; parsed objects inflate in memory
|
|
530
|
+
// several-fold over raw bytes, hence a conservative budget.
|
|
531
|
+
const CODEX_MEMO_MAX_BYTES = 128 * 1024 * 1024;
|
|
532
|
+
/** Ancestors are immutable; only their bounded prefixes are worth caching, bounded by bytes. */
|
|
533
|
+
async function parseCodexSegment(segment, signal) {
|
|
534
|
+
const cacheable = segment.endBytes != null;
|
|
535
|
+
const info = cacheable ? await stat(segment.path) : null;
|
|
536
|
+
const key = `${segment.path}|${segment.endBytes ?? "-"}|${segment.endOrdinal ?? "-"}|${info?.size ?? ""}|${info?.mtimeMs ?? ""}`;
|
|
537
|
+
const cached = codexSegmentMemo.get(key);
|
|
538
|
+
if (cached) {
|
|
539
|
+
// Refresh recency: eviction below must spare the segments still in use.
|
|
540
|
+
codexSegmentMemo.delete(key);
|
|
541
|
+
codexSegmentMemo.set(key, cached);
|
|
542
|
+
return cached;
|
|
543
|
+
}
|
|
544
|
+
const read = await readTranscriptLines(codexRolloutBytes(segment.path, segment.endBytes, signal));
|
|
545
|
+
if (!read.lines.length)
|
|
546
|
+
throw new Error("Native transcript is empty");
|
|
547
|
+
const parsed = parseCodexLines(read.lines, segment);
|
|
548
|
+
// A paginated ancestor stamps every record with its ordinal; the referenced prefix must end
|
|
549
|
+
// exactly at the ordinal bound.
|
|
550
|
+
if (segment.endBytes != null && segment.endOrdinal != null) {
|
|
551
|
+
const ordinal = read.lines.at(-1)?.value.ordinal;
|
|
552
|
+
if (typeof ordinal !== "number" || ordinal !== segment.endOrdinal - 1)
|
|
553
|
+
throw new Error("Codex history ordinal bound does not match the prefix; retain the original files");
|
|
554
|
+
}
|
|
555
|
+
const value = { turns: parsed.turns, prefixes: read.prefixes, ...(parsed.cloudSessionId ? { cloudSessionId: parsed.cloudSessionId } : {}), settled: parsed.settled, bytes: segment.endBytes ?? 0 };
|
|
556
|
+
if (cacheable) {
|
|
557
|
+
// Evict oldest entries first until the cached bytes stay bounded.
|
|
558
|
+
let total = [...codexSegmentMemo.values()].reduce((sum, entry) => sum + entry.bytes, 0) + value.bytes;
|
|
559
|
+
for (const [existing, entry] of codexSegmentMemo) {
|
|
560
|
+
if (total <= CODEX_MEMO_MAX_BYTES)
|
|
561
|
+
break;
|
|
562
|
+
total -= entry.bytes;
|
|
563
|
+
codexSegmentMemo.delete(existing);
|
|
564
|
+
}
|
|
565
|
+
codexSegmentMemo.set(key, value);
|
|
566
|
+
}
|
|
567
|
+
return value;
|
|
568
|
+
}
|
|
569
|
+
async function readCodexTranscript(path, options) {
|
|
570
|
+
const segments = await resolveCodexLineage(path, options.signal);
|
|
571
|
+
const files = [];
|
|
572
|
+
let prefixes = new Map();
|
|
573
|
+
for (const [index, segment] of segments.entries()) {
|
|
574
|
+
const parsed = await parseCodexSegment(segment, options.signal);
|
|
575
|
+
// An ancestor contributes exactly its referenced prefix; an in-flight tail would corrupt the boundary.
|
|
576
|
+
if (index < segments.length - 1 && !parsed.settled)
|
|
577
|
+
throw new Error("Codex history boundary is not at a completed Turn; retain the original files");
|
|
578
|
+
files.push({ segment, parsed });
|
|
579
|
+
if (index === segments.length - 1)
|
|
580
|
+
prefixes = parsed.prefixes;
|
|
581
|
+
}
|
|
582
|
+
const leaf = files.at(-1);
|
|
583
|
+
if (!leaf)
|
|
584
|
+
throw new Error("Native transcript is empty");
|
|
585
|
+
const turns = [];
|
|
586
|
+
for (const file of files)
|
|
587
|
+
for (const turn of file.parsed.turns)
|
|
588
|
+
turns.push({ ...turn, parentKey: turns.at(-1)?.key ?? null, sequence: turns.length });
|
|
589
|
+
// The leaf's own cloud binding wins; ancestors only matter when the leaf has none (a native
|
|
590
|
+
// continuation of a previously managed conversation).
|
|
591
|
+
const cloudSessionId = files.at(-1)?.parsed.cloudSessionId ?? files.map((file) => file.parsed.cloudSessionId).find(Boolean);
|
|
592
|
+
return { nativeSessionId: leaf.segment.threadId, cwd: leaf.segment.cwd, ...(cloudSessionId ? { cloudSessionId } : {}), turns, prefixes,
|
|
593
|
+
lineagePaths: files.map((file) => file.segment.path), lineageRolloutIds: files.map((file) => file.segment.rolloutId) };
|
|
594
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ContentBlock, NativeTurnProgress, RuntimeExecutionEvent, RuntimeMessage } from "@neta-art/cohub";
|
|
2
|
+
import { type JsonRecord } from "../json-rpc.js";
|
|
3
|
+
/** Harness events mapped onto Cohub's streaming protocol. */
|
|
4
|
+
export type Translator = {
|
|
5
|
+
push(event: JsonRecord): boolean;
|
|
6
|
+
last(): RuntimeMessage;
|
|
7
|
+
};
|
|
8
|
+
/** Pi emits the same event shapes to its extensions as over RPC. */
|
|
9
|
+
export declare function piTranslator(emit: (event: RuntimeExecutionEvent) => void): Translator;
|
|
10
|
+
/** Codex app-server notifications for one Turn of one thread. */
|
|
11
|
+
export declare function codexTranslator(emit: (event: RuntimeExecutionEvent) => void, identity: {
|
|
12
|
+
provider: string | null;
|
|
13
|
+
model: string | null;
|
|
14
|
+
}): Translator;
|
|
15
|
+
type PreviewMessage = {
|
|
16
|
+
content: ContentBlock[];
|
|
17
|
+
provider: string | null;
|
|
18
|
+
model: string | null;
|
|
19
|
+
};
|
|
20
|
+
/** Where progress goes; `null` while the Turn is not yet recorded, so nothing can be sent yet. */
|
|
21
|
+
export type ProgressSink = (progress: NativeTurnProgress) => Promise<unknown> | null;
|
|
22
|
+
/**
|
|
23
|
+
* The live preview of a Turn a terminal user runs: harness events folded into messages and sent as
|
|
24
|
+
* ephemeral progress, at most once per interval and only from the first message that changed.
|
|
25
|
+
*/
|
|
26
|
+
export declare class LiveProgress {
|
|
27
|
+
private readonly sink;
|
|
28
|
+
private readonly intervalMs;
|
|
29
|
+
private messages;
|
|
30
|
+
private dirtyFrom;
|
|
31
|
+
private synced;
|
|
32
|
+
private timer;
|
|
33
|
+
private inFlight;
|
|
34
|
+
private disposed;
|
|
35
|
+
constructor(sink: ProgressSink, intervalMs: number);
|
|
36
|
+
apply(event: RuntimeExecutionEvent): void;
|
|
37
|
+
replace(messages: PreviewMessage[]): void;
|
|
38
|
+
dispose(): void;
|
|
39
|
+
private touched;
|
|
40
|
+
private schedule;
|
|
41
|
+
private flush;
|
|
42
|
+
private at;
|
|
43
|
+
}
|
|
44
|
+
export {};
|