@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.
- package/package.json +28 -0
- package/src/create.ts +76 -0
- package/src/extensions/approval.ts +164 -0
- package/src/extensions/config.ts +86 -0
- package/src/extensions/env.ts +281 -0
- package/src/extensions/session.ts +347 -0
- package/src/extensions/settings.ts +416 -0
- package/src/extensions/system-prompt.ts +208 -0
- package/src/extensions/workspace.ts +236 -0
- package/src/index.ts +45 -0
- package/src/types.ts +148 -0
|
@@ -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
|
+
}
|