@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,290 @@
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
+ UUID_JSONL_RE,
14
+ asRecord,
15
+ asString,
16
+ cleanTitle,
17
+ isDirectory,
18
+ isoDate,
19
+ meaningfulHumanText,
20
+ readJsonlRecords,
21
+ recentEligible,
22
+ sourceFingerprint,
23
+ textFromContent,
24
+ } from "../../history/fs.js";
25
+ import { parseStreamLine } from "./parse.js";
26
+ import { resolveClaudeHome } from "./transcript.js";
27
+
28
+ const TITLE_SCAN_BYTES = 64 * 1024;
29
+ const MAX_DISCOVERY_SCAN_BYTES = 4 * 1024 * 1024;
30
+ const DEFAULT_PROBE_LIMIT = 100;
31
+ const DISCOVERY_CONCURRENCY = 16;
32
+ const DROP_EVENT_TYPES = new Set([
33
+ "assistant_delta",
34
+ "thinking_delta",
35
+ "permission_mode",
36
+ "rate_limit",
37
+ "unknown",
38
+ ]);
39
+
40
+ function claudeHome(options?: { env?: Record<string, string> }): string {
41
+ return resolveClaudeHome(options?.env?.["CLAUDE_CONFIG_DIR"]);
42
+ }
43
+
44
+ async function listClaudeSessionFiles(
45
+ home: string,
46
+ options: { limit?: number; includeSubagents?: boolean } = {},
47
+ ): Promise<string[]> {
48
+ const projectsRoot = path.join(home, "projects");
49
+ let projects;
50
+ try {
51
+ projects = await readdir(projectsRoot, { withFileTypes: true });
52
+ } catch {
53
+ return [];
54
+ }
55
+ projects.sort((a, b) => a.name.localeCompare(b.name));
56
+ const files: string[] = [];
57
+ const cap = Math.max(1, options.limit ?? Number.MAX_SAFE_INTEGER);
58
+ for (const project of projects) {
59
+ if (files.length >= cap) break;
60
+ if (!project.isDirectory()) continue;
61
+ let entries;
62
+ try {
63
+ entries = await readdir(path.join(projectsRoot, project.name), { withFileTypes: true });
64
+ } catch {
65
+ continue;
66
+ }
67
+ entries.sort((a, b) => a.name.localeCompare(b.name));
68
+ for (const entry of entries) {
69
+ if (files.length >= cap) break;
70
+ // Top-level UUID files are main sessions. Nested subagent directories and
71
+ // symlinks are deliberately ignored.
72
+ if (entry.isFile() && UUID_JSONL_RE.test(entry.name)) {
73
+ files.push(path.join(projectsRoot, project.name, entry.name));
74
+ }
75
+ if (options.includeSubagents && entry.isDirectory() && UUID_JSONL_RE.test(`${entry.name}.jsonl`)) {
76
+ const subagentsDir = path.join(projectsRoot, project.name, entry.name, "subagents");
77
+ let subagents;
78
+ try {
79
+ subagents = await readdir(subagentsDir, { withFileTypes: true });
80
+ } catch {
81
+ continue;
82
+ }
83
+ subagents.sort((a, b) => a.name.localeCompare(b.name));
84
+ for (const subagent of subagents) {
85
+ if (files.length >= cap) break;
86
+ if (subagent.isFile() && /^agent-[a-z0-9]+\.jsonl$/i.test(subagent.name)) {
87
+ files.push(path.join(subagentsDir, subagent.name));
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ return files;
94
+ }
95
+
96
+ function subagentParentId(transcriptPath: string): string | null {
97
+ if (path.basename(path.dirname(transcriptPath)) !== "subagents") return null;
98
+ return path.basename(path.dirname(path.dirname(transcriptPath))) || null;
99
+ }
100
+
101
+ async function parentSessionMetadata(
102
+ transcriptPath: string,
103
+ ): Promise<{ cwd: string | null; branch: string | null }> {
104
+ const parentId = subagentParentId(transcriptPath);
105
+ if (!parentId) return { cwd: null, branch: null };
106
+ const projectDir = path.dirname(path.dirname(path.dirname(transcriptPath)));
107
+ const parentTranscript = path.join(projectDir, `${parentId}.jsonl`);
108
+ for await (const line of readJsonlRecords(parentTranscript)) {
109
+ const cwd = absoluteCwd(asString(line.raw["cwd"]));
110
+ const branch = asString(line.raw["gitBranch"]);
111
+ if (cwd) return { cwd, branch };
112
+ if (line.nextOffset >= TITLE_SCAN_BYTES) break;
113
+ }
114
+ return { cwd: null, branch: null };
115
+ }
116
+
117
+ function claudeUserText(record: Record<string, unknown>): string | null {
118
+ if (record["type"] !== "user") return null;
119
+ const message = asRecord(record["message"]);
120
+ if (!message || message["role"] !== "user") return null;
121
+ return meaningfulHumanText(textFromContent(
122
+ message["content"],
123
+ new Set(["text", "input_text"]),
124
+ ));
125
+ }
126
+
127
+ function absoluteCwd(value: string | null): string | null {
128
+ return value && path.isAbsolute(value) ? value : null;
129
+ }
130
+
131
+ async function inspectClaudeSession(
132
+ transcriptPath: string,
133
+ options: LocalHistoryDiscoverOptions,
134
+ ): Promise<LocalHistorySession | null> {
135
+ const fileIdentity = path.basename(transcriptPath, ".jsonl");
136
+ const parentId = subagentParentId(transcriptPath);
137
+ const externalSessionId = parentId
138
+ ? `subagent:${parentId}:${fileIdentity}`
139
+ : fileIdentity;
140
+ let cwd: string | null = null;
141
+ let title: string | null = null;
142
+ let firstPrompt: string | null = null;
143
+ let startedAt: string | null = null;
144
+ let branch: string | null = null;
145
+ let sidechain = false;
146
+
147
+ for await (const line of readJsonlRecords(transcriptPath)) {
148
+ const record = line.raw;
149
+ if (record["isSidechain"] === true) sidechain = true;
150
+ cwd ??= absoluteCwd(asString(record["cwd"]));
151
+ startedAt ??= isoDate(record["timestamp"]);
152
+ branch ??= asString(record["gitBranch"]);
153
+ if (record["type"] === "ai-title") title ??= cleanTitle(asString(record["aiTitle"]));
154
+ firstPrompt ??= claudeUserText(record);
155
+ if (cwd && firstPrompt && (title || line.nextOffset >= TITLE_SCAN_BYTES)) break;
156
+ if (line.nextOffset >= MAX_DISCOVERY_SCAN_BYTES) break;
157
+ }
158
+
159
+ if (!cwd && parentId) {
160
+ const parent = await parentSessionMetadata(transcriptPath);
161
+ cwd = parent.cwd;
162
+ branch ??= parent.branch;
163
+ }
164
+
165
+ if ((options.mainSessionsOnly ?? true) && sidechain) return null;
166
+ const hasUserMessage = firstPrompt !== null;
167
+ if ((options.requireUserMessage ?? true) && !hasUserMessage) return null;
168
+ if ((options.mainSessionsOnly ?? true) && !cwd) return null;
169
+ if (options.cwd && (!cwd || path.normalize(cwd) !== path.normalize(options.cwd))) return null;
170
+
171
+ const source = await sourceFingerprint(transcriptPath);
172
+ return {
173
+ version: 1,
174
+ providerType: "claude",
175
+ externalSessionId,
176
+ transcriptPath,
177
+ cwd,
178
+ title: title ?? cleanTitle(firstPrompt),
179
+ startedAt,
180
+ updatedAt: new Date(Number(BigInt(source.modifiedAtNs) / 1_000_000n)).toISOString(),
181
+ branch,
182
+ gitOriginUrl: null,
183
+ archiveState: "unknown",
184
+ hasUserMessage,
185
+ source,
186
+ };
187
+ }
188
+
189
+ function baseFields(
190
+ session: LocalHistorySession,
191
+ raw: Record<string, unknown>,
192
+ eventId: string,
193
+ ): BaseStreamEventFields & { eventId: string } {
194
+ const message = asRecord(raw["message"]);
195
+ return {
196
+ timestamp: isoDate(raw["timestamp"], session.startedAt ?? new Date(0).toISOString())!,
197
+ providerType: "claude",
198
+ sessionId: session.externalSessionId,
199
+ messageId: asString(message?.["id"]),
200
+ eventId,
201
+ turnId: null,
202
+ parentToolCallId: asString(raw["parent_tool_use_id"]),
203
+ raw,
204
+ };
205
+ }
206
+
207
+ function normalizedClaudeEvents(
208
+ session: LocalHistorySession,
209
+ raw: Record<string, unknown>,
210
+ text: string,
211
+ eventId: string,
212
+ ): Array<StreamEvent & { eventId: string }> {
213
+ return parseStreamLine(text)
214
+ .filter((event) => !DROP_EVENT_TYPES.has(event.type))
215
+ .map((event) => ({
216
+ ...event,
217
+ timestamp: isoDate(raw["timestamp"], event.timestamp)!,
218
+ providerType: "claude",
219
+ sessionId: session.externalSessionId,
220
+ eventId,
221
+ raw,
222
+ } as StreamEvent & { eventId: string }));
223
+ }
224
+
225
+ export const claudeLocalHistory: LocalHistoryOps = {
226
+ async probe(options?: LocalHistoryProbeOptions) {
227
+ const home = claudeHome(options);
228
+ const homeAvailable = await isDirectory(home);
229
+ const files = homeAvailable
230
+ ? await listClaudeSessionFiles(home, { limit: options?.limit ?? DEFAULT_PROBE_LIMIT })
231
+ : [];
232
+ return {
233
+ providerType: "claude",
234
+ homeAvailable,
235
+ historyAvailable: files.length > 0,
236
+ approximateCount: files.length,
237
+ };
238
+ },
239
+
240
+ async *discover(options: LocalHistoryDiscoverOptions = {}) {
241
+ const files = await listClaudeSessionFiles(claudeHome(options), {
242
+ includeSubagents: options.mainSessionsOnly === false,
243
+ });
244
+ const sessions = await recentEligible(
245
+ files,
246
+ { limit: options.limit, concurrency: DISCOVERY_CONCURRENCY },
247
+ async (filePath) => {
248
+ try {
249
+ return await inspectClaudeSession(filePath, options);
250
+ } catch {
251
+ return null;
252
+ }
253
+ },
254
+ );
255
+ for (const session of sessions) yield session;
256
+ },
257
+
258
+ async *read(session, options = {}): AsyncIterable<LocalHistoryYield> {
259
+ if (session.providerType !== "claude" || !path.isAbsolute(session.transcriptPath)) {
260
+ throw new Error("Invalid Claude local history session");
261
+ }
262
+ for await (const line of readJsonlRecords(
263
+ session.transcriptPath,
264
+ options.fromOffset ?? 0,
265
+ { rejectChanges: true },
266
+ )) {
267
+ const eventId = asString(line.raw["uuid"])
268
+ ?? `claude:${session.externalSessionId}:${line.lineStartOffset}`;
269
+ const events: LocalHistoryYield["event"][] = [];
270
+ const userText = claudeUserText(line.raw);
271
+ if (userText) {
272
+ events.push({ type: "user", text: userText, ...baseFields(session, line.raw, eventId) });
273
+ }
274
+ events.push(...normalizedClaudeEvents(session, line.raw, line.text, eventId));
275
+ for (let partIndex = 0; partIndex < events.length; partIndex++) {
276
+ yield {
277
+ event: events[partIndex]!,
278
+ lineStartOffset: line.lineStartOffset,
279
+ nextOffset: line.nextOffset,
280
+ partIndex,
281
+ };
282
+ }
283
+ }
284
+ },
285
+
286
+ fingerprint(session, options) {
287
+ if (session.providerType !== "claude") throw new Error("Invalid Claude local history session");
288
+ return sourceFingerprint(session.transcriptPath, options?.sha256 ?? false);
289
+ },
290
+ };
@@ -36,6 +36,7 @@ export const claudeProvider: ProviderModule = {
36
36
  goals: claudeGoalCapability,
37
37
  durableSessions: true,
38
38
  durableHistory: true,
39
+ localHistory: true,
39
40
  },
40
41
  // Heavy machinery (execute.ts, session.ts, attach.ts) loads lazily on first
41
42
  // use — every ProviderModule method is already async, so this is invisible to
@@ -56,6 +57,23 @@ export const claudeProvider: ProviderModule = {
56
57
  ]);
57
58
  return historyFromSessionAttachment("claude", await attachClaudeSession(record, opts));
58
59
  },
60
+ localHistory: {
61
+ probe: (options) => import("./history.js").then((module) => module.claudeLocalHistory.probe(options)),
62
+ discover: (options) => ({
63
+ async *[Symbol.asyncIterator]() {
64
+ const module = await import("./history.js");
65
+ yield* module.claudeLocalHistory.discover(options);
66
+ },
67
+ }),
68
+ read: (session, options) => ({
69
+ async *[Symbol.asyncIterator]() {
70
+ const module = await import("./history.js");
71
+ yield* module.claudeLocalHistory.read(session, options);
72
+ },
73
+ }),
74
+ fingerprint: (session, options) => import("./history.js")
75
+ .then((module) => module.claudeLocalHistory.fingerprint(session, options)),
76
+ },
59
77
  };
60
78
 
61
79
  export {
@@ -79,8 +79,8 @@ async function latestTurnBoundary(
79
79
  /**
80
80
  * Read-only reattachment to a durable Codex session. Same skeleton as Claude,
81
81
  * with two deltas: classify via the rollout's last line, and replay through
82
- * `codexLineToStreamEvents` (Codex has no wire ids, so `eventId` is always null
83
- * — hosts gate replay dedup on their own running flag, per spec §9.7).
82
+ * `codexLineToStreamEvents`. File-backed transcript lines carry deterministic
83
+ * synthetic event ids so replay can be deduplicated across process restarts.
84
84
  */
85
85
  export async function attachCodexSession(
86
86
  record: SessionRecord,
@@ -142,7 +142,7 @@ export async function attachCodexSession(
142
142
  transcript,
143
143
  lastTurn,
144
144
  // 4. Replay: map each rollout line through the normalizer; one yield per
145
- // produced event, all sharing the line's offset, eventId always null.
145
+ // produced event, all sharing the line's offset and source event id.
146
146
  catchUp(catchOpts?: CatchUpOptions): AsyncIterable<CatchUpYield> {
147
147
  if (!transcript) return EMPTY;
148
148
  const filePath = transcript.filePath;
@@ -154,7 +154,7 @@ export async function attachCodexSession(
154
154
  ...(catchOpts?.fromOffset !== undefined ? { fromOffset: catchOpts.fromOffset } : {}),
155
155
  })) {
156
156
  for (const event of codexLineToStreamEvents(line, { sessionId: sid })) {
157
- yield { event, offset, eventId: null };
157
+ yield { event, offset, eventId: event.eventId };
158
158
  }
159
159
  }
160
160
  },