@dex-ai/coding-agent-sdk 0.1.21

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.
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Session Extension — persists conversation history as JSONL.
3
+ */
4
+
5
+ import type {
6
+ Extension,
7
+ AgentContext,
8
+ Message,
9
+ GenerateContext,
10
+ Content,
11
+ } from "@dex-ai/sdk";
12
+ import {
13
+ existsSync,
14
+ readFileSync,
15
+ appendFileSync,
16
+ mkdirSync,
17
+ readdirSync,
18
+ statSync,
19
+ } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { homedir } from "node:os";
22
+
23
+ export interface SessionExtensionOptions {
24
+ sessionId?: string;
25
+ dir?: string;
26
+ }
27
+
28
+ /* ------------------------------------------------------------------ */
29
+ /* Session listing for /resume */
30
+ /* ------------------------------------------------------------------ */
31
+
32
+ export interface SessionInfo {
33
+ /** Session ID (filename without .jsonl). */
34
+ id: string;
35
+ /** Last modification time of the session file. */
36
+ lastModified: Date;
37
+ /** Last user message text (truncated). */
38
+ lastUserMessage: string;
39
+ }
40
+
41
+ /**
42
+ * Extract plain text from a message's content.
43
+ */
44
+ function extractText(content: ReadonlyArray<Content> | undefined): string {
45
+ if (!content || content.length === 0) return "";
46
+ for (const block of content) {
47
+ if (block.type === "text") return block.text;
48
+ }
49
+ return "";
50
+ }
51
+
52
+ /**
53
+ * List all sessions in the given directory, returning metadata for each.
54
+ * Results are sorted chronologically (oldest first).
55
+ */
56
+ export function listSessions(dir: string): SessionInfo[] {
57
+ if (!existsSync(dir)) return [];
58
+
59
+ const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
60
+ const sessions: SessionInfo[] = [];
61
+
62
+ for (const file of files) {
63
+ const filePath = join(dir, file);
64
+ const id = file.replace(/\.jsonl$/, "");
65
+
66
+ try {
67
+ const stat = statSync(filePath);
68
+ const text = readFileSync(filePath, "utf-8");
69
+ const lines = text.split("\n").filter((l) => l.trim());
70
+
71
+ // Skip empty files
72
+ if (lines.length === 0) continue;
73
+
74
+ // Find the last user message
75
+ let lastUserMessage = "";
76
+ for (let i = lines.length - 1; i >= 0; i--) {
77
+ try {
78
+ const msg = JSON.parse(lines[i]!) as Message;
79
+ if (msg.role === "user") {
80
+ lastUserMessage = extractText(msg.content);
81
+ break;
82
+ }
83
+ } catch {
84
+ /* skip malformed */
85
+ }
86
+ }
87
+
88
+ // Skip sessions without user messages
89
+ if (!lastUserMessage) continue;
90
+
91
+ // Truncate for display
92
+ const truncated =
93
+ lastUserMessage.length > 80
94
+ ? lastUserMessage.slice(0, 77) + "..."
95
+ : lastUserMessage;
96
+
97
+ sessions.push({
98
+ id,
99
+ lastModified: stat.mtime,
100
+ lastUserMessage: truncated,
101
+ });
102
+ } catch {
103
+ /* skip unreadable files */
104
+ }
105
+ }
106
+
107
+ // Sort chronologically (oldest first)
108
+ sessions.sort((a, b) => a.lastModified.getTime() - b.lastModified.getTime());
109
+
110
+ return sessions;
111
+ }
112
+
113
+ /* ------------------------------------------------------------------ */
114
+ /* Session repair */
115
+ /* ------------------------------------------------------------------ */
116
+
117
+ /**
118
+ * Repair orphaned tool calls — assistant messages with tool-call content
119
+ * that have no matching tool-result message following them. This can happen
120
+ * when a session is aborted mid-tool-execution.
121
+ */
122
+ function repairOrphanedToolCalls(messages: Message[]): Message[] {
123
+ const repaired: Message[] = [];
124
+
125
+ for (let i = 0; i < messages.length; i++) {
126
+ const msg = messages[i]!;
127
+ repaired.push(msg);
128
+
129
+ // Only check assistant messages with tool-call content
130
+ if (msg.role !== "assistant") continue;
131
+ const toolCalls = msg.content.filter(
132
+ (c): c is Extract<Content, { type: "tool-call" }> =>
133
+ c.type === "tool-call",
134
+ );
135
+ if (toolCalls.length === 0) continue;
136
+
137
+ // Collect all tool-result IDs that follow this assistant message
138
+ // (they should be in immediately subsequent tool-role messages)
139
+ const fulfilledIds = new Set<string>();
140
+ let lastToolIdx = i;
141
+ for (let j = i + 1; j < messages.length; j++) {
142
+ const next = messages[j]!;
143
+ if (next.role !== "tool") break;
144
+ lastToolIdx = j;
145
+ for (const c of next.content) {
146
+ if (c.type === "tool-result") fulfilledIds.add(c.toolCallId);
147
+ }
148
+ }
149
+
150
+ // Skip past existing tool results (they'll be pushed by subsequent iterations)
151
+ // and only synthesize if there are unfulfilled calls
152
+ const unfulfilled = toolCalls.filter(
153
+ (tc) => !fulfilledIds.has(tc.toolCallId),
154
+ );
155
+ if (unfulfilled.length === 0) continue;
156
+
157
+ // Push all existing tool result messages first
158
+ for (let j = i + 1; j <= lastToolIdx; j++) {
159
+ repaired.push(messages[j]!);
160
+ }
161
+ // Skip those in the outer loop
162
+ i = lastToolIdx;
163
+
164
+ // Now synthesize results for orphaned tool calls
165
+ for (const tc of unfulfilled) {
166
+ repaired.push({
167
+ role: "tool",
168
+ content: [
169
+ {
170
+ type: "tool-result",
171
+ toolCallId: tc.toolCallId,
172
+ toolName: tc.toolName,
173
+ output: {
174
+ type: "error-text",
175
+ value: "Tool call aborted (session interrupted).",
176
+ },
177
+ },
178
+ ],
179
+ });
180
+ }
181
+ }
182
+
183
+ return repaired;
184
+ }
185
+
186
+ /**
187
+ * Repair orphaned tool results — tool-result messages whose corresponding
188
+ * tool-call is missing from any preceding assistant message. This can happen
189
+ * when context compression removes an assistant message (e.g. one with
190
+ * multiple tool-calls) but the tool-results were persisted separately.
191
+ *
192
+ * Strategy: drop tool-result entries that have no matching tool-call.
193
+ * If a tool message becomes empty after filtering, remove it entirely.
194
+ */
195
+ function repairOrphanedToolResults(messages: Message[]): Message[] {
196
+ // First pass: collect all tool-call IDs from assistant messages
197
+ const knownToolCallIds = new Set<string>();
198
+ for (const msg of messages) {
199
+ if (msg.role !== "assistant") continue;
200
+ for (const c of msg.content) {
201
+ if (c.type === "tool-call") {
202
+ knownToolCallIds.add(
203
+ (c as Extract<Content, { type: "tool-call" }>).toolCallId,
204
+ );
205
+ }
206
+ }
207
+ }
208
+
209
+ // Second pass: filter out tool-results with no matching tool-call
210
+ const repaired: Message[] = [];
211
+ for (const msg of messages) {
212
+ if (msg.role !== "tool") {
213
+ repaired.push(msg);
214
+ continue;
215
+ }
216
+
217
+ const validContent = msg.content.filter((c) => {
218
+ if (c.type !== "tool-result") return true;
219
+ return knownToolCallIds.has(
220
+ (c as Extract<Content, { type: "tool-result" }>).toolCallId,
221
+ );
222
+ });
223
+
224
+ // Only keep the tool message if it still has content
225
+ if (validContent.length > 0) {
226
+ repaired.push({ ...msg, content: validContent });
227
+ }
228
+ }
229
+
230
+ return repaired;
231
+ }
232
+
233
+ function generateId(): string {
234
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
235
+ }
236
+
237
+ export function sessionExtension(
238
+ opts: SessionExtensionOptions = {},
239
+ ): Extension {
240
+ const sessionId = opts.sessionId ?? generateId();
241
+ let sessionDir: string;
242
+ let sessionPath: string;
243
+ let watermark = 0;
244
+ /** Set of message IDs already persisted (prevents duplication after watermark drift). */
245
+ const persisted = new Set<string>();
246
+
247
+ function flushMessages(actx: AgentContext): void {
248
+ // Clamp watermark if context compression has shrunk the array
249
+ if (watermark > actx.messages.length) {
250
+ watermark = actx.messages.length;
251
+ }
252
+ while (watermark < actx.messages.length) {
253
+ const msg = actx.messages[watermark]!;
254
+ // Skip system messages — they are re-assembled on each create.
255
+ // Skip context-turn messages — they are ephemeral per-turn context
256
+ // (e.g. <env> blocks) regenerated fresh each generate cycle.
257
+ if (msg.role !== "system" && (msg as any).type !== "context-turn") {
258
+ try {
259
+ // Guard against double-writes after watermark drift from splice.
260
+ // Use message ID (not serialized JSON) so format differences
261
+ // (e.g. spaced vs compact JSON) don't bypass dedup.
262
+ const msgId = msg.id ?? "";
263
+ if (msgId && !persisted.has(msgId)) {
264
+ const line = JSON.stringify(msg);
265
+ appendFileSync(sessionPath, line + "\n");
266
+ persisted.add(msgId);
267
+ }
268
+ } catch (err) {
269
+ // Session persistence is best-effort but log for troubleshooting
270
+ console.error(
271
+ `[session] Failed to persist message: ${err instanceof Error ? err.message : String(err)}`,
272
+ );
273
+ }
274
+ }
275
+ watermark++;
276
+ }
277
+ }
278
+
279
+ return {
280
+ name: "session",
281
+
282
+ init(actx: AgentContext) {
283
+ sessionDir = opts.dir ?? join(homedir(), ".dex", "sessions");
284
+ sessionPath = join(sessionDir, `${sessionId}.jsonl`);
285
+
286
+ // Ensure sessions dir exists
287
+ if (!existsSync(sessionDir)) {
288
+ mkdirSync(sessionDir, { recursive: true });
289
+ }
290
+
291
+ // Resume: load existing messages from session file
292
+ if (existsSync(sessionPath)) {
293
+ try {
294
+ const lines = readFileSync(sessionPath, "utf-8")
295
+ .split("\n")
296
+ .filter((l) => l.trim());
297
+ let messages: Message[] = lines.map((l) => JSON.parse(l) as Message);
298
+ if (messages.length > 0) {
299
+ const beforeLen = messages.length;
300
+ messages = repairOrphanedToolCalls(messages);
301
+ messages = repairOrphanedToolResults(messages);
302
+ const repaired = messages.length !== beforeLen;
303
+ if (repaired) {
304
+ console.error(
305
+ `[session] Repaired orphaned tool-call/result(s) from interrupted session (${beforeLen} → ${messages.length} messages)`,
306
+ );
307
+ }
308
+ actx.appendMessage(messages);
309
+ }
310
+ // Seed persisted set with message IDs so we don't double-write on watermark drift
311
+ for (const msg of messages) {
312
+ if (msg.id) persisted.add(msg.id);
313
+ }
314
+ } catch (err) {
315
+ // Corrupted session — start fresh but log it
316
+ console.error(
317
+ `[session] Failed to load session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
318
+ );
319
+ }
320
+ }
321
+
322
+ watermark = actx.messages.length;
323
+ actx.state.set("session", { id: sessionId, path: sessionPath });
324
+ },
325
+
326
+ on: {
327
+ /**
328
+ * Flush new messages on model-stop — this fires BEFORE the context
329
+ * extension's model-stop handler (which may splice/delete messages
330
+ * from actx.messages for compression). By persisting first, we
331
+ * ensure no messages are lost due to in-place mutations.
332
+ */
333
+ "model-stop": async (gctx: GenerateContext): Promise<void> => {
334
+ flushMessages(gctx.agent);
335
+ },
336
+
337
+ /**
338
+ * Also flush on generate-stop as a safety net — catches any
339
+ * messages committed after the last model-stop (e.g. final tool
340
+ * results) and handles watermark drift from context compression.
341
+ */
342
+ "generate-stop": async (gctx: GenerateContext): Promise<void> => {
343
+ flushMessages(gctx.agent);
344
+ },
345
+ },
346
+ };
347
+ }