@agentex/agent 0.0.28 → 0.0.29

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 (53) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +40 -1
  3. package/dist/history/fs.d.ts +36 -0
  4. package/dist/history/fs.d.ts.map +1 -0
  5. package/dist/history/fs.js +266 -0
  6. package/dist/history/fs.js.map +1 -0
  7. package/dist/history/index.d.ts +3 -0
  8. package/dist/history/index.d.ts.map +1 -0
  9. package/dist/history/index.js +2 -0
  10. package/dist/history/index.js.map +1 -0
  11. package/dist/history/types.d.ts +75 -0
  12. package/dist/history/types.d.ts.map +1 -0
  13. package/dist/history/types.js +9 -0
  14. package/dist/history/types.js.map +1 -0
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/providers/claude/history.d.ts +3 -0
  19. package/dist/providers/claude/history.d.ts.map +1 -0
  20. package/dist/providers/claude/history.js +248 -0
  21. package/dist/providers/claude/history.js.map +1 -0
  22. package/dist/providers/claude/index.d.ts.map +1 -1
  23. package/dist/providers/claude/index.js +18 -0
  24. package/dist/providers/claude/index.js.map +1 -1
  25. package/dist/providers/codex/attach.d.ts +2 -2
  26. package/dist/providers/codex/attach.js +4 -4
  27. package/dist/providers/codex/attach.js.map +1 -1
  28. package/dist/providers/codex/history.d.ts +3 -0
  29. package/dist/providers/codex/history.d.ts.map +1 -0
  30. package/dist/providers/codex/history.js +350 -0
  31. package/dist/providers/codex/history.js.map +1 -0
  32. package/dist/providers/codex/index.d.ts.map +1 -1
  33. package/dist/providers/codex/index.js +18 -0
  34. package/dist/providers/codex/index.js.map +1 -1
  35. package/dist/providers/codex/transcript-normalize.d.ts +3 -2
  36. package/dist/providers/codex/transcript-normalize.d.ts.map +1 -1
  37. package/dist/providers/codex/transcript-normalize.js +4 -3
  38. package/dist/providers/codex/transcript-normalize.js.map +1 -1
  39. package/dist/types.d.ts +5 -1
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/types.js.map +1 -1
  42. package/package.json +7 -1
  43. package/src/history/fs.ts +307 -0
  44. package/src/history/index.ts +18 -0
  45. package/src/history/types.ts +104 -0
  46. package/src/index.ts +18 -0
  47. package/src/providers/claude/history.ts +290 -0
  48. package/src/providers/claude/index.ts +18 -0
  49. package/src/providers/codex/attach.ts +4 -4
  50. package/src/providers/codex/history.ts +399 -0
  51. package/src/providers/codex/index.ts +18 -0
  52. package/src/providers/codex/transcript-normalize.ts +4 -3
  53. package/src/types.ts +5 -1
@@ -0,0 +1,399 @@
1
+ import * as path from "node:path";
2
+ import { readdir } from "node:fs/promises";
3
+
4
+ import type { BaseStreamEventFields, StreamEvent } from "../../types.js";
5
+ import type {
6
+ LocalHistoryDiscoverOptions,
7
+ LocalHistoryOps,
8
+ LocalHistoryProbeOptions,
9
+ LocalHistorySession,
10
+ LocalHistoryYield,
11
+ } from "../../history/types.js";
12
+ import {
13
+ ROLLOUT_UUID_RE,
14
+ asRecord,
15
+ asString,
16
+ cleanTitle,
17
+ isDirectory,
18
+ isoDate,
19
+ listJsonlFiles,
20
+ meaningfulHumanText,
21
+ readJsonlRecords,
22
+ recentEligible,
23
+ sourceFingerprint,
24
+ textFromContent,
25
+ } from "../../history/fs.js";
26
+ import { codexLineToStreamEvents } from "./transcript-normalize.js";
27
+ import { parseCodexLine, resolveCodexHome, type CodexTranscriptLine } from "./transcript.js";
28
+
29
+ const MAX_DISCOVERY_SCAN_BYTES = 4 * 1024 * 1024;
30
+ const DEFAULT_PROBE_LIMIT = 100;
31
+ const DISCOVERY_CONCURRENCY = 16;
32
+
33
+ function codexHome(options?: { env?: Record<string, string> }): string {
34
+ return resolveCodexHome(options?.env?.["CODEX_HOME"]);
35
+ }
36
+
37
+ async function listCodexSessionFiles(
38
+ home: string,
39
+ options: { includeArchived: boolean; limit?: number },
40
+ ): Promise<string[]> {
41
+ const cap = Math.max(1, options.limit ?? Number.MAX_SAFE_INTEGER);
42
+ const active = await listJsonlFiles(path.join(home, "sessions"), {
43
+ maxDepth: 5,
44
+ limit: cap,
45
+ accept: (name) => ROLLOUT_UUID_RE.test(name),
46
+ });
47
+ if (!options.includeArchived || active.length >= cap) return active;
48
+ const archived = await listJsonlFiles(path.join(home, "archived_sessions"), {
49
+ maxDepth: 2,
50
+ limit: cap - active.length,
51
+ accept: (name) => ROLLOUT_UUID_RE.test(name),
52
+ });
53
+ return [...active, ...archived];
54
+ }
55
+
56
+ function codexUserText(line: CodexTranscriptLine): string | null {
57
+ if (line.type === "event_msg" && line.payload?.["type"] === "user_message") {
58
+ return meaningfulHumanText(asString(line.payload["message"]));
59
+ }
60
+ // Older unwrapped rollouts have no event_msg mirror.
61
+ if (line.type === "message" && line.raw["role"] === "user") {
62
+ return meaningfulHumanText(textFromContent(
63
+ line.raw["content"],
64
+ new Set(["text", "input_text"]),
65
+ ));
66
+ }
67
+ return null;
68
+ }
69
+
70
+ function isSubagentMeta(payload: Record<string, unknown>): boolean {
71
+ if (asString(payload["parent_thread_id"])) return true;
72
+ if (payload["thread_source"] === "subagent") return true;
73
+ const source = asRecord(payload["source"]);
74
+ return !!source && source["subagent"] !== undefined;
75
+ }
76
+
77
+ function legacyCwd(record: Record<string, unknown>): string | null {
78
+ if (record["type"] !== "message" || record["role"] !== "user") return null;
79
+ const text = textFromContent(record["content"], new Set(["text", "input_text"]));
80
+ const xml = text?.match(/<cwd>([^<]+)<\/cwd>/)?.[1];
81
+ const plain = text?.match(/Current working directory:\s*([^\n\r]+)/)?.[1];
82
+ const candidate = (xml ?? plain)?.trim();
83
+ return candidate && path.isAbsolute(candidate) ? candidate : null;
84
+ }
85
+
86
+ async function loadSessionIndexTitles(home: string): Promise<Map<string, string>> {
87
+ const titles = new Map<string, string>();
88
+ try {
89
+ for await (const line of readJsonlRecords(path.join(home, "session_index.jsonl"))) {
90
+ const id = asString(line.raw["id"]);
91
+ const title = cleanTitle(asString(line.raw["thread_name"]));
92
+ if (id && title) titles.set(id, title);
93
+ }
94
+ } catch {
95
+ // The index is optional and absent on older Codex versions.
96
+ }
97
+ return titles;
98
+ }
99
+
100
+ interface SqliteDatabase {
101
+ prepare(sql: string): { all(): unknown[] };
102
+ close(): void;
103
+ }
104
+
105
+ async function stateDatabasePaths(home: string): Promise<string[]> {
106
+ const paths: string[] = [];
107
+ for (const dir of [home, path.join(home, "sqlite")]) {
108
+ let entries;
109
+ try {
110
+ entries = await readdir(dir, { withFileTypes: true });
111
+ } catch {
112
+ continue;
113
+ }
114
+ for (const entry of entries) {
115
+ if (entry.isFile() && /^state_\d+\.sqlite$/.test(entry.name)) paths.push(path.join(dir, entry.name));
116
+ }
117
+ }
118
+ return paths.sort().reverse();
119
+ }
120
+
121
+ async function loadSqliteTitles(home: string): Promise<Map<string, string>> {
122
+ const titles = new Map<string, string>();
123
+ try {
124
+ // Keep Node 20 compatibility. node:sqlite is used opportunistically on
125
+ // runtimes that provide it and is always opened read-only.
126
+ const moduleName = "node:sqlite";
127
+ const sqlite = await import(moduleName) as unknown as {
128
+ DatabaseSync: new (filePath: string, options: { readOnly: boolean }) => SqliteDatabase;
129
+ };
130
+ for (const filePath of await stateDatabasePaths(home)) {
131
+ let database: SqliteDatabase | null = null;
132
+ try {
133
+ database = new sqlite.DatabaseSync(filePath, { readOnly: true });
134
+ for (const value of database.prepare("SELECT id, title FROM threads WHERE title <> ''").all()) {
135
+ const row = asRecord(value);
136
+ const id = asString(row?.["id"]);
137
+ const title = cleanTitle(asString(row?.["title"]));
138
+ if (id && title && !titles.has(id)) titles.set(id, title);
139
+ }
140
+ if (titles.size > 0) break;
141
+ } catch {
142
+ // Missing, locked, or schema-incompatible indexes are non-fatal.
143
+ } finally {
144
+ database?.close();
145
+ }
146
+ }
147
+ } catch {
148
+ // node:sqlite is unavailable on Node 20.
149
+ }
150
+ return titles;
151
+ }
152
+
153
+ async function loadCodexTitles(home: string): Promise<Map<string, string>> {
154
+ const index = await loadSessionIndexTitles(home);
155
+ // session_index is the least invasive and most portable source. SQLite is
156
+ // a fallback for installations that do not maintain the JSONL index.
157
+ return index.size > 0 ? index : loadSqliteTitles(home);
158
+ }
159
+
160
+ async function inspectCodexSession(
161
+ transcriptPath: string,
162
+ titles: ReadonlyMap<string, string>,
163
+ options: LocalHistoryDiscoverOptions,
164
+ ): Promise<LocalHistorySession | null> {
165
+ let meta: Record<string, unknown> | null = null;
166
+ let legacyMeta: Record<string, unknown> | null = null;
167
+ let metaTimestamp: string | null = null;
168
+ let firstPrompt: string | null = null;
169
+ let fallbackCwd: string | null = null;
170
+
171
+ for await (const record of readJsonlRecords(transcriptPath)) {
172
+ const line = parseCodexLine(record.text);
173
+ if (!line) continue;
174
+ if (line.type === "session_meta" && line.payload) {
175
+ meta ??= line.payload;
176
+ metaTimestamp ??= line.timestamp;
177
+ }
178
+ if (line.type === null && asString(line.raw["id"])) legacyMeta ??= line.raw;
179
+ firstPrompt ??= codexUserText(line);
180
+ fallbackCwd ??= legacyCwd(line.raw);
181
+ if (meta && firstPrompt) break;
182
+ if (record.nextOffset >= MAX_DISCOVERY_SCAN_BYTES) break;
183
+ }
184
+
185
+ const fileId = path.basename(transcriptPath).match(ROLLOUT_UUID_RE)?.[1] ?? null;
186
+ const externalSessionId = asString(meta?.["id"])
187
+ ?? asString(meta?.["session_id"])
188
+ ?? asString(legacyMeta?.["id"])
189
+ ?? fileId;
190
+ if (!externalSessionId) return null;
191
+ if ((options.mainSessionsOnly ?? true) && meta && isSubagentMeta(meta)) return null;
192
+
193
+ const metaCwd = asString(meta?.["cwd"]);
194
+ const cwd = metaCwd && path.isAbsolute(metaCwd) ? metaCwd : fallbackCwd;
195
+ if (!cwd || (options.cwd && path.normalize(cwd) !== path.normalize(options.cwd))) return null;
196
+ const hasUserMessage = firstPrompt !== null;
197
+ if ((options.requireUserMessage ?? true) && !hasUserMessage) return null;
198
+
199
+ const git = asRecord(meta?.["git"]);
200
+ const source = await sourceFingerprint(transcriptPath);
201
+ const archiveState = transcriptPath.split(path.sep).includes("archived_sessions")
202
+ ? "archived" as const
203
+ : "active" as const;
204
+ return {
205
+ version: 1,
206
+ providerType: "codex",
207
+ externalSessionId,
208
+ transcriptPath,
209
+ cwd,
210
+ title: titles.get(externalSessionId) ?? cleanTitle(firstPrompt),
211
+ startedAt: isoDate(meta?.["timestamp"] ?? metaTimestamp ?? legacyMeta?.["timestamp"]),
212
+ updatedAt: new Date(Number(BigInt(source.modifiedAtNs) / 1_000_000n)).toISOString(),
213
+ branch: asString(git?.["branch"]) ?? asString(meta?.["git_branch"]),
214
+ gitOriginUrl: asString(git?.["repository_url"])
215
+ ?? asString(git?.["origin_url"])
216
+ ?? asString(meta?.["git_origin_url"]),
217
+ archiveState,
218
+ hasUserMessage,
219
+ source,
220
+ };
221
+ }
222
+
223
+ function baseFields(
224
+ session: LocalHistorySession,
225
+ line: CodexTranscriptLine,
226
+ eventId: string,
227
+ ): BaseStreamEventFields & { eventId: string } {
228
+ return {
229
+ timestamp: isoDate(line.timestamp, session.startedAt ?? new Date(0).toISOString())!,
230
+ providerType: "codex",
231
+ sessionId: session.externalSessionId,
232
+ messageId: null,
233
+ eventId,
234
+ turnId: null,
235
+ parentToolCallId: null,
236
+ raw: line.raw,
237
+ };
238
+ }
239
+
240
+ function legacyCodexEvents(
241
+ session: LocalHistorySession,
242
+ line: CodexTranscriptLine,
243
+ eventId: string,
244
+ ): Array<StreamEvent & { eventId: string }> {
245
+ if (line.payload) return [];
246
+ const base = baseFields(session, line, eventId);
247
+ if (line.type === "message" && line.raw["role"] === "assistant") {
248
+ const text = textFromContent(line.raw["content"], new Set(["text", "output_text"]));
249
+ return text ? [{ type: "assistant", text, ...base }] : [];
250
+ }
251
+ if (line.type === "reasoning") {
252
+ const text = textFromContent(line.raw["summary"] ?? line.raw["content"], new Set(["text", "summary_text"]));
253
+ return text ? [{ type: "thinking", text, ...base }] : [];
254
+ }
255
+ if (line.type === "function_call") {
256
+ const toolCallId = asString(line.raw["call_id"]) ?? asString(line.raw["id"]);
257
+ const name = asString(line.raw["name"]) ?? "function_call";
258
+ const rawArguments = line.raw["arguments"];
259
+ let input: Record<string, unknown> | string | null = asString(rawArguments);
260
+ if (typeof rawArguments === "string") {
261
+ try {
262
+ const parsed = JSON.parse(rawArguments);
263
+ if (asRecord(parsed)) input = parsed as Record<string, unknown>;
264
+ } catch {
265
+ // Preserve malformed arguments as their original string.
266
+ }
267
+ } else if (asRecord(rawArguments)) {
268
+ input = rawArguments as Record<string, unknown>;
269
+ }
270
+ return [{ type: "tool_call", toolCallId, name, input, ...base }];
271
+ }
272
+ if (line.type === "function_call_output") {
273
+ const output = line.raw["output"];
274
+ let content = typeof output === "string" ? output : "";
275
+ if (!content && output !== undefined) {
276
+ try {
277
+ content = JSON.stringify(output);
278
+ } catch {
279
+ content = "";
280
+ }
281
+ }
282
+ return [{
283
+ type: "tool_result",
284
+ toolCallId: asString(line.raw["call_id"]),
285
+ toolName: null,
286
+ content,
287
+ isError: line.raw["is_error"] === true,
288
+ exitCode: typeof line.raw["exit_code"] === "number" ? line.raw["exit_code"] : null,
289
+ ...base,
290
+ }];
291
+ }
292
+ return [];
293
+ }
294
+
295
+ function dedupeCodexSessionFiles(filePaths: readonly string[]): string[] {
296
+ const seen = new Set<string>();
297
+ const output: string[] = [];
298
+ // listCodexSessionFiles returns active files first, so an active copy wins
299
+ // over a briefly duplicated archive copy before transcript inspection.
300
+ for (const filePath of filePaths) {
301
+ const id = path.basename(filePath).match(ROLLOUT_UUID_RE)?.[1] ?? filePath;
302
+ if (seen.has(id)) continue;
303
+ seen.add(id);
304
+ output.push(filePath);
305
+ }
306
+ return output;
307
+ }
308
+
309
+ function normalizedCodexEvents(
310
+ session: LocalHistorySession,
311
+ line: CodexTranscriptLine,
312
+ eventId: string,
313
+ ): Array<StreamEvent & { eventId: string }> {
314
+ const events = line.payload
315
+ ? codexLineToStreamEvents(line, { sessionId: session.externalSessionId })
316
+ : legacyCodexEvents(session, line, eventId);
317
+ return events
318
+ .filter((event) => !((event.type === "assistant" || event.type === "thinking") && !event.text.trim()))
319
+ .map((event) => ({
320
+ ...event,
321
+ timestamp: isoDate(line.timestamp, event.timestamp)!,
322
+ providerType: "codex",
323
+ sessionId: session.externalSessionId,
324
+ eventId,
325
+ raw: line.raw,
326
+ } as StreamEvent & { eventId: string }));
327
+ }
328
+
329
+ export const codexLocalHistory: LocalHistoryOps = {
330
+ async probe(options?: LocalHistoryProbeOptions) {
331
+ const home = codexHome(options);
332
+ const homeAvailable = await isDirectory(home);
333
+ const files = homeAvailable
334
+ ? await listCodexSessionFiles(home, {
335
+ includeArchived: true,
336
+ limit: options?.limit ?? DEFAULT_PROBE_LIMIT,
337
+ })
338
+ : [];
339
+ return {
340
+ providerType: "codex",
341
+ homeAvailable,
342
+ historyAvailable: files.length > 0,
343
+ approximateCount: files.length,
344
+ };
345
+ },
346
+
347
+ async *discover(options: LocalHistoryDiscoverOptions = {}) {
348
+ const home = codexHome(options);
349
+ const [files, titles] = await Promise.all([
350
+ listCodexSessionFiles(home, { includeArchived: options.includeArchived ?? true }),
351
+ loadCodexTitles(home),
352
+ ]);
353
+ const sessions = await recentEligible(
354
+ dedupeCodexSessionFiles(files),
355
+ { limit: options.limit, concurrency: DISCOVERY_CONCURRENCY },
356
+ async (filePath) => {
357
+ try {
358
+ return await inspectCodexSession(filePath, titles, options);
359
+ } catch {
360
+ return null;
361
+ }
362
+ },
363
+ );
364
+ for (const session of sessions) yield session;
365
+ },
366
+
367
+ async *read(session, options = {}): AsyncIterable<LocalHistoryYield> {
368
+ if (session.providerType !== "codex" || !path.isAbsolute(session.transcriptPath)) {
369
+ throw new Error("Invalid Codex local history session");
370
+ }
371
+ for await (const record of readJsonlRecords(
372
+ session.transcriptPath,
373
+ options.fromOffset ?? 0,
374
+ { rejectChanges: true },
375
+ )) {
376
+ const line = parseCodexLine(record.text);
377
+ if (!line) continue;
378
+ const eventId = `codex:${session.externalSessionId}:${record.lineStartOffset}`;
379
+ line.eventId = eventId;
380
+ const events: LocalHistoryYield["event"][] = [];
381
+ const userText = codexUserText(line);
382
+ if (userText) events.push({ type: "user", text: userText, ...baseFields(session, line, eventId) });
383
+ events.push(...normalizedCodexEvents(session, line, eventId));
384
+ for (let partIndex = 0; partIndex < events.length; partIndex++) {
385
+ yield {
386
+ event: events[partIndex]!,
387
+ lineStartOffset: record.lineStartOffset,
388
+ nextOffset: record.nextOffset,
389
+ partIndex,
390
+ };
391
+ }
392
+ }
393
+ },
394
+
395
+ fingerprint(session, options) {
396
+ if (session.providerType !== "codex") throw new Error("Invalid Codex local history session");
397
+ return sourceFingerprint(session.transcriptPath, options?.sha256 ?? false);
398
+ },
399
+ };
@@ -24,6 +24,7 @@ export const codexProvider: ProviderModule = {
24
24
  goals: codexGoalCapability,
25
25
  durableSessions: true,
26
26
  durableHistory: true,
27
+ localHistory: true,
27
28
  },
28
29
  // Heavy machinery (execute.ts, session.ts, modes.ts, attach.ts) loads lazily
29
30
  // on first use — every ProviderModule method is already async.
@@ -43,6 +44,23 @@ export const codexProvider: ProviderModule = {
43
44
  ]);
44
45
  return historyFromSessionAttachment("codex", await attachCodexSession(record, opts));
45
46
  },
47
+ localHistory: {
48
+ probe: (options) => import("./history.js").then((module) => module.codexLocalHistory.probe(options)),
49
+ discover: (options) => ({
50
+ async *[Symbol.asyncIterator]() {
51
+ const module = await import("./history.js");
52
+ yield* module.codexLocalHistory.discover(options);
53
+ },
54
+ }),
55
+ read: (session, options) => ({
56
+ async *[Symbol.asyncIterator]() {
57
+ const module = await import("./history.js");
58
+ yield* module.codexLocalHistory.read(session, options);
59
+ },
60
+ }),
61
+ fingerprint: (session, options) => import("./history.js")
62
+ .then((module) => module.codexLocalHistory.fingerprint(session, options)),
63
+ },
46
64
  };
47
65
 
48
66
  export {
@@ -20,8 +20,9 @@ import type { CodexTranscriptLine } from "./transcript.js";
20
20
  *
21
21
  * The on-disk vocabulary is Codex-internal and version-shifting, so every field
22
22
  * is read defensively and a weird line NEVER throws — it returns `[]`. Codex
23
- * emits no per-line wire id, so `eventId`/`turnId` are null (hosts gate replay
24
- * dedup on their own "not currently running" flag; see spec §9.7).
23
+ * emits no native per-line wire id. File readers attach a deterministic
24
+ * synthetic `line.eventId`, which this normalizer preserves. Standalone line
25
+ * parsing still produces a null event id.
25
26
  */
26
27
  export function codexLineToStreamEvents(
27
28
  line: CodexTranscriptLine,
@@ -47,7 +48,7 @@ function mapLine(line: CodexTranscriptLine, sessionId: string | null): StreamEve
47
48
  providerType: "codex",
48
49
  sessionId,
49
50
  messageId: null,
50
- eventId: null,
51
+ eventId: line.eventId,
51
52
  turnId: null,
52
53
  parentToolCallId: null,
53
54
  raw: line.raw,
package/src/types.ts CHANGED
@@ -95,6 +95,8 @@ export interface ProviderCapabilities {
95
95
  durableSessions?: boolean;
96
96
  /** Provider-neutral durable history through `attachHistory`. */
97
97
  durableHistory?: boolean;
98
+ /** Read-only discovery and import of locally persisted sessions. */
99
+ localHistory?: boolean;
98
100
  /** A persisted provider session can be resumed. */
99
101
  resume?: boolean;
100
102
  /** Models expose provider-native variants independently from effort. */
@@ -368,6 +370,8 @@ export interface ProviderModule {
368
370
  attachSession?(record: SessionRecord, opts?: AttachOptions): Promise<SessionAttachment>;
369
371
  /** Additive file-or-service durable history surface. */
370
372
  attachHistory?(record: SessionRecord, opts?: AttachOptions): Promise<HistoryAttachment>;
373
+ /** Discover and read local sessions when their ids are not already known. */
374
+ localHistory?: import("./history/types.js").LocalHistoryOps;
371
375
  }
372
376
 
373
377
  // Result of looking up a transcript for a given session.
@@ -499,7 +503,7 @@ export interface CatchUpYield {
499
503
  * only bites Claude tool-call turns.)
500
504
  */
501
505
  offset: number;
502
- /** Stable wire id for dedup vs live events (Claude); null when the provider has none (Codex). */
506
+ /** Stable replay id. Claude uses its wire id and file-backed Codex uses a deterministic synthetic id. */
503
507
  eventId: string | null;
504
508
  }
505
509