@ap3x/agent-core 0.1.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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/agent-errors.d.ts +54 -0
- package/dist/agent-errors.d.ts.map +1 -0
- package/dist/agent-loop.d.ts +30 -0
- package/dist/agent-loop.d.ts.map +1 -0
- package/dist/agent.d.ts +160 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/backend.d.ts +67 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/compaction.d.ts +147 -0
- package/dist/compaction.d.ts.map +1 -0
- package/dist/concurrency.d.ts +32 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/conversation.d.ts +215 -0
- package/dist/conversation.d.ts.map +1 -0
- package/dist/env.d.ts +125 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/errors.d.ts +37 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/harness.d.ts +110 -0
- package/dist/harness.d.ts.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4153 -0
- package/dist/loader.d.ts +142 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/logger.d.ts +36 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/output-formatter.d.ts +27 -0
- package/dist/output-formatter.d.ts.map +1 -0
- package/dist/prompt-templates.d.ts +78 -0
- package/dist/prompt-templates.d.ts.map +1 -0
- package/dist/result.d.ts +26 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/serialization.d.ts +75 -0
- package/dist/serialization.d.ts.map +1 -0
- package/dist/session-repo.d.ts +90 -0
- package/dist/session-repo.d.ts.map +1 -0
- package/dist/session.d.ts +251 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/shell-blocklist.d.ts +18 -0
- package/dist/shell-blocklist.d.ts.map +1 -0
- package/dist/skills.d.ts +95 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/system-prompt.d.ts +90 -0
- package/dist/system-prompt.d.ts.map +1 -0
- package/dist/tools.d.ts +61 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/types.d.ts +320 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/uuid.d.ts +23 -0
- package/dist/uuid.d.ts.map +1 -0
- package/package.json +33 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4153 @@
|
|
|
1
|
+
// src/result.ts
|
|
2
|
+
function ok(value) {
|
|
3
|
+
return { ok: true, value };
|
|
4
|
+
}
|
|
5
|
+
function err(error) {
|
|
6
|
+
return { ok: false, error };
|
|
7
|
+
}
|
|
8
|
+
function getOrThrow(result) {
|
|
9
|
+
if (result.ok) return result.value;
|
|
10
|
+
throw result.error;
|
|
11
|
+
}
|
|
12
|
+
function getOrUndefined(result) {
|
|
13
|
+
return result.ok ? result.value : void 0;
|
|
14
|
+
}
|
|
15
|
+
function toError(value) {
|
|
16
|
+
if (value instanceof Error) return value;
|
|
17
|
+
if (typeof value === "string") return new Error(value);
|
|
18
|
+
try {
|
|
19
|
+
return new Error(JSON.stringify(value));
|
|
20
|
+
} catch {
|
|
21
|
+
return new Error(String(value));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/errors.ts
|
|
26
|
+
var Ap3xError = class extends Error {
|
|
27
|
+
code;
|
|
28
|
+
cause;
|
|
29
|
+
constructor(code, message, cause) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = new.target.name;
|
|
32
|
+
this.code = code;
|
|
33
|
+
this.cause = cause;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var FileError = class extends Ap3xError {
|
|
37
|
+
path;
|
|
38
|
+
constructor(code, message, path, cause) {
|
|
39
|
+
super(code, message, cause);
|
|
40
|
+
this.path = path;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
var ExecutionError = class extends Ap3xError {
|
|
44
|
+
};
|
|
45
|
+
var SessionError = class extends Ap3xError {
|
|
46
|
+
};
|
|
47
|
+
var CompactionError = class extends Ap3xError {
|
|
48
|
+
};
|
|
49
|
+
var AgentHarnessError = class extends Ap3xError {
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// src/uuid.ts
|
|
53
|
+
var lastMillis = -1;
|
|
54
|
+
var counter = 0;
|
|
55
|
+
var RAND = new Uint8Array(16);
|
|
56
|
+
function randomBytes(out) {
|
|
57
|
+
globalThis.crypto.getRandomValues(out);
|
|
58
|
+
}
|
|
59
|
+
var HEX = Array.from({ length: 256 }, (_, byte) => byte.toString(16).padStart(2, "0"));
|
|
60
|
+
function format(bytes) {
|
|
61
|
+
let hex = "";
|
|
62
|
+
for (let i = 0; i < 16; i++) hex += HEX[bytes[i]];
|
|
63
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
64
|
+
}
|
|
65
|
+
function uuidv7() {
|
|
66
|
+
randomBytes(RAND);
|
|
67
|
+
let now = Date.now();
|
|
68
|
+
if (now > lastMillis) {
|
|
69
|
+
lastMillis = now;
|
|
70
|
+
counter = (RAND[6] << 18 | RAND[7] << 10 | RAND[8] << 2 | RAND[9] >> 6) & 67108863;
|
|
71
|
+
} else {
|
|
72
|
+
counter = counter + 1 & 67108863;
|
|
73
|
+
if (counter === 0) {
|
|
74
|
+
lastMillis += 1;
|
|
75
|
+
now = lastMillis;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const millis = lastMillis;
|
|
79
|
+
const bytes = new Uint8Array(16);
|
|
80
|
+
bytes[0] = Math.floor(millis / 1099511627776) & 255;
|
|
81
|
+
bytes[1] = Math.floor(millis / 4294967296) & 255;
|
|
82
|
+
bytes[2] = millis >>> 24 & 255;
|
|
83
|
+
bytes[3] = millis >>> 16 & 255;
|
|
84
|
+
bytes[4] = millis >>> 8 & 255;
|
|
85
|
+
bytes[5] = millis & 255;
|
|
86
|
+
bytes[6] = 112 | counter >>> 22 & 15;
|
|
87
|
+
bytes[7] = counter >>> 14 & 255;
|
|
88
|
+
bytes[8] = 128 | counter >>> 8 & 63;
|
|
89
|
+
bytes[9] = counter & 255;
|
|
90
|
+
bytes[10] = RAND[10];
|
|
91
|
+
bytes[11] = RAND[11];
|
|
92
|
+
bytes[12] = RAND[12];
|
|
93
|
+
bytes[13] = RAND[13];
|
|
94
|
+
bytes[14] = RAND[14];
|
|
95
|
+
bytes[15] = RAND[15];
|
|
96
|
+
return format(bytes);
|
|
97
|
+
}
|
|
98
|
+
function shortId(existing, length = 8) {
|
|
99
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
100
|
+
const candidate = uuidv7().replace(/-/g, "").slice(-length);
|
|
101
|
+
if (!existing || !existing.has(candidate)) return candidate;
|
|
102
|
+
}
|
|
103
|
+
return uuidv7();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/concurrency.ts
|
|
107
|
+
import { cpus, availableParallelism as nodeAvailableParallelism } from "os";
|
|
108
|
+
function availableParallelism() {
|
|
109
|
+
if (typeof nodeAvailableParallelism === "function") return nodeAvailableParallelism();
|
|
110
|
+
return cpus().length || 1;
|
|
111
|
+
}
|
|
112
|
+
function createLimiter(concurrency) {
|
|
113
|
+
const limit = Math.max(1, Math.floor(concurrency));
|
|
114
|
+
let active = 0;
|
|
115
|
+
const queue = [];
|
|
116
|
+
const next = () => {
|
|
117
|
+
if (active >= limit) return;
|
|
118
|
+
const task = queue.shift();
|
|
119
|
+
if (task) {
|
|
120
|
+
active += 1;
|
|
121
|
+
task();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
return function run(fn) {
|
|
125
|
+
return new Promise((resolve2, reject) => {
|
|
126
|
+
const start = () => {
|
|
127
|
+
fn().then(resolve2, reject).finally(() => {
|
|
128
|
+
active -= 1;
|
|
129
|
+
next();
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
queue.push(start);
|
|
133
|
+
next();
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function mapLimit(items, concurrency, fn) {
|
|
138
|
+
const limit = createLimiter(concurrency);
|
|
139
|
+
return Promise.all(items.map((item, index) => limit(() => fn(item, index))));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// src/tools.ts
|
|
143
|
+
import {
|
|
144
|
+
validateToolArguments
|
|
145
|
+
} from "@ap3x/ai";
|
|
146
|
+
function defineTool(spec) {
|
|
147
|
+
return spec;
|
|
148
|
+
}
|
|
149
|
+
function textResult(text, isError) {
|
|
150
|
+
return { content: [{ type: "text", text }], isError };
|
|
151
|
+
}
|
|
152
|
+
function toToolResultMessage(toolCall, result) {
|
|
153
|
+
return {
|
|
154
|
+
role: "toolResult",
|
|
155
|
+
toolCallId: toolCall.id,
|
|
156
|
+
toolName: toolCall.name,
|
|
157
|
+
content: result.content,
|
|
158
|
+
details: result.details,
|
|
159
|
+
isError: result.isError ?? false,
|
|
160
|
+
timestamp: Date.now()
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
async function prepareToolCall(toolCall, tools, hooks, signal, refs) {
|
|
164
|
+
if (signal?.aborted) {
|
|
165
|
+
return { kind: "immediate", result: textResult("Operation aborted", true) };
|
|
166
|
+
}
|
|
167
|
+
const tool = tools.find((candidate) => candidate.name === toolCall.name);
|
|
168
|
+
if (!tool) {
|
|
169
|
+
return { kind: "immediate", result: textResult(`Tool ${toolCall.name} not found`, true) };
|
|
170
|
+
}
|
|
171
|
+
let raw = toolCall.arguments;
|
|
172
|
+
if (tool.prepareArguments) {
|
|
173
|
+
const reshaped = tool.prepareArguments(raw);
|
|
174
|
+
if (reshaped !== raw) raw = reshaped;
|
|
175
|
+
}
|
|
176
|
+
let args;
|
|
177
|
+
try {
|
|
178
|
+
args = validateToolArguments(tool, { ...toolCall, arguments: raw });
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return { kind: "immediate", result: textResult(toError(error).message, true) };
|
|
181
|
+
}
|
|
182
|
+
if (hooks?.beforeToolCall) {
|
|
183
|
+
const context = refs.assistantMessage && refs.agentContext ? {
|
|
184
|
+
assistantMessage: refs.assistantMessage,
|
|
185
|
+
toolCall,
|
|
186
|
+
args,
|
|
187
|
+
agentContext: refs.agentContext,
|
|
188
|
+
signal
|
|
189
|
+
} : void 0;
|
|
190
|
+
const gate = await hooks.beforeToolCall(tool.name, args, toolCall.id, context);
|
|
191
|
+
if (gate?.block) {
|
|
192
|
+
return {
|
|
193
|
+
kind: "immediate",
|
|
194
|
+
result: textResult(gate.reason ?? "Tool execution was blocked", true)
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (gate?.updatedArgs) {
|
|
198
|
+
try {
|
|
199
|
+
args = validateToolArguments(tool, { ...toolCall, arguments: gate.updatedArgs });
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return {
|
|
202
|
+
kind: "immediate",
|
|
203
|
+
result: textResult(
|
|
204
|
+
`hook-updated arguments failed validation: ${toError(error).message}`,
|
|
205
|
+
true
|
|
206
|
+
)
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (signal?.aborted) {
|
|
212
|
+
return { kind: "immediate", result: textResult("Operation aborted", true) };
|
|
213
|
+
}
|
|
214
|
+
return { kind: "ready", tool, args };
|
|
215
|
+
}
|
|
216
|
+
async function executePreparedToolCall(toolCall, prepared, signal, emitUpdate) {
|
|
217
|
+
let acceptingUpdates = true;
|
|
218
|
+
try {
|
|
219
|
+
const result = await prepared.tool.execute(toolCall.id, prepared.args, signal, (update) => {
|
|
220
|
+
if (acceptingUpdates) emitUpdate(update);
|
|
221
|
+
});
|
|
222
|
+
acceptingUpdates = false;
|
|
223
|
+
return result;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
acceptingUpdates = false;
|
|
226
|
+
return textResult(toError(error).message, true);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async function finalizeExecutedToolCall(toolCall, result, hooks, refs) {
|
|
230
|
+
if (!hooks?.afterToolCall) return result;
|
|
231
|
+
try {
|
|
232
|
+
const context = refs.assistantMessage && refs.agentContext ? {
|
|
233
|
+
assistantMessage: refs.assistantMessage,
|
|
234
|
+
toolCall,
|
|
235
|
+
args: refs.args ?? toolCall.arguments,
|
|
236
|
+
result,
|
|
237
|
+
isError: result.isError ?? false,
|
|
238
|
+
agentContext: refs.agentContext,
|
|
239
|
+
signal: refs.signal
|
|
240
|
+
} : void 0;
|
|
241
|
+
const patch = await hooks.afterToolCall(toolCall.name, result, toolCall.id, context);
|
|
242
|
+
if (!patch) return result;
|
|
243
|
+
return {
|
|
244
|
+
content: patch.content ?? result.content,
|
|
245
|
+
details: "details" in patch ? patch.details : result.details,
|
|
246
|
+
terminate: patch.terminate ?? result.terminate,
|
|
247
|
+
isError: patch.isError ?? result.isError
|
|
248
|
+
};
|
|
249
|
+
} catch (error) {
|
|
250
|
+
return textResult(toError(error).message, true);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function shouldTerminateBatch(results) {
|
|
254
|
+
return results.length > 0 && results.every((result) => result.terminate === true);
|
|
255
|
+
}
|
|
256
|
+
function parallelToolCallLimit() {
|
|
257
|
+
return Math.max(4, availableParallelism());
|
|
258
|
+
}
|
|
259
|
+
async function runOneToolCall(toolCall, options) {
|
|
260
|
+
const { tools, hooks, signal, emit, assistantMessage, context } = options;
|
|
261
|
+
const refs = { assistantMessage, agentContext: context };
|
|
262
|
+
await emit({
|
|
263
|
+
type: "tool_execution_start",
|
|
264
|
+
toolCallId: toolCall.id,
|
|
265
|
+
toolName: toolCall.name,
|
|
266
|
+
args: toolCall.arguments
|
|
267
|
+
});
|
|
268
|
+
const prepared = await prepareToolCall(toolCall, tools, hooks, signal, refs);
|
|
269
|
+
if (prepared.kind === "immediate") {
|
|
270
|
+
return finalizeExecutedToolCall(toolCall, prepared.result, hooks, { ...refs, signal });
|
|
271
|
+
}
|
|
272
|
+
const executed = await executePreparedToolCall(toolCall, prepared, signal, (update) => {
|
|
273
|
+
void emit({
|
|
274
|
+
type: "tool_execution_update",
|
|
275
|
+
toolCallId: toolCall.id,
|
|
276
|
+
toolName: toolCall.name,
|
|
277
|
+
update
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
return finalizeExecutedToolCall(toolCall, executed, hooks, {
|
|
281
|
+
...refs,
|
|
282
|
+
args: prepared.args,
|
|
283
|
+
signal
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
async function executeToolCalls(options) {
|
|
287
|
+
const { toolCalls, signal, mode, emit } = options;
|
|
288
|
+
const sequential = mode === "sequential" || toolCalls.some((toolCall) => {
|
|
289
|
+
const tool = options.tools.find((candidate) => candidate.name === toolCall.name);
|
|
290
|
+
return tool?.executionMode === "sequential";
|
|
291
|
+
});
|
|
292
|
+
const results = [];
|
|
293
|
+
const messages = [];
|
|
294
|
+
if (sequential) {
|
|
295
|
+
for (const toolCall of toolCalls) {
|
|
296
|
+
if (signal?.aborted) break;
|
|
297
|
+
const result = await runOneToolCall(toolCall, options);
|
|
298
|
+
const message = toToolResultMessage(toolCall, result);
|
|
299
|
+
results.push(result);
|
|
300
|
+
messages.push(message);
|
|
301
|
+
await emit({
|
|
302
|
+
type: "tool_execution_end",
|
|
303
|
+
toolCallId: toolCall.id,
|
|
304
|
+
toolName: toolCall.name,
|
|
305
|
+
result: message,
|
|
306
|
+
isError: message.isError
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return { messages, terminate: shouldTerminateBatch(results) };
|
|
310
|
+
}
|
|
311
|
+
const settled = await mapLimit(
|
|
312
|
+
toolCalls,
|
|
313
|
+
parallelToolCallLimit(),
|
|
314
|
+
(toolCall) => runOneToolCall(toolCall, options)
|
|
315
|
+
);
|
|
316
|
+
for (let i = 0; i < toolCalls.length; i++) {
|
|
317
|
+
const toolCall = toolCalls[i];
|
|
318
|
+
const result = settled[i];
|
|
319
|
+
const message = toToolResultMessage(toolCall, result);
|
|
320
|
+
results.push(result);
|
|
321
|
+
messages.push(message);
|
|
322
|
+
await emit({
|
|
323
|
+
type: "tool_execution_end",
|
|
324
|
+
toolCallId: toolCall.id,
|
|
325
|
+
toolName: toolCall.name,
|
|
326
|
+
result: message,
|
|
327
|
+
isError: message.isError
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return { messages, terminate: shouldTerminateBatch(results) };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// src/agent-loop.ts
|
|
334
|
+
import {
|
|
335
|
+
streamSimple
|
|
336
|
+
} from "@ap3x/ai";
|
|
337
|
+
|
|
338
|
+
// src/compaction.ts
|
|
339
|
+
import { completeSimple } from "@ap3x/ai";
|
|
340
|
+
var DEFAULT_COMPACTION_SETTINGS = {
|
|
341
|
+
enabled: true,
|
|
342
|
+
reserveTokens: 16384,
|
|
343
|
+
keepRecentTokens: 2e4
|
|
344
|
+
};
|
|
345
|
+
var RESERVE_FRACTION = 0.15;
|
|
346
|
+
var KEEP_RECENT_FRACTION = 0.25;
|
|
347
|
+
function resolveCompactionSettings(settings, contextWindow) {
|
|
348
|
+
if (contextWindow <= 0) return settings;
|
|
349
|
+
return {
|
|
350
|
+
enabled: settings.enabled,
|
|
351
|
+
reserveTokens: Math.min(settings.reserveTokens, Math.floor(RESERVE_FRACTION * contextWindow)),
|
|
352
|
+
keepRecentTokens: Math.min(
|
|
353
|
+
settings.keepRecentTokens,
|
|
354
|
+
Math.floor(KEEP_RECENT_FRACTION * contextWindow)
|
|
355
|
+
)
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function resolveAutoCompact(autoCompact) {
|
|
359
|
+
return autoCompact === false ? { ...DEFAULT_COMPACTION_SETTINGS, enabled: false } : DEFAULT_COMPACTION_SETTINGS;
|
|
360
|
+
}
|
|
361
|
+
var SUMMARIZATION_SYSTEM_PROMPT = "You are compacting a conversation to free up context. Produce a concise but complete summary under these markdown headings: Goal, Constraints, Progress, Decisions, Next Steps, Critical Context. Preserve every fact, file path, and decision a future turn would need. Omit pleasantries and redundant detail.";
|
|
362
|
+
var CHARS_PER_TOKEN = 4;
|
|
363
|
+
var IMAGE_TOKEN_ESTIMATE = 1200;
|
|
364
|
+
var NO_ANCHOR_INFLATION = 1.25;
|
|
365
|
+
function textLength(content) {
|
|
366
|
+
let total = 0;
|
|
367
|
+
for (const block of content) {
|
|
368
|
+
if (block.type === "text") total += block.text.length;
|
|
369
|
+
else total += IMAGE_TOKEN_ESTIMATE * CHARS_PER_TOKEN;
|
|
370
|
+
}
|
|
371
|
+
return total;
|
|
372
|
+
}
|
|
373
|
+
function estimateMessageTokens(message) {
|
|
374
|
+
if (message.role === "user") {
|
|
375
|
+
if (typeof message.content === "string") {
|
|
376
|
+
return Math.ceil(message.content.length / CHARS_PER_TOKEN);
|
|
377
|
+
}
|
|
378
|
+
return Math.ceil(textLength(message.content) / CHARS_PER_TOKEN);
|
|
379
|
+
}
|
|
380
|
+
if (message.role === "toolResult") {
|
|
381
|
+
return Math.ceil(textLength(message.content) / CHARS_PER_TOKEN);
|
|
382
|
+
}
|
|
383
|
+
if (message.role === "assistant") {
|
|
384
|
+
let chars = 0;
|
|
385
|
+
for (const block of message.content) {
|
|
386
|
+
if (block.type === "text") chars += block.text.length;
|
|
387
|
+
else if (block.type === "thinking") chars += block.thinking.length;
|
|
388
|
+
else chars += JSON.stringify(block.arguments).length + block.name.length;
|
|
389
|
+
}
|
|
390
|
+
return Math.ceil(chars / CHARS_PER_TOKEN);
|
|
391
|
+
}
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
function estimateContextTokens(messages, anchorFromIndex = 0) {
|
|
395
|
+
let anchorIndex = -1;
|
|
396
|
+
let anchorTokens = 0;
|
|
397
|
+
for (let i = messages.length - 1; i >= anchorFromIndex; i--) {
|
|
398
|
+
const message = messages[i];
|
|
399
|
+
if (message?.role === "assistant" && message.stopReason !== "error" && message.stopReason !== "aborted" && message.usage.totalTokens > 0) {
|
|
400
|
+
anchorIndex = i;
|
|
401
|
+
anchorTokens = message.usage.totalTokens;
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (anchorIndex === -1) {
|
|
406
|
+
const sum = messages.reduce((total, message) => total + estimateMessageTokens(message), 0);
|
|
407
|
+
return Math.ceil(sum * NO_ANCHOR_INFLATION);
|
|
408
|
+
}
|
|
409
|
+
let trailing = 0;
|
|
410
|
+
for (let i = anchorIndex + 1; i < messages.length; i++) {
|
|
411
|
+
trailing += estimateMessageTokens(messages[i]);
|
|
412
|
+
}
|
|
413
|
+
return anchorTokens + trailing;
|
|
414
|
+
}
|
|
415
|
+
function shouldCompact(contextTokens, contextWindow, settings = DEFAULT_COMPACTION_SETTINGS) {
|
|
416
|
+
if (!settings.enabled) return false;
|
|
417
|
+
return contextTokens > contextWindow - settings.reserveTokens;
|
|
418
|
+
}
|
|
419
|
+
var SOFT_FRACTION = 0.6;
|
|
420
|
+
var HARD_FRACTION = 0.85;
|
|
421
|
+
function compactionTier(usage, contextWindow, settings) {
|
|
422
|
+
if (!settings.enabled || contextWindow <= 0) return "none";
|
|
423
|
+
const hardLine = Math.min(HARD_FRACTION * contextWindow, contextWindow - settings.reserveTokens);
|
|
424
|
+
const softLine = Math.min(SOFT_FRACTION * contextWindow, hardLine);
|
|
425
|
+
const floor = settings.keepRecentTokens + Math.floor(0.8 * settings.reserveTokens);
|
|
426
|
+
const hardTriggered = usage > HARD_FRACTION * contextWindow || shouldCompact(usage, contextWindow, settings);
|
|
427
|
+
if (hardTriggered) return floor < hardLine ? "hard" : "none";
|
|
428
|
+
if (usage > softLine) return floor < softLine ? "soft" : "none";
|
|
429
|
+
return "none";
|
|
430
|
+
}
|
|
431
|
+
function findCutPoint(messages, keepRecentTokens) {
|
|
432
|
+
if (messages.length === 0) return { cutIndex: 0, keptTokens: 0 };
|
|
433
|
+
let accumulated = 0;
|
|
434
|
+
let cutIndex = messages.length;
|
|
435
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
436
|
+
accumulated += estimateMessageTokens(messages[i]);
|
|
437
|
+
cutIndex = i;
|
|
438
|
+
if (accumulated >= keepRecentTokens) break;
|
|
439
|
+
}
|
|
440
|
+
while (cutIndex < messages.length && messages[cutIndex]?.role === "toolResult") {
|
|
441
|
+
cutIndex++;
|
|
442
|
+
}
|
|
443
|
+
let keptTokens = 0;
|
|
444
|
+
for (let i = cutIndex; i < messages.length; i++) {
|
|
445
|
+
keptTokens += estimateMessageTokens(messages[i]);
|
|
446
|
+
}
|
|
447
|
+
return { cutIndex, keptTokens };
|
|
448
|
+
}
|
|
449
|
+
function serializeConversation(messages) {
|
|
450
|
+
return messages.map((message) => {
|
|
451
|
+
if (message.role === "user") {
|
|
452
|
+
const text = typeof message.content === "string" ? message.content : message.content.map((b) => b.type === "text" ? b.text : "[image]").join("\n");
|
|
453
|
+
return `User: ${text}`;
|
|
454
|
+
}
|
|
455
|
+
if (message.role === "toolResult") {
|
|
456
|
+
const text = message.content.map((b) => b.type === "text" ? b.text : "[image]").join("\n");
|
|
457
|
+
return `Tool(${message.toolName}): ${text}`;
|
|
458
|
+
}
|
|
459
|
+
if (message.role === "assistant") {
|
|
460
|
+
const text = message.content.map((b) => {
|
|
461
|
+
if (b.type === "text") return b.text;
|
|
462
|
+
if (b.type === "thinking") return `[thinking] ${b.thinking}`;
|
|
463
|
+
return `[tool call ${b.name}(${JSON.stringify(b.arguments)})]`;
|
|
464
|
+
}).join("\n");
|
|
465
|
+
return `Assistant: ${text}`;
|
|
466
|
+
}
|
|
467
|
+
return `[${message.role}]`;
|
|
468
|
+
}).join("\n\n");
|
|
469
|
+
}
|
|
470
|
+
function createFileOps() {
|
|
471
|
+
return { read: /* @__PURE__ */ new Set(), written: /* @__PURE__ */ new Set(), edited: /* @__PURE__ */ new Set() };
|
|
472
|
+
}
|
|
473
|
+
function extractFileOpsFromMessage(message, fileOps) {
|
|
474
|
+
if (message.role !== "assistant") return;
|
|
475
|
+
for (const block of message.content) {
|
|
476
|
+
if (block.type !== "toolCall") continue;
|
|
477
|
+
const path = typeof block.arguments.path === "string" ? block.arguments.path : void 0;
|
|
478
|
+
if (!path) continue;
|
|
479
|
+
if (block.name === "read") fileOps.read.add(path);
|
|
480
|
+
else if (block.name === "write") fileOps.written.add(path);
|
|
481
|
+
else if (block.name === "edit") fileOps.edited.add(path);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function computeFileLists(fileOps) {
|
|
485
|
+
const modified = /* @__PURE__ */ new Set([...fileOps.edited, ...fileOps.written]);
|
|
486
|
+
const readFiles = [...fileOps.read].filter((file) => !modified.has(file)).sort();
|
|
487
|
+
const modifiedFiles = [...modified].sort();
|
|
488
|
+
return { readFiles, modifiedFiles };
|
|
489
|
+
}
|
|
490
|
+
function formatFileOperations(readFiles, modifiedFiles) {
|
|
491
|
+
const sections = [];
|
|
492
|
+
if (readFiles.length > 0) sections.push(`<read-files>
|
|
493
|
+
${readFiles.join("\n")}
|
|
494
|
+
</read-files>`);
|
|
495
|
+
if (modifiedFiles.length > 0) {
|
|
496
|
+
sections.push(`<modified-files>
|
|
497
|
+
${modifiedFiles.join("\n")}
|
|
498
|
+
</modified-files>`);
|
|
499
|
+
}
|
|
500
|
+
return sections.length === 0 ? "" : `
|
|
501
|
+
|
|
502
|
+
${sections.join("\n\n")}`;
|
|
503
|
+
}
|
|
504
|
+
function prepareCompaction(messages, settings = DEFAULT_COMPACTION_SETTINGS) {
|
|
505
|
+
const { cutIndex } = findCutPoint(messages, settings.keepRecentTokens);
|
|
506
|
+
const tokensBefore = estimateContextTokens(messages);
|
|
507
|
+
let turnStartIndex = -1;
|
|
508
|
+
if (cutIndex < messages.length && messages[cutIndex]?.role === "assistant") {
|
|
509
|
+
for (let i = cutIndex - 1; i >= 0; i--) {
|
|
510
|
+
if (messages[i]?.role === "user") {
|
|
511
|
+
turnStartIndex = i;
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const isSplitTurn = turnStartIndex >= 0;
|
|
517
|
+
const historyEnd = isSplitTurn ? turnStartIndex : cutIndex;
|
|
518
|
+
return {
|
|
519
|
+
historyMessages: messages.slice(0, historyEnd),
|
|
520
|
+
turnPrefixMessages: isSplitTurn ? messages.slice(turnStartIndex, cutIndex) : [],
|
|
521
|
+
keptMessages: messages.slice(cutIndex),
|
|
522
|
+
isSplitTurn,
|
|
523
|
+
tokensBefore
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
var UPDATE_SUMMARIZATION_PROMPT = "Update the existing summary (in <previous-summary>) with information from the new messages below. Preserve every fact, file path, and decision already recorded; move finished work forward and refresh Next Steps. Keep the same markdown headings.";
|
|
527
|
+
var TURN_PREFIX_SUMMARIZATION_PROMPT = "The messages below are the PREFIX of a turn whose recent work is retained verbatim. Summarize only what the retained suffix needs: the original request, early progress, and context required to understand the kept work. Be concise.";
|
|
528
|
+
function summaryMaxTokens(model, reserveTokens, fraction) {
|
|
529
|
+
const modelCap = model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY;
|
|
530
|
+
return Math.min(Math.floor(fraction * reserveTokens), modelCap);
|
|
531
|
+
}
|
|
532
|
+
function completionOptions(model, maxTokens, thinkingLevel, signal) {
|
|
533
|
+
if (model.reasoning && thinkingLevel && thinkingLevel !== "off") {
|
|
534
|
+
return { maxTokens, signal, reasoning: thinkingLevel };
|
|
535
|
+
}
|
|
536
|
+
return { maxTokens, signal };
|
|
537
|
+
}
|
|
538
|
+
function finalizeSummary(response, abortMessage) {
|
|
539
|
+
if (response.stopReason === "aborted") {
|
|
540
|
+
return err(new CompactionError("aborted", abortMessage));
|
|
541
|
+
}
|
|
542
|
+
if (response.stopReason === "error") {
|
|
543
|
+
return err(
|
|
544
|
+
new CompactionError("summarization_failed", response.errorMessage ?? "Summarization failed")
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
const summary = response.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
548
|
+
return ok(summary);
|
|
549
|
+
}
|
|
550
|
+
async function generateSummary(history, model, settings, complete, options) {
|
|
551
|
+
const maxTokens = summaryMaxTokens(model, settings.reserveTokens, 0.8);
|
|
552
|
+
const instructions = options?.customInstructions ? `${SUMMARIZATION_SYSTEM_PROMPT}
|
|
553
|
+
|
|
554
|
+
Additional instructions: ${options.customInstructions}` : SUMMARIZATION_SYSTEM_PROMPT;
|
|
555
|
+
const conversationText = serializeConversation(history);
|
|
556
|
+
const userText = options?.previousSummary ? `${UPDATE_SUMMARIZATION_PROMPT}
|
|
557
|
+
|
|
558
|
+
<previous-summary>
|
|
559
|
+
${options.previousSummary}
|
|
560
|
+
</previous-summary>
|
|
561
|
+
|
|
562
|
+
New messages to incorporate:
|
|
563
|
+
|
|
564
|
+
${conversationText}` : `Summarize this conversation:
|
|
565
|
+
|
|
566
|
+
${conversationText}`;
|
|
567
|
+
let response;
|
|
568
|
+
try {
|
|
569
|
+
response = await complete(
|
|
570
|
+
model,
|
|
571
|
+
{
|
|
572
|
+
systemPrompt: instructions,
|
|
573
|
+
messages: [{ role: "user", content: userText, timestamp: Date.now() }]
|
|
574
|
+
},
|
|
575
|
+
completionOptions(model, maxTokens, options?.thinkingLevel, options?.signal)
|
|
576
|
+
);
|
|
577
|
+
} catch (error) {
|
|
578
|
+
return err(new CompactionError("summarization_failed", toError(error).message, error));
|
|
579
|
+
}
|
|
580
|
+
return finalizeSummary(response, "Compaction was aborted");
|
|
581
|
+
}
|
|
582
|
+
async function generateTurnPrefixSummary(messages, model, settings, complete, options) {
|
|
583
|
+
const maxTokens = summaryMaxTokens(model, settings.reserveTokens, 0.5);
|
|
584
|
+
const userText = `${TURN_PREFIX_SUMMARIZATION_PROMPT}
|
|
585
|
+
|
|
586
|
+
${serializeConversation(messages)}`;
|
|
587
|
+
let response;
|
|
588
|
+
try {
|
|
589
|
+
response = await complete(
|
|
590
|
+
model,
|
|
591
|
+
{
|
|
592
|
+
systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
|
|
593
|
+
messages: [{ role: "user", content: userText, timestamp: Date.now() }]
|
|
594
|
+
},
|
|
595
|
+
completionOptions(model, maxTokens, options?.thinkingLevel, options?.signal)
|
|
596
|
+
);
|
|
597
|
+
} catch (error) {
|
|
598
|
+
return err(new CompactionError("summarization_failed", toError(error).message, error));
|
|
599
|
+
}
|
|
600
|
+
return finalizeSummary(response, "Turn prefix summarization was aborted");
|
|
601
|
+
}
|
|
602
|
+
async function compact(messages, model, options) {
|
|
603
|
+
const settings = options?.settings ?? DEFAULT_COMPACTION_SETTINGS;
|
|
604
|
+
if (messages.length === 0) {
|
|
605
|
+
return err(new CompactionError("compaction", "Nothing to compact"));
|
|
606
|
+
}
|
|
607
|
+
const complete = options?.complete ?? completeSimple;
|
|
608
|
+
const { historyMessages, turnPrefixMessages, keptMessages, isSplitTurn, tokensBefore } = prepareCompaction(messages, settings);
|
|
609
|
+
if (historyMessages.length === 0 && turnPrefixMessages.length === 0) {
|
|
610
|
+
return ok({ summary: "", keptMessages, tokensBefore, readFiles: [], modifiedFiles: [] });
|
|
611
|
+
}
|
|
612
|
+
const fileOps = createFileOps();
|
|
613
|
+
for (const message of historyMessages) extractFileOpsFromMessage(message, fileOps);
|
|
614
|
+
for (const message of turnPrefixMessages) extractFileOpsFromMessage(message, fileOps);
|
|
615
|
+
let summary;
|
|
616
|
+
if (isSplitTurn && turnPrefixMessages.length > 0) {
|
|
617
|
+
const historyResult = historyMessages.length > 0 ? await generateSummary(historyMessages, model, settings, complete, options) : ok("No prior history.");
|
|
618
|
+
if (!historyResult.ok) return err(historyResult.error);
|
|
619
|
+
const prefixResult = await generateTurnPrefixSummary(
|
|
620
|
+
turnPrefixMessages,
|
|
621
|
+
model,
|
|
622
|
+
settings,
|
|
623
|
+
complete,
|
|
624
|
+
options
|
|
625
|
+
);
|
|
626
|
+
if (!prefixResult.ok) return err(prefixResult.error);
|
|
627
|
+
summary = `${historyResult.value}
|
|
628
|
+
|
|
629
|
+
---
|
|
630
|
+
|
|
631
|
+
**Turn Context (split turn):**
|
|
632
|
+
|
|
633
|
+
${prefixResult.value}`;
|
|
634
|
+
} else {
|
|
635
|
+
const summaryResult = await generateSummary(
|
|
636
|
+
historyMessages,
|
|
637
|
+
model,
|
|
638
|
+
settings,
|
|
639
|
+
complete,
|
|
640
|
+
options
|
|
641
|
+
);
|
|
642
|
+
if (!summaryResult.ok) return err(summaryResult.error);
|
|
643
|
+
summary = summaryResult.value;
|
|
644
|
+
}
|
|
645
|
+
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
646
|
+
summary += formatFileOperations(readFiles, modifiedFiles);
|
|
647
|
+
return ok({ summary, keptMessages, tokensBefore, readFiles, modifiedFiles });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// src/logger.ts
|
|
651
|
+
var RANK = {
|
|
652
|
+
silent: 0,
|
|
653
|
+
error: 1,
|
|
654
|
+
warn: 2,
|
|
655
|
+
info: 3,
|
|
656
|
+
debug: 4
|
|
657
|
+
};
|
|
658
|
+
function parseLevel(raw) {
|
|
659
|
+
if (raw === void 0) return void 0;
|
|
660
|
+
const normalized = raw.trim().toLowerCase();
|
|
661
|
+
return normalized in RANK ? normalized : void 0;
|
|
662
|
+
}
|
|
663
|
+
var activeLevel = parseLevel(process.env.AP3X_LOG_LEVEL) ?? "silent";
|
|
664
|
+
function setLogLevel(level) {
|
|
665
|
+
activeLevel = level;
|
|
666
|
+
}
|
|
667
|
+
function getLogLevel() {
|
|
668
|
+
return activeLevel;
|
|
669
|
+
}
|
|
670
|
+
function enabled(level) {
|
|
671
|
+
return RANK[activeLevel] >= RANK[level];
|
|
672
|
+
}
|
|
673
|
+
var logger = {
|
|
674
|
+
debug(message, ...args) {
|
|
675
|
+
if (enabled("debug")) console.debug(message, ...args);
|
|
676
|
+
},
|
|
677
|
+
info(message, ...args) {
|
|
678
|
+
if (enabled("info")) console.info(message, ...args);
|
|
679
|
+
},
|
|
680
|
+
warn(message, ...args) {
|
|
681
|
+
if (enabled("warn")) console.warn(message, ...args);
|
|
682
|
+
},
|
|
683
|
+
error(message, ...args) {
|
|
684
|
+
if (enabled("error")) console.error(message, ...args);
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
// src/agent-loop.ts
|
|
689
|
+
var DEFAULT_MAX_LOOPS = 1e3;
|
|
690
|
+
function emptyUsage() {
|
|
691
|
+
return {
|
|
692
|
+
input: 0,
|
|
693
|
+
output: 0,
|
|
694
|
+
cacheRead: 0,
|
|
695
|
+
cacheWrite: 0,
|
|
696
|
+
totalTokens: 0,
|
|
697
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
function failureMessage(model, message) {
|
|
701
|
+
return {
|
|
702
|
+
role: "assistant",
|
|
703
|
+
content: [],
|
|
704
|
+
api: model.api,
|
|
705
|
+
provider: model.provider,
|
|
706
|
+
model: model.id,
|
|
707
|
+
usage: emptyUsage(),
|
|
708
|
+
stopReason: "error",
|
|
709
|
+
errorMessage: message,
|
|
710
|
+
timestamp: Date.now()
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
function toolCallsOf(message) {
|
|
714
|
+
return message.content.filter((block) => block.type === "toolCall");
|
|
715
|
+
}
|
|
716
|
+
function configForTurn(config, turn, messages) {
|
|
717
|
+
if (!config.selectModel) return config;
|
|
718
|
+
const model = config.selectModel({ turn, messages });
|
|
719
|
+
return model ? { ...config, model } : config;
|
|
720
|
+
}
|
|
721
|
+
async function streamAssistantResponse(context, config, emit, signal, streamFn) {
|
|
722
|
+
const transformed = config.hooks?.transformContext ? await config.hooks.transformContext(context.messages, signal) : context.messages;
|
|
723
|
+
const messages = config.hooks?.convertToLlm ? await config.hooks.convertToLlm(transformed) : (
|
|
724
|
+
// Without a convertToLlm hook the (already transformed) transcript passes
|
|
725
|
+
// straight through. `AgentMessage` may be wider than `Message` once a host
|
|
726
|
+
// augments CustomAgentMessages, but a host that does so is expected to map
|
|
727
|
+
// its custom types down via convertToLlm; absent that, the transcript is
|
|
728
|
+
// standard `Message`s, so this cast is a no-op on the runtime value.
|
|
729
|
+
transformed
|
|
730
|
+
);
|
|
731
|
+
const llmContext = {
|
|
732
|
+
systemPrompt: config.systemPrompt ?? context.systemPrompt,
|
|
733
|
+
messages,
|
|
734
|
+
tools: config.tools?.map((tool) => ({
|
|
735
|
+
name: tool.name,
|
|
736
|
+
description: tool.description,
|
|
737
|
+
parameters: tool.parameters
|
|
738
|
+
}))
|
|
739
|
+
};
|
|
740
|
+
const resolvedApiKey = config.hooks?.getApiKey ? await config.hooks.getApiKey(config.model.provider) : void 0;
|
|
741
|
+
const options = {
|
|
742
|
+
...config.streamOptions,
|
|
743
|
+
...resolvedApiKey ? { apiKey: resolvedApiKey } : {},
|
|
744
|
+
signal,
|
|
745
|
+
...config.reasoning ? { reasoning: config.reasoning } : {}
|
|
746
|
+
};
|
|
747
|
+
let started = false;
|
|
748
|
+
try {
|
|
749
|
+
const eventStream = streamFn(config.model, llmContext, options);
|
|
750
|
+
for await (const providerEvent of eventStream) {
|
|
751
|
+
if (providerEvent.type === "start") {
|
|
752
|
+
started = true;
|
|
753
|
+
await emit({ type: "message_start", message: providerEvent.partial });
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
if (providerEvent.type === "done" || providerEvent.type === "error") {
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
if ("partial" in providerEvent) {
|
|
760
|
+
await emit({
|
|
761
|
+
type: "message_update",
|
|
762
|
+
message: providerEvent.partial,
|
|
763
|
+
assistantMessageEvent: providerEvent
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const final = await eventStream.result();
|
|
768
|
+
if (!started) await emit({ type: "message_start", message: final });
|
|
769
|
+
await emit({ type: "message_end", message: final });
|
|
770
|
+
return final;
|
|
771
|
+
} catch (error) {
|
|
772
|
+
const message = failureMessage(
|
|
773
|
+
config.model,
|
|
774
|
+
error instanceof Error ? error.message : String(error)
|
|
775
|
+
);
|
|
776
|
+
if (!started) await emit({ type: "message_start", message });
|
|
777
|
+
await emit({ type: "message_end", message });
|
|
778
|
+
return message;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
async function runAgentLoop(prompts, context, config, emit, signal) {
|
|
782
|
+
const streamFn = config.streamFn ?? streamSimple;
|
|
783
|
+
const maxLoops = config.maxLoops ?? DEFAULT_MAX_LOOPS;
|
|
784
|
+
const newMessages = [];
|
|
785
|
+
const working = {
|
|
786
|
+
systemPrompt: config.systemPrompt ?? context.systemPrompt,
|
|
787
|
+
messages: [...context.messages],
|
|
788
|
+
tools: context.tools
|
|
789
|
+
};
|
|
790
|
+
await emit({ type: "agent_start" });
|
|
791
|
+
const drainSteering = async () => {
|
|
792
|
+
if (signal?.aborted || !config.hooks?.getSteeringMessages) return [];
|
|
793
|
+
return await config.hooks.getSteeringMessages() ?? [];
|
|
794
|
+
};
|
|
795
|
+
const drainFollowUp = async () => {
|
|
796
|
+
if (signal?.aborted || !config.hooks?.getFollowUpMessages) return [];
|
|
797
|
+
return await config.hooks.getFollowUpMessages() ?? [];
|
|
798
|
+
};
|
|
799
|
+
const compactionDeferred = async () => {
|
|
800
|
+
if (!config.hooks?.beforeCompaction) return false;
|
|
801
|
+
try {
|
|
802
|
+
return (await config.hooks.beforeCompaction())?.defer === true;
|
|
803
|
+
} catch {
|
|
804
|
+
return false;
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
let turns = 0;
|
|
808
|
+
let hasMoreToolCalls = true;
|
|
809
|
+
let pending = [...prompts];
|
|
810
|
+
const contextWindow = config.model.contextWindow;
|
|
811
|
+
const settings = resolveCompactionSettings(
|
|
812
|
+
config.compaction ?? DEFAULT_COMPACTION_SETTINGS,
|
|
813
|
+
contextWindow
|
|
814
|
+
);
|
|
815
|
+
const compactionOn = settings.enabled && contextWindow > 0;
|
|
816
|
+
let shadow = null;
|
|
817
|
+
let anchorFloor = 0;
|
|
818
|
+
let lastSummary = config.previousSummary;
|
|
819
|
+
let sawToolContinuation = false;
|
|
820
|
+
const launchShadow = () => {
|
|
821
|
+
const snapshotLen = working.messages.length;
|
|
822
|
+
const snapshot = working.messages.slice(0, snapshotLen);
|
|
823
|
+
const abort = new AbortController();
|
|
824
|
+
const onAbort = () => abort.abort();
|
|
825
|
+
let detached = false;
|
|
826
|
+
const cleanup = () => {
|
|
827
|
+
if (detached) return;
|
|
828
|
+
detached = true;
|
|
829
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
830
|
+
};
|
|
831
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
832
|
+
const s = { snapshotLen, abort, cleanup, promise: void 0 };
|
|
833
|
+
s.promise = compact(snapshot, config.model, {
|
|
834
|
+
settings,
|
|
835
|
+
signal: abort.signal,
|
|
836
|
+
...lastSummary !== void 0 ? { previousSummary: lastSummary } : {},
|
|
837
|
+
...config.compactionComplete ? { complete: config.compactionComplete } : {}
|
|
838
|
+
}).then((r) => {
|
|
839
|
+
s.settled = r;
|
|
840
|
+
cleanup();
|
|
841
|
+
return r;
|
|
842
|
+
}).catch((e) => {
|
|
843
|
+
const r = err(
|
|
844
|
+
new CompactionError("compaction", toError(e).message, e)
|
|
845
|
+
);
|
|
846
|
+
s.settled = r;
|
|
847
|
+
cleanup();
|
|
848
|
+
return r;
|
|
849
|
+
});
|
|
850
|
+
logger.debug(`compaction: launched shadow (snapshotLen=${snapshotLen})`);
|
|
851
|
+
return s;
|
|
852
|
+
};
|
|
853
|
+
const applyShadow = async (s) => {
|
|
854
|
+
const r = s.settled;
|
|
855
|
+
if (!r) return false;
|
|
856
|
+
if (!r.ok) {
|
|
857
|
+
logger.warn(`compaction: failed, keeping context (${r.error.message})`);
|
|
858
|
+
return false;
|
|
859
|
+
}
|
|
860
|
+
const cr = r.value;
|
|
861
|
+
if (cr.summary === "" || cr.keptMessages.length >= s.snapshotLen) return false;
|
|
862
|
+
const summaryMsg = {
|
|
863
|
+
// Mirrors buildSessionContext so in-memory swap and resume read identically.
|
|
864
|
+
role: "user",
|
|
865
|
+
content: `[Earlier conversation summarized]
|
|
866
|
+
${cr.summary}`,
|
|
867
|
+
timestamp: Date.now()
|
|
868
|
+
};
|
|
869
|
+
const rebased = [
|
|
870
|
+
summaryMsg,
|
|
871
|
+
...cr.keptMessages,
|
|
872
|
+
...working.messages.slice(s.snapshotLen)
|
|
873
|
+
// the arrival tail — messages appended during the in-flight compaction
|
|
874
|
+
];
|
|
875
|
+
const sum = (ms) => ms.reduce((n, m) => n + estimateMessageTokens(m), 0);
|
|
876
|
+
const before = sum(working.messages);
|
|
877
|
+
const after = sum(rebased);
|
|
878
|
+
if (after >= before) {
|
|
879
|
+
logger.debug("compaction: not smaller, rolled back");
|
|
880
|
+
return false;
|
|
881
|
+
}
|
|
882
|
+
if (rebased.at(-1) !== working.messages.at(-1)) {
|
|
883
|
+
logger.debug("compaction: tail identity check failed, rolled back");
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
working.messages = rebased;
|
|
887
|
+
anchorFloor = rebased.length;
|
|
888
|
+
lastSummary = cr.summary;
|
|
889
|
+
await emit({
|
|
890
|
+
type: "compaction",
|
|
891
|
+
summary: cr.summary,
|
|
892
|
+
tokensBefore: cr.tokensBefore,
|
|
893
|
+
retainedAtSwap: rebased.length - 1,
|
|
894
|
+
// excludes summaryMsg
|
|
895
|
+
newMessagesAtSwap: newMessages.length
|
|
896
|
+
});
|
|
897
|
+
logger.info(`compaction: swapped ${before}->${after} tokens (kept ${cr.keptMessages.length})`);
|
|
898
|
+
return true;
|
|
899
|
+
};
|
|
900
|
+
while (hasMoreToolCalls || pending.length > 0) {
|
|
901
|
+
if (turns >= maxLoops) break;
|
|
902
|
+
await emit({ type: "turn_start" });
|
|
903
|
+
const steering = await drainSteering();
|
|
904
|
+
if (steering.length > 0) pending.push(...steering);
|
|
905
|
+
for (const prompt of pending) {
|
|
906
|
+
await emit({ type: "message_start", message: prompt });
|
|
907
|
+
working.messages.push(prompt);
|
|
908
|
+
newMessages.push(prompt);
|
|
909
|
+
await emit({ type: "message_end", message: prompt });
|
|
910
|
+
}
|
|
911
|
+
pending = [];
|
|
912
|
+
if (compactionOn) {
|
|
913
|
+
let handled = false;
|
|
914
|
+
if (shadow?.settled) {
|
|
915
|
+
await applyShadow(shadow);
|
|
916
|
+
shadow = null;
|
|
917
|
+
handled = true;
|
|
918
|
+
}
|
|
919
|
+
if (!handled) {
|
|
920
|
+
const usage = estimateContextTokens(working.messages, anchorFloor);
|
|
921
|
+
const tier = compactionTier(usage, contextWindow, settings);
|
|
922
|
+
if (tier === "hard") {
|
|
923
|
+
if (!shadow && !await compactionDeferred()) shadow = launchShadow();
|
|
924
|
+
if (shadow) {
|
|
925
|
+
await shadow.promise;
|
|
926
|
+
await applyShadow(shadow);
|
|
927
|
+
shadow = null;
|
|
928
|
+
}
|
|
929
|
+
} else if (tier === "soft" && !shadow && sawToolContinuation && !await compactionDeferred()) {
|
|
930
|
+
shadow = launchShadow();
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
const assistant = await streamAssistantResponse(
|
|
935
|
+
working,
|
|
936
|
+
configForTurn(config, turns, working.messages),
|
|
937
|
+
emit,
|
|
938
|
+
signal,
|
|
939
|
+
streamFn
|
|
940
|
+
);
|
|
941
|
+
working.messages.push(assistant);
|
|
942
|
+
newMessages.push(assistant);
|
|
943
|
+
turns++;
|
|
944
|
+
if (config.hooks?.onResponse) await config.hooks.onResponse(assistant);
|
|
945
|
+
if (assistant.stopReason === "error" || assistant.stopReason === "aborted") {
|
|
946
|
+
await emit({ type: "turn_end", message: assistant, toolResults: [] });
|
|
947
|
+
break;
|
|
948
|
+
}
|
|
949
|
+
const toolCalls = toolCallsOf(assistant);
|
|
950
|
+
hasMoreToolCalls = false;
|
|
951
|
+
if (toolCalls.length === 0) {
|
|
952
|
+
await emit({ type: "turn_end", message: assistant, toolResults: [] });
|
|
953
|
+
const followUp = await drainFollowUp();
|
|
954
|
+
if (followUp.length > 0) {
|
|
955
|
+
pending = followUp;
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
const batch = await executeToolCalls({
|
|
961
|
+
toolCalls,
|
|
962
|
+
tools: config.tools ?? [],
|
|
963
|
+
hooks: config.hooks,
|
|
964
|
+
signal,
|
|
965
|
+
mode: config.toolExecution ?? "parallel",
|
|
966
|
+
assistantMessage: assistant,
|
|
967
|
+
context: working,
|
|
968
|
+
emit: (event) => emit(event)
|
|
969
|
+
});
|
|
970
|
+
for (const result of batch.messages) {
|
|
971
|
+
await emit({ type: "message_start", message: result });
|
|
972
|
+
working.messages.push(result);
|
|
973
|
+
newMessages.push(result);
|
|
974
|
+
await emit({ type: "message_end", message: result });
|
|
975
|
+
}
|
|
976
|
+
await emit({ type: "turn_end", message: assistant, toolResults: batch.messages });
|
|
977
|
+
if (batch.terminate) {
|
|
978
|
+
const followUp = await drainFollowUp();
|
|
979
|
+
if (followUp.length > 0) {
|
|
980
|
+
pending = followUp;
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
if (signal?.aborted) break;
|
|
986
|
+
hasMoreToolCalls = true;
|
|
987
|
+
sawToolContinuation = true;
|
|
988
|
+
}
|
|
989
|
+
if (shadow && !shadow.settled) {
|
|
990
|
+
shadow.abort.abort();
|
|
991
|
+
shadow.cleanup();
|
|
992
|
+
}
|
|
993
|
+
await emit({ type: "agent_end", messages: newMessages });
|
|
994
|
+
return newMessages;
|
|
995
|
+
}
|
|
996
|
+
async function runAgentLoopContinue(context, config, emit, signal) {
|
|
997
|
+
const last = context.messages.at(-1);
|
|
998
|
+
if (!last) throw new Error("Cannot continue: no messages in context");
|
|
999
|
+
if (last.role === "assistant") {
|
|
1000
|
+
throw new Error("Cannot continue from message role: assistant");
|
|
1001
|
+
}
|
|
1002
|
+
return runAgentLoop([], context, config, emit, signal);
|
|
1003
|
+
}
|
|
1004
|
+
function makeEventStream(run) {
|
|
1005
|
+
const queue = [];
|
|
1006
|
+
const waiters = [];
|
|
1007
|
+
let done = false;
|
|
1008
|
+
const emit = (event) => {
|
|
1009
|
+
const waiter = waiters.shift();
|
|
1010
|
+
if (waiter) waiter({ value: event, done: false });
|
|
1011
|
+
else queue.push(event);
|
|
1012
|
+
};
|
|
1013
|
+
const resultPromise = run(emit).finally(() => {
|
|
1014
|
+
done = true;
|
|
1015
|
+
while (waiters.length > 0) {
|
|
1016
|
+
waiters.shift()?.({ value: void 0, done: true });
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
return {
|
|
1020
|
+
result: () => resultPromise,
|
|
1021
|
+
async *[Symbol.asyncIterator]() {
|
|
1022
|
+
while (true) {
|
|
1023
|
+
if (queue.length > 0) {
|
|
1024
|
+
yield queue.shift();
|
|
1025
|
+
} else if (done) {
|
|
1026
|
+
return;
|
|
1027
|
+
} else {
|
|
1028
|
+
const next = await new Promise((resolve2) => {
|
|
1029
|
+
waiters.push(resolve2);
|
|
1030
|
+
});
|
|
1031
|
+
if (next.done) return;
|
|
1032
|
+
yield next.value;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
function agentLoop(prompts, context, config, signal) {
|
|
1039
|
+
return makeEventStream((emit) => runAgentLoop(prompts, context, config, emit, signal));
|
|
1040
|
+
}
|
|
1041
|
+
function agentLoopContinue(context, config, signal) {
|
|
1042
|
+
const last = context.messages.at(-1);
|
|
1043
|
+
if (!last) throw new Error("Cannot continue: no messages in context");
|
|
1044
|
+
if (last.role === "assistant") {
|
|
1045
|
+
throw new Error("Cannot continue from message role: assistant");
|
|
1046
|
+
}
|
|
1047
|
+
return makeEventStream((emit) => runAgentLoopContinue(context, config, emit, signal));
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// src/agent.ts
|
|
1051
|
+
function finalTextOf(message) {
|
|
1052
|
+
if (!message) return "";
|
|
1053
|
+
return message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
1054
|
+
}
|
|
1055
|
+
function lastAssistant(messages) {
|
|
1056
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1057
|
+
const message = messages[i];
|
|
1058
|
+
if (message?.role === "assistant") return message;
|
|
1059
|
+
}
|
|
1060
|
+
return void 0;
|
|
1061
|
+
}
|
|
1062
|
+
var Agent = class {
|
|
1063
|
+
model;
|
|
1064
|
+
systemPrompt;
|
|
1065
|
+
reasoning;
|
|
1066
|
+
maxLoops;
|
|
1067
|
+
toolExecution;
|
|
1068
|
+
streamOptions;
|
|
1069
|
+
hooks;
|
|
1070
|
+
streamFn;
|
|
1071
|
+
sessionId;
|
|
1072
|
+
autoCompact;
|
|
1073
|
+
previousSummary;
|
|
1074
|
+
toolList;
|
|
1075
|
+
transcript;
|
|
1076
|
+
running = false;
|
|
1077
|
+
controller;
|
|
1078
|
+
lastError;
|
|
1079
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1080
|
+
/**
|
|
1081
|
+
* One shared FIFO of pending host-queued messages, each tagged with its
|
|
1082
|
+
* {@link QueueMode}. `steer`-mode entries drain at each turn boundary
|
|
1083
|
+
* (mid-run injection); everything left — `followUp`, `nextTurn`, and any
|
|
1084
|
+
* late `steer` — drains when the run would otherwise stop (the rescue).
|
|
1085
|
+
* JS is single-threaded, so enqueue is a plain push and drain is a plain
|
|
1086
|
+
* splice at a loop boundary: no locking, no lost update.
|
|
1087
|
+
*/
|
|
1088
|
+
pending = [];
|
|
1089
|
+
/** A never-aborted signal handed to listeners for out-of-run queue events. */
|
|
1090
|
+
idleSignal = new AbortController().signal;
|
|
1091
|
+
constructor(options) {
|
|
1092
|
+
this.model = options.model;
|
|
1093
|
+
this.systemPrompt = options.systemPrompt;
|
|
1094
|
+
this.reasoning = options.reasoning;
|
|
1095
|
+
this.maxLoops = options.maxLoops;
|
|
1096
|
+
this.toolExecution = options.toolExecution ?? "parallel";
|
|
1097
|
+
this.streamOptions = options.streamOptions;
|
|
1098
|
+
this.hooks = options.hooks ?? {};
|
|
1099
|
+
this.streamFn = options.streamFn;
|
|
1100
|
+
this.sessionId = options.sessionId;
|
|
1101
|
+
this.autoCompact = options.autoCompact;
|
|
1102
|
+
this.previousSummary = options.previousSummary;
|
|
1103
|
+
this.toolList = options.tools ? [...options.tools] : [];
|
|
1104
|
+
this.transcript = options.messages ? [...options.messages] : [];
|
|
1105
|
+
}
|
|
1106
|
+
/** A defensive snapshot of the agent's state. */
|
|
1107
|
+
get state() {
|
|
1108
|
+
return {
|
|
1109
|
+
systemPrompt: this.systemPrompt,
|
|
1110
|
+
model: this.model,
|
|
1111
|
+
messages: [...this.transcript],
|
|
1112
|
+
tools: [...this.toolList],
|
|
1113
|
+
isRunning: this.running,
|
|
1114
|
+
errorMessage: this.lastError
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
/** Whether a run is in progress. */
|
|
1118
|
+
get isRunning() {
|
|
1119
|
+
return this.running;
|
|
1120
|
+
}
|
|
1121
|
+
/** The abort signal of the active run, if any. */
|
|
1122
|
+
get signal() {
|
|
1123
|
+
return this.controller?.signal;
|
|
1124
|
+
}
|
|
1125
|
+
/** The full transcript (a copy). */
|
|
1126
|
+
get messages() {
|
|
1127
|
+
return [...this.transcript];
|
|
1128
|
+
}
|
|
1129
|
+
/** The registered tools (a copy). Setting replaces the list with a copy. */
|
|
1130
|
+
get tools() {
|
|
1131
|
+
return [...this.toolList];
|
|
1132
|
+
}
|
|
1133
|
+
set tools(tools) {
|
|
1134
|
+
this.toolList = [...tools];
|
|
1135
|
+
}
|
|
1136
|
+
/** Register an event listener. Returns an unsubscribe function. */
|
|
1137
|
+
subscribe(listener) {
|
|
1138
|
+
this.listeners.add(listener);
|
|
1139
|
+
return () => {
|
|
1140
|
+
this.listeners.delete(listener);
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Queue a steering message. During a live run it is injected at the next turn
|
|
1145
|
+
* boundary (before the next assistant turn streams); a steer queued during the
|
|
1146
|
+
* final turn is rescued into the follow-up drain so it still extends the run.
|
|
1147
|
+
* While idle it rides into the next run's first turn. Safe to call any time.
|
|
1148
|
+
*/
|
|
1149
|
+
steer(message) {
|
|
1150
|
+
this.enqueue("steer", message);
|
|
1151
|
+
}
|
|
1152
|
+
/** Queue a follow-up message that extends the run once it would otherwise stop. */
|
|
1153
|
+
followUp(message) {
|
|
1154
|
+
this.enqueue("followUp", message);
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Queue a next-turn message. At the Agent level this shares the follow-up
|
|
1158
|
+
* drain (delivered when the run would stop); the mode tag is preserved on
|
|
1159
|
+
* `queue_update` so a host/harness can apply richer next-prompt semantics.
|
|
1160
|
+
*/
|
|
1161
|
+
nextTurn(message) {
|
|
1162
|
+
this.enqueue("nextTurn", message);
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Move every pending host-queued message (steer/followUp/nextTurn, in queue
|
|
1166
|
+
* order, mode tags intact) from `source` into this agent's queue. Used when a
|
|
1167
|
+
* host replaces its agent mid-conversation (e.g. a compaction reseed) so
|
|
1168
|
+
* messages queued while idle survive the rebuild instead of being dropped.
|
|
1169
|
+
*/
|
|
1170
|
+
adoptPendingFrom(source) {
|
|
1171
|
+
this.pending.push(...source.pending.splice(0, source.pending.length));
|
|
1172
|
+
}
|
|
1173
|
+
/** Abort the active run, if any. */
|
|
1174
|
+
abort() {
|
|
1175
|
+
this.controller?.abort();
|
|
1176
|
+
}
|
|
1177
|
+
/** Clear the transcript and any error state. Rejects while running. */
|
|
1178
|
+
reset() {
|
|
1179
|
+
if (this.running) {
|
|
1180
|
+
throw new AgentHarnessError("busy", "Cannot reset while a run is in progress");
|
|
1181
|
+
}
|
|
1182
|
+
this.transcript = [];
|
|
1183
|
+
this.lastError = void 0;
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Append a user prompt and run the loop to completion. Returns the final
|
|
1187
|
+
* assistant message. Throws {@link AgentHarnessError} `busy` if already running.
|
|
1188
|
+
*/
|
|
1189
|
+
async prompt(input) {
|
|
1190
|
+
if (this.running) {
|
|
1191
|
+
throw new AgentHarnessError("busy", "Agent is already processing a run");
|
|
1192
|
+
}
|
|
1193
|
+
const prompts = this.normalizePrompts(input);
|
|
1194
|
+
return this.runWithLifecycle(
|
|
1195
|
+
(emit, signal) => runAgentLoop(prompts, this.currentContext(), this.currentConfig(), emit, signal)
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
/** Continue the loop from the current transcript with no new prompt. */
|
|
1199
|
+
async continue() {
|
|
1200
|
+
if (this.running) {
|
|
1201
|
+
throw new AgentHarnessError("busy", "Agent is already processing a run");
|
|
1202
|
+
}
|
|
1203
|
+
return this.runWithLifecycle(
|
|
1204
|
+
(emit, signal) => runAgentLoopContinue(this.currentContext(), this.currentConfig(), emit, signal)
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
normalizePrompts(input) {
|
|
1208
|
+
if (typeof input === "string") {
|
|
1209
|
+
return [{ role: "user", content: input, timestamp: Date.now() }];
|
|
1210
|
+
}
|
|
1211
|
+
return Array.isArray(input) ? input : [input];
|
|
1212
|
+
}
|
|
1213
|
+
currentContext() {
|
|
1214
|
+
return {
|
|
1215
|
+
systemPrompt: this.systemPrompt,
|
|
1216
|
+
messages: [...this.transcript],
|
|
1217
|
+
tools: this.toolList.map((tool) => ({
|
|
1218
|
+
name: tool.name,
|
|
1219
|
+
description: tool.description,
|
|
1220
|
+
parameters: tool.parameters
|
|
1221
|
+
}))
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
currentConfig() {
|
|
1225
|
+
return {
|
|
1226
|
+
model: this.model,
|
|
1227
|
+
systemPrompt: this.systemPrompt,
|
|
1228
|
+
tools: this.toolList,
|
|
1229
|
+
reasoning: this.reasoning,
|
|
1230
|
+
maxLoops: this.maxLoops,
|
|
1231
|
+
toolExecution: this.toolExecution,
|
|
1232
|
+
streamOptions: this.streamOptions,
|
|
1233
|
+
hooks: this.composedHooks(),
|
|
1234
|
+
streamFn: this.streamFn,
|
|
1235
|
+
compaction: resolveAutoCompact(this.autoCompact),
|
|
1236
|
+
previousSummary: this.previousSummary
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Wire the shared pending queue into the loop's two drain hooks, COMPOSED
|
|
1241
|
+
* with any user-supplied hooks (never clobbered): the queue is drained first,
|
|
1242
|
+
* then the user's hook is consulted and appended. `getSteeringMessages`
|
|
1243
|
+
* drains `steer`-mode entries (mid-run inject); `getFollowUpMessages` drains
|
|
1244
|
+
* everything left — `followUp`, `nextTurn`, and any late `steer` (the rescue).
|
|
1245
|
+
* When the queue is empty and the user set no hook, each returns `[]`, which
|
|
1246
|
+
* the loop treats identically to an absent hook (byte-identical no-queue path).
|
|
1247
|
+
*/
|
|
1248
|
+
composedHooks() {
|
|
1249
|
+
const userHooks = this.hooks;
|
|
1250
|
+
return {
|
|
1251
|
+
...userHooks,
|
|
1252
|
+
getSteeringMessages: async () => {
|
|
1253
|
+
const drained = this.drainQueue((mode) => mode === "steer");
|
|
1254
|
+
if (drained.length > 0) await this.emitQueueUpdate();
|
|
1255
|
+
const fromUser = userHooks.getSteeringMessages ? await userHooks.getSteeringMessages() : [];
|
|
1256
|
+
return [...drained, ...fromUser];
|
|
1257
|
+
},
|
|
1258
|
+
getFollowUpMessages: async () => {
|
|
1259
|
+
const drained = this.drainQueue(() => true);
|
|
1260
|
+
if (drained.length > 0) await this.emitQueueUpdate();
|
|
1261
|
+
const fromUser = userHooks.getFollowUpMessages ? await userHooks.getFollowUpMessages() : [];
|
|
1262
|
+
return [...drained, ...fromUser];
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
/** Enqueue a pending message and notify observers via `queue_update`. */
|
|
1267
|
+
enqueue(mode, message) {
|
|
1268
|
+
this.pending.push({ mode, message });
|
|
1269
|
+
void this.emitQueueUpdate();
|
|
1270
|
+
}
|
|
1271
|
+
/** Remove and return (in FIFO order) the messages whose mode matches. */
|
|
1272
|
+
drainQueue(match) {
|
|
1273
|
+
if (this.pending.length === 0) return [];
|
|
1274
|
+
const drained = [];
|
|
1275
|
+
const remaining = [];
|
|
1276
|
+
for (const entry of this.pending) {
|
|
1277
|
+
if (match(entry.mode)) drained.push(entry.message);
|
|
1278
|
+
else remaining.push(entry);
|
|
1279
|
+
}
|
|
1280
|
+
this.pending = remaining;
|
|
1281
|
+
return drained;
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Emit a `queue_update` snapshot to every listener. Best-effort and
|
|
1285
|
+
* out-of-band: a failing observer is isolated so it can neither break an
|
|
1286
|
+
* enqueue nor the active run. Uses the active run's signal when running, else
|
|
1287
|
+
* a never-aborted idle signal.
|
|
1288
|
+
*/
|
|
1289
|
+
async emitQueueUpdate() {
|
|
1290
|
+
const byMode = (mode) => this.pending.filter((entry) => entry.mode === mode).map((entry) => entry.message);
|
|
1291
|
+
const event = {
|
|
1292
|
+
type: "queue_update",
|
|
1293
|
+
steer: byMode("steer"),
|
|
1294
|
+
followUp: byMode("followUp"),
|
|
1295
|
+
nextTurn: byMode("nextTurn")
|
|
1296
|
+
};
|
|
1297
|
+
const signal = this.controller?.signal ?? this.idleSignal;
|
|
1298
|
+
for (const listener of this.listeners) {
|
|
1299
|
+
try {
|
|
1300
|
+
await listener(event, signal);
|
|
1301
|
+
} catch {
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
async runWithLifecycle(run) {
|
|
1306
|
+
this.running = true;
|
|
1307
|
+
this.lastError = void 0;
|
|
1308
|
+
this.controller = new AbortController();
|
|
1309
|
+
const signal = this.controller.signal;
|
|
1310
|
+
const emit = async (event) => {
|
|
1311
|
+
if (event.type === "compaction") this.previousSummary = event.summary;
|
|
1312
|
+
for (const listener of this.listeners) {
|
|
1313
|
+
await listener(event, signal);
|
|
1314
|
+
}
|
|
1315
|
+
};
|
|
1316
|
+
try {
|
|
1317
|
+
const newMessages = await run(emit, signal);
|
|
1318
|
+
this.transcript.push(...newMessages);
|
|
1319
|
+
const assistant = lastAssistant(newMessages);
|
|
1320
|
+
if (assistant?.errorMessage) this.lastError = assistant.errorMessage;
|
|
1321
|
+
if (!assistant) {
|
|
1322
|
+
throw new AgentHarnessError("invalid_state", "Run completed without an assistant message");
|
|
1323
|
+
}
|
|
1324
|
+
return assistant;
|
|
1325
|
+
} finally {
|
|
1326
|
+
this.running = false;
|
|
1327
|
+
this.controller = void 0;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
|
|
1332
|
+
// src/backend.ts
|
|
1333
|
+
var EMPTY_USAGE = {
|
|
1334
|
+
input: 0,
|
|
1335
|
+
output: 0,
|
|
1336
|
+
cacheRead: 0,
|
|
1337
|
+
cacheWrite: 0,
|
|
1338
|
+
totalTokens: 0,
|
|
1339
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
|
|
1340
|
+
};
|
|
1341
|
+
function toRunResult(messages) {
|
|
1342
|
+
const assistant = lastAssistant(messages);
|
|
1343
|
+
return {
|
|
1344
|
+
messages,
|
|
1345
|
+
finalText: finalTextOf(assistant),
|
|
1346
|
+
usage: assistant?.usage ?? EMPTY_USAGE,
|
|
1347
|
+
stopReason: assistant?.stopReason ?? "error",
|
|
1348
|
+
errorMessage: assistant?.errorMessage
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
function toPrompts(task) {
|
|
1352
|
+
if (typeof task === "string") {
|
|
1353
|
+
return [{ role: "user", content: task, timestamp: Date.now() }];
|
|
1354
|
+
}
|
|
1355
|
+
return task;
|
|
1356
|
+
}
|
|
1357
|
+
var Ap3xRuntimeBackend = class _Ap3xRuntimeBackend {
|
|
1358
|
+
options;
|
|
1359
|
+
ephemeral;
|
|
1360
|
+
stateful;
|
|
1361
|
+
active;
|
|
1362
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1363
|
+
constructor(options) {
|
|
1364
|
+
const { ephemeral, ...agentOptions } = options;
|
|
1365
|
+
this.options = agentOptions;
|
|
1366
|
+
this.ephemeral = ephemeral ?? true;
|
|
1367
|
+
}
|
|
1368
|
+
newAgent() {
|
|
1369
|
+
return new Agent(this.options);
|
|
1370
|
+
}
|
|
1371
|
+
agentFor() {
|
|
1372
|
+
if (this.ephemeral) return this.newAgent();
|
|
1373
|
+
if (!this.stateful) this.stateful = this.newAgent();
|
|
1374
|
+
return this.stateful;
|
|
1375
|
+
}
|
|
1376
|
+
async run(task, opts) {
|
|
1377
|
+
const agent = this.agentFor();
|
|
1378
|
+
this.active = agent;
|
|
1379
|
+
const unsubscribers = [...this.listeners].map(
|
|
1380
|
+
(listener) => agent.subscribe((event) => listener(event))
|
|
1381
|
+
);
|
|
1382
|
+
let onExternalAbort;
|
|
1383
|
+
if (opts?.signal) {
|
|
1384
|
+
onExternalAbort = () => agent.abort();
|
|
1385
|
+
if (opts.signal.aborted) {
|
|
1386
|
+
queueMicrotask(() => agent.abort());
|
|
1387
|
+
} else {
|
|
1388
|
+
opts.signal.addEventListener("abort", onExternalAbort, { once: true });
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
const before = this.ephemeral ? 0 : agent.messages.length;
|
|
1392
|
+
try {
|
|
1393
|
+
await agent.prompt(toPrompts(task));
|
|
1394
|
+
} finally {
|
|
1395
|
+
for (const unsubscribe of unsubscribers) unsubscribe();
|
|
1396
|
+
if (opts?.signal && onExternalAbort) {
|
|
1397
|
+
opts.signal.removeEventListener("abort", onExternalAbort);
|
|
1398
|
+
}
|
|
1399
|
+
this.active = void 0;
|
|
1400
|
+
}
|
|
1401
|
+
const messages = this.ephemeral ? agent.messages : agent.messages.slice(before);
|
|
1402
|
+
return toRunResult(messages);
|
|
1403
|
+
}
|
|
1404
|
+
runStream(task, opts) {
|
|
1405
|
+
const agent = this.agentFor();
|
|
1406
|
+
return agentLoop(
|
|
1407
|
+
toPrompts(task),
|
|
1408
|
+
{
|
|
1409
|
+
systemPrompt: this.options.systemPrompt,
|
|
1410
|
+
messages: this.ephemeral ? [] : [...agent.messages],
|
|
1411
|
+
tools: this.options.tools?.map((tool) => ({
|
|
1412
|
+
name: tool.name,
|
|
1413
|
+
description: tool.description,
|
|
1414
|
+
parameters: tool.parameters
|
|
1415
|
+
}))
|
|
1416
|
+
},
|
|
1417
|
+
{
|
|
1418
|
+
model: this.options.model,
|
|
1419
|
+
systemPrompt: this.options.systemPrompt,
|
|
1420
|
+
tools: this.options.tools,
|
|
1421
|
+
reasoning: this.options.reasoning,
|
|
1422
|
+
maxLoops: this.options.maxLoops,
|
|
1423
|
+
toolExecution: this.options.toolExecution,
|
|
1424
|
+
streamOptions: this.options.streamOptions,
|
|
1425
|
+
hooks: this.options.hooks,
|
|
1426
|
+
streamFn: this.options.streamFn,
|
|
1427
|
+
compaction: resolveAutoCompact(this.options.autoCompact),
|
|
1428
|
+
previousSummary: this.options.previousSummary
|
|
1429
|
+
},
|
|
1430
|
+
opts?.signal
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
subscribe(listener) {
|
|
1434
|
+
this.listeners.add(listener);
|
|
1435
|
+
return () => {
|
|
1436
|
+
this.listeners.delete(listener);
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
abort() {
|
|
1440
|
+
this.active?.abort();
|
|
1441
|
+
}
|
|
1442
|
+
clone() {
|
|
1443
|
+
return new _Ap3xRuntimeBackend({ ...this.options, ephemeral: this.ephemeral });
|
|
1444
|
+
}
|
|
1445
|
+
supports(capability) {
|
|
1446
|
+
switch (capability) {
|
|
1447
|
+
case "stream":
|
|
1448
|
+
case "subscribe":
|
|
1449
|
+
case "abort":
|
|
1450
|
+
case "clone":
|
|
1451
|
+
return true;
|
|
1452
|
+
case "tools":
|
|
1453
|
+
return (this.options.tools?.length ?? 0) > 0;
|
|
1454
|
+
default:
|
|
1455
|
+
return false;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
getCurrentModel() {
|
|
1459
|
+
return this.options.model;
|
|
1460
|
+
}
|
|
1461
|
+
};
|
|
1462
|
+
function createRuntimeBackend(options) {
|
|
1463
|
+
return new Ap3xRuntimeBackend(options);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// src/session.ts
|
|
1467
|
+
function createSessionId() {
|
|
1468
|
+
return `session-${uuidv7()}`;
|
|
1469
|
+
}
|
|
1470
|
+
function pathToRoot(entries, leafId) {
|
|
1471
|
+
const reversed = [];
|
|
1472
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1473
|
+
let current = leafId;
|
|
1474
|
+
while (current !== null) {
|
|
1475
|
+
if (seen.has(current)) {
|
|
1476
|
+
throw new SessionError("invalid_session", `Cycle detected in session tree at ${current}`);
|
|
1477
|
+
}
|
|
1478
|
+
seen.add(current);
|
|
1479
|
+
const entry = entries.get(current);
|
|
1480
|
+
if (!entry) {
|
|
1481
|
+
throw new SessionError("not_found", `Session entry not found: ${current}`);
|
|
1482
|
+
}
|
|
1483
|
+
reversed.push(entry);
|
|
1484
|
+
current = entry.parentId;
|
|
1485
|
+
}
|
|
1486
|
+
return reversed.reverse();
|
|
1487
|
+
}
|
|
1488
|
+
function producesContextMessage(entry) {
|
|
1489
|
+
return entry.type === "message" || entry.type === "branch_summary" && entry.summary !== "";
|
|
1490
|
+
}
|
|
1491
|
+
function buildSessionContext(path) {
|
|
1492
|
+
const context = { messages: [] };
|
|
1493
|
+
let compactionIndex = -1;
|
|
1494
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
1495
|
+
if (path[i]?.type === "compaction") {
|
|
1496
|
+
compactionIndex = i;
|
|
1497
|
+
break;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
let startMessageId;
|
|
1501
|
+
if (compactionIndex >= 0) {
|
|
1502
|
+
const compaction = path[compactionIndex];
|
|
1503
|
+
context.messages.push({
|
|
1504
|
+
role: "user",
|
|
1505
|
+
content: `[Earlier conversation summarized]
|
|
1506
|
+
${compaction.summary}`,
|
|
1507
|
+
timestamp: compaction.timestamp
|
|
1508
|
+
});
|
|
1509
|
+
context.compactionSummary = compaction.summary;
|
|
1510
|
+
startMessageId = compaction.firstKeptEntryId;
|
|
1511
|
+
}
|
|
1512
|
+
let collecting = startMessageId === void 0;
|
|
1513
|
+
for (const entry of path) {
|
|
1514
|
+
if (entry.type === "model_change") {
|
|
1515
|
+
context.model = { provider: entry.provider, modelId: entry.modelId };
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
if (entry.type === "thinking_level_change") {
|
|
1519
|
+
context.thinkingLevel = entry.thinkingLevel;
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
if (entry.type === "active_tools_change") {
|
|
1523
|
+
context.activeToolNames = [...entry.activeToolNames];
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
if (entry.type === "session_info") {
|
|
1527
|
+
context.sessionName = entry.name?.trim() || void 0;
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
if (entry.type !== "message" && entry.type !== "branch_summary") continue;
|
|
1531
|
+
if (!collecting) {
|
|
1532
|
+
if (entry.id === startMessageId) collecting = true;
|
|
1533
|
+
else continue;
|
|
1534
|
+
}
|
|
1535
|
+
if (entry.type === "branch_summary") {
|
|
1536
|
+
if (producesContextMessage(entry)) {
|
|
1537
|
+
context.messages.push({
|
|
1538
|
+
role: "user",
|
|
1539
|
+
content: `[Summary of a branch this conversation returned from]
|
|
1540
|
+
${entry.summary}`,
|
|
1541
|
+
timestamp: entry.timestamp
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
context.messages.push(entry.message);
|
|
1547
|
+
}
|
|
1548
|
+
return context;
|
|
1549
|
+
}
|
|
1550
|
+
var Session = class {
|
|
1551
|
+
constructor(storage) {
|
|
1552
|
+
this.storage = storage;
|
|
1553
|
+
}
|
|
1554
|
+
storage;
|
|
1555
|
+
getStorage() {
|
|
1556
|
+
return this.storage;
|
|
1557
|
+
}
|
|
1558
|
+
getMetadata() {
|
|
1559
|
+
return this.storage.getMetadata();
|
|
1560
|
+
}
|
|
1561
|
+
getLeafId() {
|
|
1562
|
+
return this.storage.getLeafId();
|
|
1563
|
+
}
|
|
1564
|
+
getEntry(id) {
|
|
1565
|
+
return this.storage.getEntry(id);
|
|
1566
|
+
}
|
|
1567
|
+
getEntries() {
|
|
1568
|
+
return this.storage.getEntries();
|
|
1569
|
+
}
|
|
1570
|
+
getLabel(id) {
|
|
1571
|
+
return this.storage.getLabel(id);
|
|
1572
|
+
}
|
|
1573
|
+
/** The root→leaf path of entries for the current (or a given) leaf. */
|
|
1574
|
+
getBranch(fromId) {
|
|
1575
|
+
const leaf = fromId ?? this.storage.getLeafId();
|
|
1576
|
+
if (leaf === null) return [];
|
|
1577
|
+
return this.storage.getPathToRoot(leaf);
|
|
1578
|
+
}
|
|
1579
|
+
/** Reconstruct the conversation context from the current branch. */
|
|
1580
|
+
buildContext() {
|
|
1581
|
+
return buildSessionContext(this.getBranch());
|
|
1582
|
+
}
|
|
1583
|
+
appendUnder(parentId, build) {
|
|
1584
|
+
const id = this.storage.createEntryId();
|
|
1585
|
+
const entry = build(id);
|
|
1586
|
+
this.storage.appendEntry({ ...entry, id, parentId, timestamp: Date.now() });
|
|
1587
|
+
this.storage.setLeafId(id);
|
|
1588
|
+
return id;
|
|
1589
|
+
}
|
|
1590
|
+
/** Append a transcript message under the current leaf. Returns the new id. */
|
|
1591
|
+
appendMessage(message) {
|
|
1592
|
+
return this.appendUnder(this.storage.getLeafId(), (id) => ({
|
|
1593
|
+
id,
|
|
1594
|
+
parentId: null,
|
|
1595
|
+
timestamp: 0,
|
|
1596
|
+
type: "message",
|
|
1597
|
+
message
|
|
1598
|
+
}));
|
|
1599
|
+
}
|
|
1600
|
+
/** Append a compaction boundary. Returns the new id. */
|
|
1601
|
+
appendCompaction(summary, firstKeptEntryId, tokensBefore) {
|
|
1602
|
+
if (!this.storage.getEntry(firstKeptEntryId)) {
|
|
1603
|
+
throw new SessionError(
|
|
1604
|
+
"invalid_session",
|
|
1605
|
+
`firstKeptEntryId does not resolve: ${firstKeptEntryId}`
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
return this.appendUnder(this.storage.getLeafId(), (id) => ({
|
|
1609
|
+
id,
|
|
1610
|
+
parentId: null,
|
|
1611
|
+
timestamp: 0,
|
|
1612
|
+
type: "compaction",
|
|
1613
|
+
summary,
|
|
1614
|
+
firstKeptEntryId,
|
|
1615
|
+
tokensBefore
|
|
1616
|
+
}));
|
|
1617
|
+
}
|
|
1618
|
+
/** Record a model change at the current leaf. Returns the new id. */
|
|
1619
|
+
appendModelChange(provider, modelId) {
|
|
1620
|
+
return this.appendUnder(this.storage.getLeafId(), (id) => ({
|
|
1621
|
+
id,
|
|
1622
|
+
parentId: null,
|
|
1623
|
+
timestamp: 0,
|
|
1624
|
+
type: "model_change",
|
|
1625
|
+
provider,
|
|
1626
|
+
modelId
|
|
1627
|
+
}));
|
|
1628
|
+
}
|
|
1629
|
+
/** Attach a label to a target entry. Returns the new id. */
|
|
1630
|
+
appendLabel(targetId, label) {
|
|
1631
|
+
if (!this.storage.getEntry(targetId)) {
|
|
1632
|
+
throw new SessionError("not_found", `Label target not found: ${targetId}`);
|
|
1633
|
+
}
|
|
1634
|
+
return this.appendUnder(this.storage.getLeafId(), (id) => ({
|
|
1635
|
+
id,
|
|
1636
|
+
parentId: null,
|
|
1637
|
+
timestamp: 0,
|
|
1638
|
+
type: "label",
|
|
1639
|
+
targetId,
|
|
1640
|
+
label
|
|
1641
|
+
}));
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Branch the conversation: repoint the leaf to an existing entry. Subsequent
|
|
1645
|
+
* appends grow a new branch from that point. Returns the new leaf id.
|
|
1646
|
+
*/
|
|
1647
|
+
branch(targetId) {
|
|
1648
|
+
if (!this.storage.getEntry(targetId)) {
|
|
1649
|
+
throw new SessionError("not_found", `Branch target not found: ${targetId}`);
|
|
1650
|
+
}
|
|
1651
|
+
this.storage.setLeafId(targetId);
|
|
1652
|
+
return targetId;
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Repoint the conversation head to an existing entry (or `null` to clear it),
|
|
1656
|
+
* optionally recording a summary of the branch being left behind. When a
|
|
1657
|
+
* summary is supplied the new leaf becomes that {@link BranchSummaryEntry}
|
|
1658
|
+
* (parented to `entryId`) so it folds into the reconstructed context;
|
|
1659
|
+
* otherwise the leaf is `entryId` itself. Returns the new branch-summary id,
|
|
1660
|
+
* or `undefined` when no summary was recorded.
|
|
1661
|
+
*/
|
|
1662
|
+
moveTo(entryId, summary) {
|
|
1663
|
+
if (entryId !== null && !this.storage.getEntry(entryId)) {
|
|
1664
|
+
throw new SessionError("not_found", `moveTo target not found: ${entryId}`);
|
|
1665
|
+
}
|
|
1666
|
+
this.storage.setLeafId(entryId);
|
|
1667
|
+
if (!summary) return void 0;
|
|
1668
|
+
return this.appendUnder(entryId, (id) => ({
|
|
1669
|
+
id,
|
|
1670
|
+
parentId: null,
|
|
1671
|
+
timestamp: 0,
|
|
1672
|
+
type: "branch_summary",
|
|
1673
|
+
fromId: entryId ?? "root",
|
|
1674
|
+
summary: summary.summary,
|
|
1675
|
+
details: summary.details,
|
|
1676
|
+
fromHook: summary.fromHook
|
|
1677
|
+
}));
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Record (or update) the human-readable session name. Line breaks are
|
|
1681
|
+
* collapsed to spaces and the value is trimmed. Returns the new id.
|
|
1682
|
+
*/
|
|
1683
|
+
appendSessionName(name) {
|
|
1684
|
+
const sanitized = name.replace(/[\r\n]+/g, " ").trim();
|
|
1685
|
+
return this.appendUnder(this.storage.getLeafId(), (id) => ({
|
|
1686
|
+
id,
|
|
1687
|
+
parentId: null,
|
|
1688
|
+
timestamp: 0,
|
|
1689
|
+
type: "session_info",
|
|
1690
|
+
name: sanitized
|
|
1691
|
+
}));
|
|
1692
|
+
}
|
|
1693
|
+
};
|
|
1694
|
+
function makeInitialState(options) {
|
|
1695
|
+
return {
|
|
1696
|
+
metadata: {
|
|
1697
|
+
id: options?.sessionId ?? createSessionId(),
|
|
1698
|
+
timestamp: Date.now(),
|
|
1699
|
+
cwd: options?.cwd ?? process.cwd()
|
|
1700
|
+
},
|
|
1701
|
+
entries: /* @__PURE__ */ new Map(),
|
|
1702
|
+
order: [],
|
|
1703
|
+
leafId: null
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
function createSharedStorage(state) {
|
|
1707
|
+
const usedIds = new Set(state.order);
|
|
1708
|
+
return {
|
|
1709
|
+
getMetadata: () => state.metadata,
|
|
1710
|
+
getLeafId: () => state.leafId,
|
|
1711
|
+
setLeafId: (id) => {
|
|
1712
|
+
if (id !== null && !state.entries.has(id)) {
|
|
1713
|
+
throw new SessionError("invalid_session", `Leaf points at missing entry: ${id}`);
|
|
1714
|
+
}
|
|
1715
|
+
state.leafId = id;
|
|
1716
|
+
},
|
|
1717
|
+
createEntryId: () => {
|
|
1718
|
+
const id = shortId(usedIds);
|
|
1719
|
+
return id;
|
|
1720
|
+
},
|
|
1721
|
+
appendEntry: (entry) => {
|
|
1722
|
+
state.entries.set(entry.id, entry);
|
|
1723
|
+
state.order.push(entry.id);
|
|
1724
|
+
usedIds.add(entry.id);
|
|
1725
|
+
},
|
|
1726
|
+
getEntry: (id) => state.entries.get(id),
|
|
1727
|
+
getEntries: () => state.order.map((id) => state.entries.get(id)),
|
|
1728
|
+
findEntries: (type) => state.order.map((id) => state.entries.get(id)).filter((entry) => entry.type === type),
|
|
1729
|
+
getLabel: (id) => {
|
|
1730
|
+
for (let i = state.order.length - 1; i >= 0; i--) {
|
|
1731
|
+
const entry = state.entries.get(state.order[i]);
|
|
1732
|
+
if (entry?.type === "label" && entry.targetId === id) return entry.label;
|
|
1733
|
+
}
|
|
1734
|
+
return void 0;
|
|
1735
|
+
},
|
|
1736
|
+
getPathToRoot: (leafId) => pathToRoot(state.entries, leafId)
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
var InMemorySessionStorage = class {
|
|
1740
|
+
inner;
|
|
1741
|
+
state;
|
|
1742
|
+
constructor(options) {
|
|
1743
|
+
this.state = makeInitialState(options);
|
|
1744
|
+
this.inner = createSharedStorage(this.state);
|
|
1745
|
+
}
|
|
1746
|
+
getMetadata() {
|
|
1747
|
+
return this.inner.getMetadata();
|
|
1748
|
+
}
|
|
1749
|
+
getLeafId() {
|
|
1750
|
+
return this.inner.getLeafId();
|
|
1751
|
+
}
|
|
1752
|
+
setLeafId(id) {
|
|
1753
|
+
this.inner.setLeafId(id);
|
|
1754
|
+
}
|
|
1755
|
+
createEntryId() {
|
|
1756
|
+
return this.inner.createEntryId();
|
|
1757
|
+
}
|
|
1758
|
+
appendEntry(entry) {
|
|
1759
|
+
this.inner.appendEntry(entry);
|
|
1760
|
+
}
|
|
1761
|
+
getEntry(id) {
|
|
1762
|
+
return this.inner.getEntry(id);
|
|
1763
|
+
}
|
|
1764
|
+
getEntries() {
|
|
1765
|
+
return this.inner.getEntries();
|
|
1766
|
+
}
|
|
1767
|
+
findEntries(type) {
|
|
1768
|
+
return this.inner.findEntries(type);
|
|
1769
|
+
}
|
|
1770
|
+
getLabel(id) {
|
|
1771
|
+
return this.inner.getLabel(id);
|
|
1772
|
+
}
|
|
1773
|
+
getPathToRoot(leafId) {
|
|
1774
|
+
return this.inner.getPathToRoot(leafId);
|
|
1775
|
+
}
|
|
1776
|
+
};
|
|
1777
|
+
var SESSION_FILE_VERSION = 1;
|
|
1778
|
+
var JsonlSessionStorage = class _JsonlSessionStorage {
|
|
1779
|
+
file;
|
|
1780
|
+
state;
|
|
1781
|
+
inner;
|
|
1782
|
+
constructor(file, state) {
|
|
1783
|
+
this.file = file;
|
|
1784
|
+
this.state = state;
|
|
1785
|
+
this.inner = createSharedStorage(state);
|
|
1786
|
+
}
|
|
1787
|
+
/** Create a brand-new JSONL session, writing the header line. */
|
|
1788
|
+
static create(file, options) {
|
|
1789
|
+
const state = makeInitialState(options);
|
|
1790
|
+
if (options?.parentSessionId) state.metadata.parentSessionId = options.parentSessionId;
|
|
1791
|
+
const header = {
|
|
1792
|
+
type: "session",
|
|
1793
|
+
version: SESSION_FILE_VERSION,
|
|
1794
|
+
id: state.metadata.id,
|
|
1795
|
+
timestamp: state.metadata.timestamp,
|
|
1796
|
+
cwd: state.metadata.cwd,
|
|
1797
|
+
...state.metadata.parentSessionId ? { parentSessionId: state.metadata.parentSessionId } : {}
|
|
1798
|
+
};
|
|
1799
|
+
file.append(`${JSON.stringify(header)}
|
|
1800
|
+
`);
|
|
1801
|
+
return new _JsonlSessionStorage(file, state);
|
|
1802
|
+
}
|
|
1803
|
+
/** Open an existing JSONL session, replaying entries to rebuild the tree. */
|
|
1804
|
+
static open(file) {
|
|
1805
|
+
const content = file.read();
|
|
1806
|
+
const lines = content.split("\n").filter((line) => line.trim().length > 0);
|
|
1807
|
+
if (lines.length === 0) {
|
|
1808
|
+
throw new SessionError("invalid_session", "Empty session file: missing session header");
|
|
1809
|
+
}
|
|
1810
|
+
let header;
|
|
1811
|
+
try {
|
|
1812
|
+
header = JSON.parse(lines[0]);
|
|
1813
|
+
} catch (error) {
|
|
1814
|
+
throw new SessionError("invalid_session", "Session header is not valid JSON", error);
|
|
1815
|
+
}
|
|
1816
|
+
if (header.type !== "session" || typeof header.id !== "string") {
|
|
1817
|
+
throw new SessionError("invalid_session", "Malformed session header");
|
|
1818
|
+
}
|
|
1819
|
+
const state = {
|
|
1820
|
+
metadata: {
|
|
1821
|
+
id: header.id,
|
|
1822
|
+
timestamp: header.timestamp,
|
|
1823
|
+
cwd: header.cwd,
|
|
1824
|
+
...header.parentSessionId ? { parentSessionId: header.parentSessionId } : {}
|
|
1825
|
+
},
|
|
1826
|
+
entries: /* @__PURE__ */ new Map(),
|
|
1827
|
+
order: [],
|
|
1828
|
+
leafId: null
|
|
1829
|
+
};
|
|
1830
|
+
for (let i = 1; i < lines.length; i++) {
|
|
1831
|
+
let entry;
|
|
1832
|
+
try {
|
|
1833
|
+
entry = JSON.parse(lines[i]);
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
throw new SessionError("invalid_entry", `Invalid entry on line ${i + 1}`, error);
|
|
1836
|
+
}
|
|
1837
|
+
state.entries.set(entry.id, entry);
|
|
1838
|
+
state.order.push(entry.id);
|
|
1839
|
+
state.leafId = entry.type === "leaf" ? entry.targetId : entry.id;
|
|
1840
|
+
}
|
|
1841
|
+
return new _JsonlSessionStorage(file, state);
|
|
1842
|
+
}
|
|
1843
|
+
getMetadata() {
|
|
1844
|
+
return this.inner.getMetadata();
|
|
1845
|
+
}
|
|
1846
|
+
getLeafId() {
|
|
1847
|
+
return this.inner.getLeafId();
|
|
1848
|
+
}
|
|
1849
|
+
setLeafId(id) {
|
|
1850
|
+
const previous = this.state.leafId;
|
|
1851
|
+
this.inner.setLeafId(id);
|
|
1852
|
+
if (id !== null && id !== previous) {
|
|
1853
|
+
const lastAppended = this.state.order.at(-1);
|
|
1854
|
+
if (id !== lastAppended) {
|
|
1855
|
+
const leafEntry = {
|
|
1856
|
+
id: shortId(new Set(this.state.order)),
|
|
1857
|
+
parentId: previous,
|
|
1858
|
+
timestamp: Date.now(),
|
|
1859
|
+
type: "leaf",
|
|
1860
|
+
targetId: id
|
|
1861
|
+
};
|
|
1862
|
+
this.state.entries.set(leafEntry.id, leafEntry);
|
|
1863
|
+
this.state.order.push(leafEntry.id);
|
|
1864
|
+
this.file.append(`${JSON.stringify(leafEntry)}
|
|
1865
|
+
`);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
createEntryId() {
|
|
1870
|
+
return this.inner.createEntryId();
|
|
1871
|
+
}
|
|
1872
|
+
appendEntry(entry) {
|
|
1873
|
+
this.inner.appendEntry(entry);
|
|
1874
|
+
this.file.append(`${JSON.stringify(entry)}
|
|
1875
|
+
`);
|
|
1876
|
+
}
|
|
1877
|
+
getEntry(id) {
|
|
1878
|
+
return this.inner.getEntry(id);
|
|
1879
|
+
}
|
|
1880
|
+
getEntries() {
|
|
1881
|
+
return this.inner.getEntries();
|
|
1882
|
+
}
|
|
1883
|
+
findEntries(type) {
|
|
1884
|
+
return this.inner.findEntries(type);
|
|
1885
|
+
}
|
|
1886
|
+
getLabel(id) {
|
|
1887
|
+
return this.inner.getLabel(id);
|
|
1888
|
+
}
|
|
1889
|
+
getPathToRoot(leafId) {
|
|
1890
|
+
return this.inner.getPathToRoot(leafId);
|
|
1891
|
+
}
|
|
1892
|
+
};
|
|
1893
|
+
var StringJsonlFile = class {
|
|
1894
|
+
buffer;
|
|
1895
|
+
constructor(initial = "") {
|
|
1896
|
+
this.buffer = initial;
|
|
1897
|
+
}
|
|
1898
|
+
read() {
|
|
1899
|
+
return this.buffer;
|
|
1900
|
+
}
|
|
1901
|
+
append(line) {
|
|
1902
|
+
this.buffer += line;
|
|
1903
|
+
}
|
|
1904
|
+
/** The current serialized contents (the JSONL text). */
|
|
1905
|
+
toString() {
|
|
1906
|
+
return this.buffer;
|
|
1907
|
+
}
|
|
1908
|
+
};
|
|
1909
|
+
|
|
1910
|
+
// src/harness.ts
|
|
1911
|
+
var AgentHarness = class {
|
|
1912
|
+
session;
|
|
1913
|
+
agent;
|
|
1914
|
+
agentOptions;
|
|
1915
|
+
/** Harness-owned subscribers; a single forwarder relays each agent's events. */
|
|
1916
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1917
|
+
/** Unsubscribes the forwarder from the current agent (re-wired on rebuild). */
|
|
1918
|
+
agentUnsub;
|
|
1919
|
+
/** Messages queued by {@link nextTurn}, prepended to (and cleared by) the next prompt. */
|
|
1920
|
+
pendingNextTurn = [];
|
|
1921
|
+
/**
|
|
1922
|
+
* Summary of an in-memory (unpersistable) compaction — set when {@link compact}
|
|
1923
|
+
* finds no session entry to anchor a durable CompactionEntry on (the agent runs
|
|
1924
|
+
* on seeded `messages` the session never held). Threads into the NEXT compaction
|
|
1925
|
+
* as `previousSummary` so it merges instead of being re-read as conversation
|
|
1926
|
+
* text; cleared whenever the agent is reseeded from the session, which replaces
|
|
1927
|
+
* the in-memory context this summary describes.
|
|
1928
|
+
*/
|
|
1929
|
+
unpersistedSummary;
|
|
1930
|
+
busy = false;
|
|
1931
|
+
constructor(options) {
|
|
1932
|
+
const { session, ...agentOptions } = options;
|
|
1933
|
+
this.session = session;
|
|
1934
|
+
this.agentOptions = agentOptions;
|
|
1935
|
+
const context = session.buildContext();
|
|
1936
|
+
this.buildAgent(context.messages.length > 0 ? context.messages : agentOptions.messages);
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Run a single turn and persist all new messages to the session. Only
|
|
1940
|
+
* turn-produced messages are appended — seeded {@link AgentOptions.messages}
|
|
1941
|
+
* stay context-only and are never persisted (see that option's doc; B21).
|
|
1942
|
+
*/
|
|
1943
|
+
async prompt(text) {
|
|
1944
|
+
if (this.busy) throw new AgentHarnessError("busy", "AgentHarness is busy");
|
|
1945
|
+
this.busy = true;
|
|
1946
|
+
const before = this.agent.messages.length;
|
|
1947
|
+
try {
|
|
1948
|
+
const carried = this.pendingNextTurn;
|
|
1949
|
+
this.pendingNextTurn = [];
|
|
1950
|
+
const userMessage = { role: "user", content: text, timestamp: Date.now() };
|
|
1951
|
+
const assistant = await this.agent.prompt([...carried, userMessage]);
|
|
1952
|
+
const produced = this.agent.messages.slice(before);
|
|
1953
|
+
for (const message of produced) {
|
|
1954
|
+
this.session.appendMessage(message);
|
|
1955
|
+
}
|
|
1956
|
+
return assistant;
|
|
1957
|
+
} finally {
|
|
1958
|
+
this.busy = false;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
/**
|
|
1962
|
+
* Inject a steering message into the live run at the next turn boundary (or,
|
|
1963
|
+
* while idle, into the next run's first turn). Delegates to the agent's queue.
|
|
1964
|
+
*/
|
|
1965
|
+
steer(message) {
|
|
1966
|
+
this.agent.steer(this.toMessage(message));
|
|
1967
|
+
}
|
|
1968
|
+
/** Queue a follow-up that extends the current run once it would otherwise stop. */
|
|
1969
|
+
followUp(message) {
|
|
1970
|
+
this.agent.followUp(this.toMessage(message));
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Carry a message to the NEXT {@link prompt}: it is prepended to that prompt
|
|
1974
|
+
* and the queue is cleared. Unlike {@link followUp}, this never extends the
|
|
1975
|
+
* currently-running turn — the message is held at the harness (session) layer
|
|
1976
|
+
* until the next prompt begins.
|
|
1977
|
+
*/
|
|
1978
|
+
nextTurn(message) {
|
|
1979
|
+
this.pendingNextTurn.push(this.toMessage(message));
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* Compact the agent's current context: summarize older history, persist a
|
|
1983
|
+
* compaction boundary to the session, and reseed the agent from the compacted
|
|
1984
|
+
* context so subsequent prompts run against the shortened transcript. Options
|
|
1985
|
+
* are forwarded to the P13 {@link compact}. Throws the underlying
|
|
1986
|
+
* {@link CompactionError} if summarization fails.
|
|
1987
|
+
*
|
|
1988
|
+
* When the agent runs on seeded `messages` the session never held (no entry
|
|
1989
|
+
* can anchor a durable CompactionEntry), the compaction is applied to the
|
|
1990
|
+
* live agent in-memory instead: the returned summary is real and the next
|
|
1991
|
+
* provider call sees the compacted context, but no compaction boundary is
|
|
1992
|
+
* persisted to the session for that run.
|
|
1993
|
+
*/
|
|
1994
|
+
async compact(options) {
|
|
1995
|
+
if (this.busy) throw new AgentHarnessError("busy", "AgentHarness is busy");
|
|
1996
|
+
const settings = resolveCompactionSettings(
|
|
1997
|
+
options?.settings ?? DEFAULT_COMPACTION_SETTINGS,
|
|
1998
|
+
this.agent.model.contextWindow
|
|
1999
|
+
);
|
|
2000
|
+
const previousSummary = options?.previousSummary ?? this.unpersistedSummary ?? this.session.buildContext().compactionSummary;
|
|
2001
|
+
const result = await compact(this.agent.messages, this.agent.model, {
|
|
2002
|
+
...options,
|
|
2003
|
+
settings,
|
|
2004
|
+
...previousSummary !== void 0 ? { previousSummary } : {}
|
|
2005
|
+
});
|
|
2006
|
+
if (!result.ok) throw result.error;
|
|
2007
|
+
const value = result.value;
|
|
2008
|
+
if (value.summary !== "") {
|
|
2009
|
+
const contextEntries = this.session.getBranch().filter(producesContextMessage);
|
|
2010
|
+
const cut = contextEntries.length - value.keptMessages.length;
|
|
2011
|
+
const firstKept = cut >= 0 ? contextEntries[cut] : void 0;
|
|
2012
|
+
if (!firstKept) {
|
|
2013
|
+
this.unpersistedSummary = value.summary;
|
|
2014
|
+
this.buildAgent(
|
|
2015
|
+
[
|
|
2016
|
+
{
|
|
2017
|
+
role: "user",
|
|
2018
|
+
content: `[Earlier conversation summarized]
|
|
2019
|
+
${value.summary}`,
|
|
2020
|
+
timestamp: Date.now()
|
|
2021
|
+
},
|
|
2022
|
+
...value.keptMessages
|
|
2023
|
+
],
|
|
2024
|
+
this.agent
|
|
2025
|
+
);
|
|
2026
|
+
return value;
|
|
2027
|
+
}
|
|
2028
|
+
this.session.appendCompaction(value.summary, firstKept.id, value.tokensBefore);
|
|
2029
|
+
} else if (this.session.buildContext().messages.length === 0) {
|
|
2030
|
+
return value;
|
|
2031
|
+
}
|
|
2032
|
+
this.reseedFromSession(this.agent);
|
|
2033
|
+
return value;
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Move the session's active branch to `entryId` and reseed the agent from that
|
|
2037
|
+
* branch's reconstructed context, so subsequent prompts continue from the
|
|
2038
|
+
* navigated-to point (rewind / branch-switch).
|
|
2039
|
+
*/
|
|
2040
|
+
navigateTree(entryId) {
|
|
2041
|
+
if (this.busy) throw new AgentHarnessError("busy", "AgentHarness is busy");
|
|
2042
|
+
this.session.moveTo(entryId);
|
|
2043
|
+
this.reseedFromSession();
|
|
2044
|
+
}
|
|
2045
|
+
/** Subscribe to the underlying agent's lifecycle events. */
|
|
2046
|
+
subscribe(listener) {
|
|
2047
|
+
this.listeners.add(listener);
|
|
2048
|
+
return () => {
|
|
2049
|
+
this.listeners.delete(listener);
|
|
2050
|
+
};
|
|
2051
|
+
}
|
|
2052
|
+
/**
|
|
2053
|
+
* Typed event subscription: `handler` receives only events of `type`, narrowed
|
|
2054
|
+
* to that variant. Returns an unsubscribe function.
|
|
2055
|
+
*/
|
|
2056
|
+
on(type, handler) {
|
|
2057
|
+
return this.subscribe((event) => {
|
|
2058
|
+
if (event.type === type) return handler(event);
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
/** Abort the active run. */
|
|
2062
|
+
abort() {
|
|
2063
|
+
this.agent.abort();
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* Rebuild the inner agent over `messages`, re-wiring the event forwarder.
|
|
2067
|
+
* When `previous` is given (a rebuild that CONTINUES the same conversation),
|
|
2068
|
+
* its pending steer/followUp/nextTurn queue is carried into the new agent.
|
|
2069
|
+
*/
|
|
2070
|
+
buildAgent(messages, previous) {
|
|
2071
|
+
this.agentUnsub?.();
|
|
2072
|
+
this.agent = new Agent({ ...this.agentOptions, messages });
|
|
2073
|
+
if (previous) this.agent.adoptPendingFrom(previous);
|
|
2074
|
+
this.agentUnsub = this.agent.subscribe(async (event) => {
|
|
2075
|
+
for (const listener of this.listeners) await listener(event);
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
/** Reseed the agent from the session's current branch context. */
|
|
2079
|
+
reseedFromSession(previous) {
|
|
2080
|
+
this.unpersistedSummary = void 0;
|
|
2081
|
+
this.buildAgent(this.session.buildContext().messages, previous);
|
|
2082
|
+
}
|
|
2083
|
+
/** Normalize a string prompt into a user {@link AgentMessage}. */
|
|
2084
|
+
toMessage(message) {
|
|
2085
|
+
return typeof message === "string" ? { role: "user", content: message, timestamp: Date.now() } : message;
|
|
2086
|
+
}
|
|
2087
|
+
};
|
|
2088
|
+
|
|
2089
|
+
// src/system-prompt.ts
|
|
2090
|
+
var DEFAULT_SEPARATOR = "\n\n";
|
|
2091
|
+
var SYSTEM_PROMPT_SECTIONS = {
|
|
2092
|
+
/** Base persona / role instructions. */
|
|
2093
|
+
persona: "persona",
|
|
2094
|
+
/** Caller-supplied extra instructions appended to the base. */
|
|
2095
|
+
custom: "custom",
|
|
2096
|
+
/** Skills catalog slot — filled by the skills subsystem. */
|
|
2097
|
+
skills: "skills",
|
|
2098
|
+
/** Runtime environment: current date + working directory (placed last). */
|
|
2099
|
+
environment: "environment"
|
|
2100
|
+
};
|
|
2101
|
+
function renderedContent(section) {
|
|
2102
|
+
const trimmed = section.content?.trim();
|
|
2103
|
+
return trimmed ? trimmed : void 0;
|
|
2104
|
+
}
|
|
2105
|
+
function assembleSystemPrompt(sections, options = {}) {
|
|
2106
|
+
const separator = options.separator ?? DEFAULT_SEPARATOR;
|
|
2107
|
+
const parts = [];
|
|
2108
|
+
for (const section of sections) {
|
|
2109
|
+
const content = renderedContent(section);
|
|
2110
|
+
if (content !== void 0) parts.push(content);
|
|
2111
|
+
}
|
|
2112
|
+
return parts.join(separator);
|
|
2113
|
+
}
|
|
2114
|
+
var SystemPromptBuilder = class {
|
|
2115
|
+
sections = [];
|
|
2116
|
+
separator;
|
|
2117
|
+
constructor(options = {}) {
|
|
2118
|
+
this.separator = options.separator ?? DEFAULT_SEPARATOR;
|
|
2119
|
+
}
|
|
2120
|
+
/** Append a named section in order. Chainable. */
|
|
2121
|
+
section(name, content) {
|
|
2122
|
+
this.sections.push({ name, content });
|
|
2123
|
+
return this;
|
|
2124
|
+
}
|
|
2125
|
+
/** Render the accumulated sections to a single system-prompt string. */
|
|
2126
|
+
render() {
|
|
2127
|
+
return assembleSystemPrompt(this.sections, { separator: this.separator });
|
|
2128
|
+
}
|
|
2129
|
+
};
|
|
2130
|
+
function environmentSection(input) {
|
|
2131
|
+
const now = input.date;
|
|
2132
|
+
const year = now.getFullYear();
|
|
2133
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
2134
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
2135
|
+
const lines = [`Current date: ${year}-${month}-${day}`];
|
|
2136
|
+
if (input.cwd) {
|
|
2137
|
+
lines.push(`Current working directory: ${input.cwd.replace(/\\/g, "/")}`);
|
|
2138
|
+
}
|
|
2139
|
+
return { name: SYSTEM_PROMPT_SECTIONS.environment, content: lines.join("\n") };
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
// src/prompt-templates.ts
|
|
2143
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
2144
|
+
import { basename } from "path";
|
|
2145
|
+
|
|
2146
|
+
// src/loader.ts
|
|
2147
|
+
import { readFile } from "fs/promises";
|
|
2148
|
+
import { Type } from "@ap3x/ai";
|
|
2149
|
+
import { parse as yamlParse } from "yaml";
|
|
2150
|
+
var AgentSpecSchema = Type.Object({
|
|
2151
|
+
name: Type.String(),
|
|
2152
|
+
description: Type.Optional(Type.String()),
|
|
2153
|
+
modelName: Type.Optional(Type.String()),
|
|
2154
|
+
systemPrompt: Type.Optional(Type.String()),
|
|
2155
|
+
temperature: Type.Optional(Type.Number()),
|
|
2156
|
+
maxLoops: Type.Optional(Type.Integer()),
|
|
2157
|
+
maxTokens: Type.Optional(Type.Integer()),
|
|
2158
|
+
contextLength: Type.Optional(Type.Integer()),
|
|
2159
|
+
outputType: Type.Optional(Type.String()),
|
|
2160
|
+
autosave: Type.Optional(Type.Boolean()),
|
|
2161
|
+
verbose: Type.Optional(Type.Boolean()),
|
|
2162
|
+
streamingOn: Type.Optional(Type.Boolean()),
|
|
2163
|
+
dynamicTemperatureEnabled: Type.Optional(Type.Boolean()),
|
|
2164
|
+
retryAttempts: Type.Optional(Type.Integer()),
|
|
2165
|
+
userName: Type.Optional(Type.String())
|
|
2166
|
+
});
|
|
2167
|
+
var DEFAULT_MODEL = "gpt-4";
|
|
2168
|
+
function asString(value) {
|
|
2169
|
+
if (value === void 0 || value === null) return void 0;
|
|
2170
|
+
return String(value);
|
|
2171
|
+
}
|
|
2172
|
+
function asNumber(value) {
|
|
2173
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
2174
|
+
const n = Number(value);
|
|
2175
|
+
return Number.isNaN(n) ? void 0 : n;
|
|
2176
|
+
}
|
|
2177
|
+
function asBool(value) {
|
|
2178
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
2179
|
+
if (typeof value === "boolean") return value;
|
|
2180
|
+
const s = String(value).trim().toLowerCase();
|
|
2181
|
+
if (s === "true" || s === "1" || s === "yes") return true;
|
|
2182
|
+
if (s === "false" || s === "0" || s === "no") return false;
|
|
2183
|
+
return void 0;
|
|
2184
|
+
}
|
|
2185
|
+
function buildSpec(raw, fallbackSystemPrompt) {
|
|
2186
|
+
const name = asString(raw.name ?? raw.agent_name ?? raw.agentName);
|
|
2187
|
+
if (!name) throw new Error("Agent spec is missing a name.");
|
|
2188
|
+
const spec = { name };
|
|
2189
|
+
const description = asString(raw.description);
|
|
2190
|
+
if (description !== void 0) spec.description = description;
|
|
2191
|
+
spec.modelName = asString(raw.model_name ?? raw.modelName) ?? DEFAULT_MODEL;
|
|
2192
|
+
const systemPrompt = asString(raw.system_prompt ?? raw.systemPrompt) ?? fallbackSystemPrompt;
|
|
2193
|
+
if (systemPrompt !== void 0) spec.systemPrompt = systemPrompt;
|
|
2194
|
+
const temperature = asNumber(raw.temperature);
|
|
2195
|
+
if (temperature !== void 0) spec.temperature = temperature;
|
|
2196
|
+
const maxLoops = asNumber(raw.max_loops ?? raw.maxLoops);
|
|
2197
|
+
if (maxLoops !== void 0) spec.maxLoops = maxLoops;
|
|
2198
|
+
const maxTokens = asNumber(raw.max_tokens ?? raw.maxTokens);
|
|
2199
|
+
if (maxTokens !== void 0) spec.maxTokens = maxTokens;
|
|
2200
|
+
const contextLength = asNumber(raw.context_length ?? raw.contextLength);
|
|
2201
|
+
if (contextLength !== void 0) spec.contextLength = contextLength;
|
|
2202
|
+
const outputType = asString(raw.output_type ?? raw.outputType);
|
|
2203
|
+
if (outputType !== void 0) spec.outputType = outputType;
|
|
2204
|
+
const retryAttempts = asNumber(raw.retry_attempts ?? raw.retryAttempts);
|
|
2205
|
+
if (retryAttempts !== void 0) spec.retryAttempts = retryAttempts;
|
|
2206
|
+
const userName = asString(raw.user_name ?? raw.userName);
|
|
2207
|
+
if (userName !== void 0) spec.userName = userName;
|
|
2208
|
+
const autosave = asBool(raw.autosave);
|
|
2209
|
+
if (autosave !== void 0) spec.autosave = autosave;
|
|
2210
|
+
const verbose = asBool(raw.verbose);
|
|
2211
|
+
if (verbose !== void 0) spec.verbose = verbose;
|
|
2212
|
+
const streamingOn = asBool(raw.streaming_on ?? raw.streamingOn);
|
|
2213
|
+
if (streamingOn !== void 0) spec.streamingOn = streamingOn;
|
|
2214
|
+
const dynamicTemp = asBool(raw.dynamic_temperature_enabled ?? raw.dynamicTemperatureEnabled);
|
|
2215
|
+
if (dynamicTemp !== void 0) spec.dynamicTemperatureEnabled = dynamicTemp;
|
|
2216
|
+
return spec;
|
|
2217
|
+
}
|
|
2218
|
+
function parseYamlFrontmatter(content) {
|
|
2219
|
+
const lines = content.split("\n");
|
|
2220
|
+
if (lines[0]?.trim() !== "---") return { frontmatter: {}, body: content };
|
|
2221
|
+
let endMarker = -1;
|
|
2222
|
+
for (let i = 1; i < lines.length; i++) {
|
|
2223
|
+
if (lines[i]?.trim() === "---") {
|
|
2224
|
+
endMarker = i;
|
|
2225
|
+
break;
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
if (endMarker === -1) return { frontmatter: {}, body: content };
|
|
2229
|
+
const frontmatterText = lines.slice(1, endMarker).join("\n");
|
|
2230
|
+
const body = lines.slice(endMarker + 1).join("\n").trim();
|
|
2231
|
+
let frontmatter = {};
|
|
2232
|
+
try {
|
|
2233
|
+
frontmatter = yamlParse(frontmatterText) ?? {};
|
|
2234
|
+
} catch {
|
|
2235
|
+
return { frontmatter: {}, body: content };
|
|
2236
|
+
}
|
|
2237
|
+
return { frontmatter, body };
|
|
2238
|
+
}
|
|
2239
|
+
function parseCsvLine(line) {
|
|
2240
|
+
const fields = [];
|
|
2241
|
+
let current = "";
|
|
2242
|
+
let inQuotes = false;
|
|
2243
|
+
for (let i = 0; i < line.length; i++) {
|
|
2244
|
+
const ch = line[i];
|
|
2245
|
+
if (inQuotes) {
|
|
2246
|
+
if (ch === '"') {
|
|
2247
|
+
if (line[i + 1] === '"') {
|
|
2248
|
+
current += '"';
|
|
2249
|
+
i++;
|
|
2250
|
+
} else {
|
|
2251
|
+
inQuotes = false;
|
|
2252
|
+
}
|
|
2253
|
+
} else {
|
|
2254
|
+
current += ch;
|
|
2255
|
+
}
|
|
2256
|
+
} else if (ch === '"') {
|
|
2257
|
+
inQuotes = true;
|
|
2258
|
+
} else if (ch === ",") {
|
|
2259
|
+
fields.push(current);
|
|
2260
|
+
current = "";
|
|
2261
|
+
} else {
|
|
2262
|
+
current += ch;
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
fields.push(current);
|
|
2266
|
+
return fields;
|
|
2267
|
+
}
|
|
2268
|
+
var AgentLoader = class {
|
|
2269
|
+
concurrent;
|
|
2270
|
+
constructor(options = {}) {
|
|
2271
|
+
this.concurrent = options.concurrent ?? true;
|
|
2272
|
+
}
|
|
2273
|
+
/** Parse one markdown file (YAML frontmatter + body as system prompt). */
|
|
2274
|
+
async loadAgentFromMarkdown(filePath) {
|
|
2275
|
+
const content = await readFile(filePath, "utf-8");
|
|
2276
|
+
const { frontmatter, body } = parseYamlFrontmatter(content);
|
|
2277
|
+
return buildSpec(frontmatter, body || void 0);
|
|
2278
|
+
}
|
|
2279
|
+
/** Parse one or more markdown files. */
|
|
2280
|
+
async loadAgentsFromMarkdown(filePaths, options = {}) {
|
|
2281
|
+
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
|
2282
|
+
const concurrent = options.concurrent ?? this.concurrent;
|
|
2283
|
+
if (concurrent) {
|
|
2284
|
+
return Promise.all(paths.map((p) => this.loadAgentFromMarkdown(p)));
|
|
2285
|
+
}
|
|
2286
|
+
const specs = [];
|
|
2287
|
+
for (const p of paths) specs.push(await this.loadAgentFromMarkdown(p));
|
|
2288
|
+
return specs;
|
|
2289
|
+
}
|
|
2290
|
+
/** Parse a YAML file containing one agent or a list under `agents`. */
|
|
2291
|
+
async loadAgentsFromYaml(yamlFile) {
|
|
2292
|
+
const content = await readFile(yamlFile, "utf-8");
|
|
2293
|
+
const data = yamlParse(content);
|
|
2294
|
+
let entries;
|
|
2295
|
+
if (Array.isArray(data)) {
|
|
2296
|
+
entries = data;
|
|
2297
|
+
} else if (data && typeof data === "object" && Array.isArray(data.agents)) {
|
|
2298
|
+
entries = data.agents;
|
|
2299
|
+
} else if (data && typeof data === "object") {
|
|
2300
|
+
entries = [data];
|
|
2301
|
+
} else {
|
|
2302
|
+
entries = [];
|
|
2303
|
+
}
|
|
2304
|
+
return entries.map((e) => buildSpec(e));
|
|
2305
|
+
}
|
|
2306
|
+
/** Parse several YAML files. */
|
|
2307
|
+
async loadManyAgentsFromYaml(yamlFiles) {
|
|
2308
|
+
return Promise.all(yamlFiles.map((f) => this.loadAgentsFromYaml(f)));
|
|
2309
|
+
}
|
|
2310
|
+
/** Parse a CSV file (first row is the header). */
|
|
2311
|
+
async loadAgentsFromCsv(csvFile) {
|
|
2312
|
+
const content = await readFile(csvFile, "utf-8");
|
|
2313
|
+
const lines = content.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
2314
|
+
if (lines.length === 0) return [];
|
|
2315
|
+
const header = parseCsvLine(lines[0]).map((h) => h.trim());
|
|
2316
|
+
const specs = [];
|
|
2317
|
+
for (let i = 1; i < lines.length; i++) {
|
|
2318
|
+
const cells = parseCsvLine(lines[i]);
|
|
2319
|
+
const row = {};
|
|
2320
|
+
header.forEach((key, idx) => {
|
|
2321
|
+
row[key] = cells[idx];
|
|
2322
|
+
});
|
|
2323
|
+
specs.push(buildSpec(row));
|
|
2324
|
+
}
|
|
2325
|
+
return specs;
|
|
2326
|
+
}
|
|
2327
|
+
/** Dispatch by file extension (.md / .yaml / .yml / .csv). */
|
|
2328
|
+
async auto(filePath) {
|
|
2329
|
+
const lower = filePath.toLowerCase();
|
|
2330
|
+
if (lower.endsWith(".md")) return this.loadAgentsFromMarkdown(filePath);
|
|
2331
|
+
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return this.loadAgentsFromYaml(filePath);
|
|
2332
|
+
if (lower.endsWith(".csv")) return this.loadAgentsFromCsv(filePath);
|
|
2333
|
+
throw new Error(`Unsupported file type: ${filePath}`);
|
|
2334
|
+
}
|
|
2335
|
+
/** Load a single spec from a file via {@link auto}. */
|
|
2336
|
+
async loadSingleAgent(filePath) {
|
|
2337
|
+
const specs = await this.auto(filePath);
|
|
2338
|
+
const first = specs[0];
|
|
2339
|
+
if (!first) throw new Error(`No agent specifications found in ${filePath}`);
|
|
2340
|
+
return first;
|
|
2341
|
+
}
|
|
2342
|
+
/** Load specs from several files (each may yield multiple), flattened. */
|
|
2343
|
+
async loadMultipleAgents(filePaths) {
|
|
2344
|
+
const results = await Promise.all(filePaths.map((p) => this.auto(p)));
|
|
2345
|
+
return results.flat();
|
|
2346
|
+
}
|
|
2347
|
+
};
|
|
2348
|
+
var AgentConfigSchema = Type.Object({
|
|
2349
|
+
uuid: Type.String(),
|
|
2350
|
+
name: Type.String(),
|
|
2351
|
+
description: Type.Optional(Type.String()),
|
|
2352
|
+
timeAdded: Type.String(),
|
|
2353
|
+
config: Type.Record(Type.String(), Type.Unknown())
|
|
2354
|
+
});
|
|
2355
|
+
var AgentRegistrySchema = Type.Object({
|
|
2356
|
+
name: Type.String(),
|
|
2357
|
+
description: Type.String(),
|
|
2358
|
+
agents: Type.Array(AgentConfigSchema),
|
|
2359
|
+
timeRegistryCreated: Type.String(),
|
|
2360
|
+
numberOfAgents: Type.Integer()
|
|
2361
|
+
});
|
|
2362
|
+
function registrableName(agent) {
|
|
2363
|
+
return agent.agentName ?? agent.name;
|
|
2364
|
+
}
|
|
2365
|
+
var AgentRegistry = class {
|
|
2366
|
+
name;
|
|
2367
|
+
description;
|
|
2368
|
+
agents = /* @__PURE__ */ new Map();
|
|
2369
|
+
timeCreated;
|
|
2370
|
+
constructor(options = {}) {
|
|
2371
|
+
this.name = options.name ?? "Agent Registry";
|
|
2372
|
+
this.description = options.description ?? "A registry for managing agents.";
|
|
2373
|
+
this.timeCreated = (/* @__PURE__ */ new Date()).toISOString();
|
|
2374
|
+
if (options.agents) this.addMany(options.agents);
|
|
2375
|
+
}
|
|
2376
|
+
/** Add an agent. Throws if the name is already taken. */
|
|
2377
|
+
add(agent) {
|
|
2378
|
+
const name = registrableName(agent);
|
|
2379
|
+
if (!name) throw new Error("Cannot register an agent without a name.");
|
|
2380
|
+
if (this.agents.has(name)) {
|
|
2381
|
+
throw new Error(`Agent with name ${name} already exists.`);
|
|
2382
|
+
}
|
|
2383
|
+
this.agents.set(name, agent);
|
|
2384
|
+
}
|
|
2385
|
+
/** Add several agents. */
|
|
2386
|
+
addMany(agents) {
|
|
2387
|
+
for (const agent of agents) this.add(agent);
|
|
2388
|
+
}
|
|
2389
|
+
/** Delete an agent by name. Throws if it does not exist. */
|
|
2390
|
+
delete(name) {
|
|
2391
|
+
if (!this.agents.has(name)) throw new Error(`Agent with name ${name} does not exist.`);
|
|
2392
|
+
this.agents.delete(name);
|
|
2393
|
+
}
|
|
2394
|
+
/** Replace an agent by name. Throws if it does not exist. */
|
|
2395
|
+
updateAgent(name, newAgent) {
|
|
2396
|
+
if (!this.agents.has(name)) throw new Error(`Agent with name ${name} does not exist.`);
|
|
2397
|
+
this.agents.set(name, newAgent);
|
|
2398
|
+
}
|
|
2399
|
+
/** Get an agent by name. Throws if it does not exist. */
|
|
2400
|
+
get(name) {
|
|
2401
|
+
const agent = this.agents.get(name);
|
|
2402
|
+
if (!agent) throw new Error(`Agent with name ${name} does not exist.`);
|
|
2403
|
+
return agent;
|
|
2404
|
+
}
|
|
2405
|
+
/** List all registered agent names. */
|
|
2406
|
+
listAgents() {
|
|
2407
|
+
return [...this.agents.keys()];
|
|
2408
|
+
}
|
|
2409
|
+
/** Return all registered agents. */
|
|
2410
|
+
returnAllAgents() {
|
|
2411
|
+
return [...this.agents.values()];
|
|
2412
|
+
}
|
|
2413
|
+
/** Filter agents by a predicate (or return all when omitted). */
|
|
2414
|
+
query(condition) {
|
|
2415
|
+
const all = this.returnAllAgents();
|
|
2416
|
+
return condition ? all.filter(condition) : all;
|
|
2417
|
+
}
|
|
2418
|
+
/** Find an agent by name (undefined if absent). */
|
|
2419
|
+
findAgentByName(name) {
|
|
2420
|
+
return this.agents.get(name);
|
|
2421
|
+
}
|
|
2422
|
+
/** Find an agent by id (undefined if absent). */
|
|
2423
|
+
findAgentById(id) {
|
|
2424
|
+
for (const agent of this.agents.values()) {
|
|
2425
|
+
if (agent.id === id) return agent;
|
|
2426
|
+
}
|
|
2427
|
+
return void 0;
|
|
2428
|
+
}
|
|
2429
|
+
/** Number of registered agents. */
|
|
2430
|
+
get size() {
|
|
2431
|
+
return this.agents.size;
|
|
2432
|
+
}
|
|
2433
|
+
/** Serialize every agent's `toDict()` keyed by name. */
|
|
2434
|
+
agentsToJson() {
|
|
2435
|
+
const out = {};
|
|
2436
|
+
for (const [name, agent] of this.agents) {
|
|
2437
|
+
out[name] = agent.toDict ? agent.toDict() : { ...agent };
|
|
2438
|
+
}
|
|
2439
|
+
return JSON.stringify(out, null, 4);
|
|
2440
|
+
}
|
|
2441
|
+
/** Build a validated {@link AgentConfig} record for an agent. */
|
|
2442
|
+
agentToModel(agent) {
|
|
2443
|
+
return {
|
|
2444
|
+
uuid: agent.id ?? uuidv7(),
|
|
2445
|
+
name: registrableName(agent) ?? "unknown",
|
|
2446
|
+
description: agent.description ?? "No description provided",
|
|
2447
|
+
timeAdded: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2448
|
+
config: agent.toDict ? agent.toDict() : { ...agent }
|
|
2449
|
+
};
|
|
2450
|
+
}
|
|
2451
|
+
/** A validated snapshot of the whole registry. */
|
|
2452
|
+
toSchema() {
|
|
2453
|
+
return {
|
|
2454
|
+
name: this.name,
|
|
2455
|
+
description: this.description,
|
|
2456
|
+
agents: this.returnAllAgents().map((a) => this.agentToModel(a)),
|
|
2457
|
+
timeRegistryCreated: this.timeCreated,
|
|
2458
|
+
numberOfAgents: this.agents.size
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
};
|
|
2462
|
+
|
|
2463
|
+
// src/prompt-templates.ts
|
|
2464
|
+
var DESCRIPTION_PREVIEW_LIMIT = 60;
|
|
2465
|
+
function parseCommandArgs(argsString) {
|
|
2466
|
+
const args = [];
|
|
2467
|
+
let current = "";
|
|
2468
|
+
let inQuote = null;
|
|
2469
|
+
for (let i = 0; i < argsString.length; i++) {
|
|
2470
|
+
const char = argsString[i];
|
|
2471
|
+
if (inQuote) {
|
|
2472
|
+
if (char === inQuote) inQuote = null;
|
|
2473
|
+
else current += char;
|
|
2474
|
+
} else if (char === '"' || char === "'") {
|
|
2475
|
+
inQuote = char;
|
|
2476
|
+
} else if (char === " " || char === " ") {
|
|
2477
|
+
if (current) {
|
|
2478
|
+
args.push(current);
|
|
2479
|
+
current = "";
|
|
2480
|
+
}
|
|
2481
|
+
} else {
|
|
2482
|
+
current += char;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
if (current) args.push(current);
|
|
2486
|
+
return args;
|
|
2487
|
+
}
|
|
2488
|
+
function substituteArgs(content, args) {
|
|
2489
|
+
let result = content;
|
|
2490
|
+
result = result.replace(
|
|
2491
|
+
/\$(\d+)/g,
|
|
2492
|
+
(_match, num) => args[Number.parseInt(num, 10) - 1] ?? ""
|
|
2493
|
+
);
|
|
2494
|
+
result = result.replace(
|
|
2495
|
+
/\$\{@:(\d+)(?::(\d+))?\}/g,
|
|
2496
|
+
(_match, startStr, lengthStr) => {
|
|
2497
|
+
let start = Number.parseInt(startStr, 10) - 1;
|
|
2498
|
+
if (start < 0) start = 0;
|
|
2499
|
+
if (lengthStr !== void 0) {
|
|
2500
|
+
return args.slice(start, start + Number.parseInt(lengthStr, 10)).join(" ");
|
|
2501
|
+
}
|
|
2502
|
+
return args.slice(start).join(" ");
|
|
2503
|
+
}
|
|
2504
|
+
);
|
|
2505
|
+
const allArgs = args.join(" ");
|
|
2506
|
+
result = result.replace(/\$ARGUMENTS/g, allArgs);
|
|
2507
|
+
result = result.replace(/\$@/g, allArgs);
|
|
2508
|
+
return result;
|
|
2509
|
+
}
|
|
2510
|
+
function parsePromptTemplate(name, content) {
|
|
2511
|
+
const { frontmatter, body } = parseYamlFrontmatter(content);
|
|
2512
|
+
let description = typeof frontmatter.description === "string" ? frontmatter.description : "";
|
|
2513
|
+
if (!description) {
|
|
2514
|
+
const firstLine = body.split("\n").find((line) => line.trim());
|
|
2515
|
+
if (firstLine) {
|
|
2516
|
+
description = firstLine.slice(0, DESCRIPTION_PREVIEW_LIMIT);
|
|
2517
|
+
if (firstLine.length > DESCRIPTION_PREVIEW_LIMIT) description += "...";
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
return { name, description, content: body };
|
|
2521
|
+
}
|
|
2522
|
+
async function loadPromptTemplate(filePath) {
|
|
2523
|
+
const content = await readFile2(filePath, "utf-8");
|
|
2524
|
+
const name = basename(filePath).replace(/\.md$/i, "");
|
|
2525
|
+
return parsePromptTemplate(name, content);
|
|
2526
|
+
}
|
|
2527
|
+
function renderPromptTemplate(template, args = []) {
|
|
2528
|
+
return substituteArgs(template.content, args);
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
// src/skills.ts
|
|
2532
|
+
import { access, readFile as readFile3, readdir } from "fs/promises";
|
|
2533
|
+
import { basename as basename2, dirname, join } from "path";
|
|
2534
|
+
function parseSkill(fallbackName, content, location) {
|
|
2535
|
+
const { frontmatter, body } = parseYamlFrontmatter(content);
|
|
2536
|
+
const fmName = frontmatter.name;
|
|
2537
|
+
const name = typeof fmName === "string" && fmName.trim() ? fmName : fallbackName;
|
|
2538
|
+
const description = typeof frontmatter.description === "string" ? frontmatter.description : "";
|
|
2539
|
+
return {
|
|
2540
|
+
name,
|
|
2541
|
+
description,
|
|
2542
|
+
location,
|
|
2543
|
+
body,
|
|
2544
|
+
disableModelInvocation: frontmatter["disable-model-invocation"] === true
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
async function loadSkill(filePath) {
|
|
2548
|
+
const content = await readFile3(filePath, "utf-8");
|
|
2549
|
+
const fallbackName = basename2(filePath).replace(/\.md$/i, "");
|
|
2550
|
+
return parseSkill(fallbackName, content, filePath);
|
|
2551
|
+
}
|
|
2552
|
+
async function loadSkills(filePaths) {
|
|
2553
|
+
return Promise.all(filePaths.map((p) => loadSkill(p)));
|
|
2554
|
+
}
|
|
2555
|
+
async function discoverSkillFiles(dir) {
|
|
2556
|
+
let entries;
|
|
2557
|
+
try {
|
|
2558
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
2559
|
+
} catch {
|
|
2560
|
+
return [];
|
|
2561
|
+
}
|
|
2562
|
+
const files = [];
|
|
2563
|
+
for (const entry of entries) {
|
|
2564
|
+
if (entry.isDirectory()) {
|
|
2565
|
+
const candidate = join(dir, entry.name, "SKILL.md");
|
|
2566
|
+
try {
|
|
2567
|
+
await access(candidate);
|
|
2568
|
+
files.push(candidate);
|
|
2569
|
+
} catch {
|
|
2570
|
+
}
|
|
2571
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
|
|
2572
|
+
files.push(join(dir, entry.name));
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
return files.sort();
|
|
2576
|
+
}
|
|
2577
|
+
async function discoverSkills(dir) {
|
|
2578
|
+
const files = await discoverSkillFiles(dir);
|
|
2579
|
+
const skills = [];
|
|
2580
|
+
for (const file of files) {
|
|
2581
|
+
let content;
|
|
2582
|
+
try {
|
|
2583
|
+
content = await readFile3(file, "utf-8");
|
|
2584
|
+
} catch {
|
|
2585
|
+
continue;
|
|
2586
|
+
}
|
|
2587
|
+
const base = basename2(file);
|
|
2588
|
+
const parent = dirname(file);
|
|
2589
|
+
const fallback = base.toLowerCase() === "skill.md" && parent !== dir ? basename2(parent) : base.replace(/\.md$/i, "");
|
|
2590
|
+
skills.push(parseSkill(fallback, content, file));
|
|
2591
|
+
}
|
|
2592
|
+
return skills;
|
|
2593
|
+
}
|
|
2594
|
+
var SkillRegistry = class {
|
|
2595
|
+
skills = /* @__PURE__ */ new Map();
|
|
2596
|
+
constructor(options = {}) {
|
|
2597
|
+
if (options.skills) this.addMany(options.skills);
|
|
2598
|
+
}
|
|
2599
|
+
/** Add a skill. Throws if the name is already registered. */
|
|
2600
|
+
add(skill) {
|
|
2601
|
+
if (this.skills.has(skill.name)) {
|
|
2602
|
+
throw new Error(`Skill with name ${skill.name} already exists.`);
|
|
2603
|
+
}
|
|
2604
|
+
this.skills.set(skill.name, skill);
|
|
2605
|
+
}
|
|
2606
|
+
/** Add several skills. */
|
|
2607
|
+
addMany(skills) {
|
|
2608
|
+
for (const skill of skills) this.add(skill);
|
|
2609
|
+
}
|
|
2610
|
+
/** Get a skill by name. Throws if it does not exist. */
|
|
2611
|
+
get(name) {
|
|
2612
|
+
const skill = this.skills.get(name);
|
|
2613
|
+
if (!skill) throw new Error(`Skill with name ${name} does not exist.`);
|
|
2614
|
+
return skill;
|
|
2615
|
+
}
|
|
2616
|
+
/** Find a skill by name (undefined if absent). */
|
|
2617
|
+
find(name) {
|
|
2618
|
+
return this.skills.get(name);
|
|
2619
|
+
}
|
|
2620
|
+
/** List all registered skills. */
|
|
2621
|
+
list() {
|
|
2622
|
+
return [...this.skills.values()];
|
|
2623
|
+
}
|
|
2624
|
+
/** Filter skills by a predicate (or return all when omitted). */
|
|
2625
|
+
query(condition) {
|
|
2626
|
+
const all = this.list();
|
|
2627
|
+
return condition ? all.filter(condition) : all;
|
|
2628
|
+
}
|
|
2629
|
+
/** Number of registered skills. */
|
|
2630
|
+
get size() {
|
|
2631
|
+
return this.skills.size;
|
|
2632
|
+
}
|
|
2633
|
+
};
|
|
2634
|
+
var CATALOG_INTRO = "Skills below give focused instructions for particular tasks. When a task matches a skill's description, use the read tool to load its full instructions from the file at its location; resolve any relative path inside that file against the skill's directory.";
|
|
2635
|
+
function formatSkillsForSystemPrompt(skills) {
|
|
2636
|
+
const visible = skills.filter((s) => !s.disableModelInvocation);
|
|
2637
|
+
if (visible.length === 0) return "";
|
|
2638
|
+
const lines = [CATALOG_INTRO, "", "<available_skills>"];
|
|
2639
|
+
for (const skill of visible) {
|
|
2640
|
+
lines.push(" <skill>");
|
|
2641
|
+
lines.push(` <name>${escapeXml(skill.name)}</name>`);
|
|
2642
|
+
lines.push(` <description>${escapeXml(skill.description)}</description>`);
|
|
2643
|
+
lines.push(` <location>${escapeXml(skill.location)}</location>`);
|
|
2644
|
+
lines.push(" </skill>");
|
|
2645
|
+
}
|
|
2646
|
+
lines.push("</available_skills>");
|
|
2647
|
+
return lines.join("\n");
|
|
2648
|
+
}
|
|
2649
|
+
function escapeXml(value) {
|
|
2650
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
// src/session-repo.ts
|
|
2654
|
+
import {
|
|
2655
|
+
appendFileSync,
|
|
2656
|
+
existsSync,
|
|
2657
|
+
mkdirSync,
|
|
2658
|
+
readFileSync,
|
|
2659
|
+
readdirSync,
|
|
2660
|
+
rmSync,
|
|
2661
|
+
statSync
|
|
2662
|
+
} from "fs";
|
|
2663
|
+
import { homedir } from "os";
|
|
2664
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
2665
|
+
var FileJsonlFile = class {
|
|
2666
|
+
constructor(path) {
|
|
2667
|
+
this.path = path;
|
|
2668
|
+
}
|
|
2669
|
+
path;
|
|
2670
|
+
ensuredDir = false;
|
|
2671
|
+
/** Read the entire file, or "" when it does not exist yet. */
|
|
2672
|
+
read() {
|
|
2673
|
+
try {
|
|
2674
|
+
return readFileSync(this.path, "utf8");
|
|
2675
|
+
} catch (error) {
|
|
2676
|
+
if (error.code === "ENOENT") return "";
|
|
2677
|
+
throw error;
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
/** Append a line (the caller supplies the trailing newline). */
|
|
2681
|
+
append(line) {
|
|
2682
|
+
if (!this.ensuredDir) {
|
|
2683
|
+
mkdirSync(dirname2(this.path), { recursive: true });
|
|
2684
|
+
this.ensuredDir = true;
|
|
2685
|
+
}
|
|
2686
|
+
appendFileSync(this.path, line);
|
|
2687
|
+
}
|
|
2688
|
+
};
|
|
2689
|
+
function entriesToFork(storage, options) {
|
|
2690
|
+
if (!options.entryId) return storage.getEntries();
|
|
2691
|
+
const target = storage.getEntry(options.entryId);
|
|
2692
|
+
if (!target) {
|
|
2693
|
+
throw new SessionError("not_found", `Fork target not found: ${options.entryId}`);
|
|
2694
|
+
}
|
|
2695
|
+
let effectiveLeafId;
|
|
2696
|
+
if ((options.position ?? "before") === "at") {
|
|
2697
|
+
effectiveLeafId = target.id;
|
|
2698
|
+
} else {
|
|
2699
|
+
if (target.type !== "message" || target.message.role !== "user") {
|
|
2700
|
+
throw new SessionError(
|
|
2701
|
+
"invalid_session",
|
|
2702
|
+
`Fork target is not a user message: ${options.entryId}`
|
|
2703
|
+
);
|
|
2704
|
+
}
|
|
2705
|
+
effectiveLeafId = target.parentId;
|
|
2706
|
+
}
|
|
2707
|
+
if (effectiveLeafId === null) return [];
|
|
2708
|
+
return storage.getPathToRoot(effectiveLeafId);
|
|
2709
|
+
}
|
|
2710
|
+
var JsonlSessionRepo = class {
|
|
2711
|
+
root;
|
|
2712
|
+
constructor(options) {
|
|
2713
|
+
this.root = options?.root ?? join2(homedir(), ".ap3x", "sessions");
|
|
2714
|
+
}
|
|
2715
|
+
pathFor(id) {
|
|
2716
|
+
return join2(this.root, `${id}.jsonl`);
|
|
2717
|
+
}
|
|
2718
|
+
fileFor(id) {
|
|
2719
|
+
return new FileJsonlFile(this.pathFor(id));
|
|
2720
|
+
}
|
|
2721
|
+
create(options = {}) {
|
|
2722
|
+
const id = options.id ?? createSessionId();
|
|
2723
|
+
const storage = JsonlSessionStorage.create(this.fileFor(id), {
|
|
2724
|
+
cwd: options.cwd,
|
|
2725
|
+
sessionId: id,
|
|
2726
|
+
parentSessionId: options.parentSessionId
|
|
2727
|
+
});
|
|
2728
|
+
return new Session(storage);
|
|
2729
|
+
}
|
|
2730
|
+
open(id) {
|
|
2731
|
+
if (!existsSync(this.pathFor(id))) {
|
|
2732
|
+
throw new SessionError("not_found", `Session not found: ${id}`);
|
|
2733
|
+
}
|
|
2734
|
+
return new Session(JsonlSessionStorage.open(this.fileFor(id)));
|
|
2735
|
+
}
|
|
2736
|
+
list() {
|
|
2737
|
+
let names;
|
|
2738
|
+
try {
|
|
2739
|
+
names = readdirSync(this.root);
|
|
2740
|
+
} catch (error) {
|
|
2741
|
+
if (error.code === "ENOENT") return [];
|
|
2742
|
+
throw error;
|
|
2743
|
+
}
|
|
2744
|
+
const sessions = [];
|
|
2745
|
+
for (const name of names) {
|
|
2746
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
2747
|
+
const path = join2(this.root, name);
|
|
2748
|
+
try {
|
|
2749
|
+
const storage = JsonlSessionStorage.open(new FileJsonlFile(path));
|
|
2750
|
+
const metadata = storage.getMetadata();
|
|
2751
|
+
sessions.push({
|
|
2752
|
+
id: metadata.id,
|
|
2753
|
+
path,
|
|
2754
|
+
cwd: metadata.cwd,
|
|
2755
|
+
name: new Session(storage).buildContext().sessionName,
|
|
2756
|
+
parentSessionId: metadata.parentSessionId,
|
|
2757
|
+
createdAt: metadata.timestamp,
|
|
2758
|
+
updatedAt: statSync(path).mtimeMs
|
|
2759
|
+
});
|
|
2760
|
+
} catch (error) {
|
|
2761
|
+
if (error instanceof SessionError) continue;
|
|
2762
|
+
throw error;
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
sessions.sort((a, b) => b.createdAt - a.createdAt);
|
|
2766
|
+
return sessions;
|
|
2767
|
+
}
|
|
2768
|
+
delete(id) {
|
|
2769
|
+
rmSync(this.pathFor(id), { force: true });
|
|
2770
|
+
}
|
|
2771
|
+
fork(id, options = {}) {
|
|
2772
|
+
const source = this.open(id);
|
|
2773
|
+
const forkedEntries = entriesToFork(source.getStorage(), options);
|
|
2774
|
+
const newId = options.id ?? createSessionId();
|
|
2775
|
+
const created = JsonlSessionStorage.create(this.fileFor(newId), {
|
|
2776
|
+
cwd: options.cwd ?? source.getMetadata().cwd,
|
|
2777
|
+
sessionId: newId,
|
|
2778
|
+
parentSessionId: options.parentSessionId ?? id
|
|
2779
|
+
});
|
|
2780
|
+
for (const entry of forkedEntries) created.appendEntry(entry);
|
|
2781
|
+
return new Session(JsonlSessionStorage.open(this.fileFor(newId)));
|
|
2782
|
+
}
|
|
2783
|
+
};
|
|
2784
|
+
|
|
2785
|
+
// src/env.ts
|
|
2786
|
+
import { spawn } from "child_process";
|
|
2787
|
+
import { constants } from "fs";
|
|
2788
|
+
import { access as access2, appendFile, mkdir, readFile as readFile4, readdir as readdir2, rm, writeFile } from "fs/promises";
|
|
2789
|
+
import { isAbsolute, relative, resolve } from "path";
|
|
2790
|
+
|
|
2791
|
+
// src/shell-blocklist.ts
|
|
2792
|
+
var RULES = [
|
|
2793
|
+
// POSIX recursive force-delete of a root-ish *whole* path argument:
|
|
2794
|
+
// the filesystem root (`/`, `/*`), home (`~`, `~/`, `~/*`, `$HOME...`), or the
|
|
2795
|
+
// bare current directory (`.`, `./`, `./*`). A named subdirectory such as
|
|
2796
|
+
// `./build` or `~/Downloads` is intentionally allowed.
|
|
2797
|
+
{
|
|
2798
|
+
pattern: /\brm\s+(-[a-z]*\s+)*-[a-z]*r[a-z]*f[a-z]*\b[^\n]*?(\s|^)(\/\*?|~\/?\*?|\$HOME\/?\*?|\.\/?\*?)(\s|$)/i,
|
|
2799
|
+
reason: "Recursive force-delete of a root, home, or current-directory path"
|
|
2800
|
+
},
|
|
2801
|
+
// POSIX rm with -rf in either flag order targeting the bare filesystem root.
|
|
2802
|
+
{
|
|
2803
|
+
pattern: /\brm\b[^\n]*\s-[a-z]*[rf][a-z]*[rf][a-z]*[^\n]*\s\/\*?(\s|$)/i,
|
|
2804
|
+
reason: "Recursive force-delete of the filesystem root"
|
|
2805
|
+
},
|
|
2806
|
+
// Windows: rmdir/rd /s recursive removal of a drive root or profile.
|
|
2807
|
+
{
|
|
2808
|
+
pattern: /\b(rmdir|rd)\b[^\n]*\/s[^\n]*\s([a-z]:\\?|%userprofile%|%homedrive%)/i,
|
|
2809
|
+
reason: "Recursive removal of a drive root or user profile"
|
|
2810
|
+
},
|
|
2811
|
+
// Windows: del /s /q against a drive root.
|
|
2812
|
+
{
|
|
2813
|
+
pattern: /\bdel\b[^\n]*\/s[^\n]*\s([a-z]:\\?\*?|%userprofile%)/i,
|
|
2814
|
+
reason: "Recursive delete of a drive root or user profile"
|
|
2815
|
+
},
|
|
2816
|
+
// PowerShell: Remove-Item -Recurse -Force on a root/profile path.
|
|
2817
|
+
{
|
|
2818
|
+
pattern: /\bremove-item\b[^\n]*(-recurse|-force)[^\n]*\s([a-z]:\\?|\$env:userprofile|~)/i,
|
|
2819
|
+
reason: "Recursive Remove-Item on a drive root or user profile"
|
|
2820
|
+
},
|
|
2821
|
+
// Fork bomb (POSIX function-style and bare).
|
|
2822
|
+
{ pattern: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, reason: "Shell fork bomb" },
|
|
2823
|
+
// Overwrite a raw block device / partition table.
|
|
2824
|
+
{
|
|
2825
|
+
pattern: /\bdd\b[^\n]*\bof=\/dev\/(sd|nvme|hd|disk|vd)[a-z0-9]*/i,
|
|
2826
|
+
reason: "Raw write to a block device with dd"
|
|
2827
|
+
},
|
|
2828
|
+
// mkfs against a real device.
|
|
2829
|
+
{
|
|
2830
|
+
pattern: /\bmkfs(\.[a-z0-9]+)?\b[^\n]*\/dev\/(sd|nvme|hd|disk|vd)[a-z0-9]*/i,
|
|
2831
|
+
reason: "Formatting a block device with mkfs"
|
|
2832
|
+
},
|
|
2833
|
+
// Windows format of a drive (non-interactive).
|
|
2834
|
+
{ pattern: /\bformat\b[^\n]*\s[a-z]:[^\n]*\/(y|q|fs:)/i, reason: "Formatting a Windows drive" },
|
|
2835
|
+
// Redirect to a raw disk device.
|
|
2836
|
+
{
|
|
2837
|
+
pattern: />\s*\/dev\/(sd|nvme|hd|disk|vd)[a-z0-9]*/i,
|
|
2838
|
+
reason: "Redirecting output onto a block device"
|
|
2839
|
+
},
|
|
2840
|
+
// chmod/chown -R on a root path.
|
|
2841
|
+
{
|
|
2842
|
+
pattern: /\b(chmod|chown)\b[^\n]*\s-[a-z]*r[a-z]*\b[^\n]*\s\/(\s|$)/i,
|
|
2843
|
+
reason: "Recursive permission/ownership change on the filesystem root"
|
|
2844
|
+
},
|
|
2845
|
+
// Piping a remote script straight into a shell.
|
|
2846
|
+
{
|
|
2847
|
+
pattern: /\b(curl|wget)\b[^\n|]*\|[^\n]*\b(sh|bash|zsh|powershell|pwsh|iex)\b/i,
|
|
2848
|
+
reason: "Piping a remote download directly into a shell"
|
|
2849
|
+
},
|
|
2850
|
+
// PowerShell download-and-execute via Invoke-Expression.
|
|
2851
|
+
{
|
|
2852
|
+
pattern: /\b(iex|invoke-expression)\b[^\n]*\b(invoke-webrequest|iwr|downloadstring|net\.webclient)\b/i,
|
|
2853
|
+
reason: "Downloading and executing a remote script in PowerShell"
|
|
2854
|
+
},
|
|
2855
|
+
// Disk wipe via cipher /w (Windows) over a drive.
|
|
2856
|
+
{ pattern: /\bcipher\b[^\n]*\/w:[a-z]:/i, reason: "Secure-wiping free space on a drive" }
|
|
2857
|
+
];
|
|
2858
|
+
function matchBlocklist(command) {
|
|
2859
|
+
const normalized = command.trim();
|
|
2860
|
+
for (const rule of RULES) {
|
|
2861
|
+
if (rule.pattern.test(normalized)) {
|
|
2862
|
+
return { reason: rule.reason, pattern: rule.pattern.source };
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
return void 0;
|
|
2866
|
+
}
|
|
2867
|
+
function isBlockedCommand(command) {
|
|
2868
|
+
return matchBlocklist(command) !== void 0;
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
// src/env.ts
|
|
2872
|
+
function isNodeError(value) {
|
|
2873
|
+
return value instanceof Error && "code" in value;
|
|
2874
|
+
}
|
|
2875
|
+
function toFileError(error, path) {
|
|
2876
|
+
if (error instanceof FileError) return error;
|
|
2877
|
+
const cause = toError(error);
|
|
2878
|
+
if (isNodeError(error)) {
|
|
2879
|
+
switch (error.code) {
|
|
2880
|
+
case "ABORT_ERR":
|
|
2881
|
+
return new FileError("aborted", cause.message, path, cause);
|
|
2882
|
+
case "ENOENT":
|
|
2883
|
+
return new FileError("not_found", cause.message, path, cause);
|
|
2884
|
+
case "EACCES":
|
|
2885
|
+
case "EPERM":
|
|
2886
|
+
return new FileError("permission_denied", cause.message, path, cause);
|
|
2887
|
+
case "ENOTDIR":
|
|
2888
|
+
return new FileError("not_directory", cause.message, path, cause);
|
|
2889
|
+
case "EISDIR":
|
|
2890
|
+
return new FileError("is_directory", cause.message, path, cause);
|
|
2891
|
+
case "EINVAL":
|
|
2892
|
+
return new FileError("invalid", cause.message, path, cause);
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
return new FileError("unknown", cause.message, path, cause);
|
|
2896
|
+
}
|
|
2897
|
+
function killTree(pid) {
|
|
2898
|
+
if (process.platform === "win32") {
|
|
2899
|
+
try {
|
|
2900
|
+
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
|
2901
|
+
stdio: "ignore",
|
|
2902
|
+
windowsHide: true
|
|
2903
|
+
});
|
|
2904
|
+
} catch {
|
|
2905
|
+
}
|
|
2906
|
+
return;
|
|
2907
|
+
}
|
|
2908
|
+
try {
|
|
2909
|
+
process.kill(-pid, "SIGKILL");
|
|
2910
|
+
} catch {
|
|
2911
|
+
try {
|
|
2912
|
+
process.kill(pid, "SIGKILL");
|
|
2913
|
+
} catch {
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
async function pathExists(path) {
|
|
2918
|
+
try {
|
|
2919
|
+
await access2(path, constants.F_OK);
|
|
2920
|
+
return true;
|
|
2921
|
+
} catch {
|
|
2922
|
+
return false;
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
async function resolveShell(customShellPath) {
|
|
2926
|
+
if (customShellPath) {
|
|
2927
|
+
if (await pathExists(customShellPath)) return ok({ shell: customShellPath, args: ["-c"] });
|
|
2928
|
+
return err(
|
|
2929
|
+
new ExecutionError("shell_unavailable", `Custom shell not found: ${customShellPath}`)
|
|
2930
|
+
);
|
|
2931
|
+
}
|
|
2932
|
+
if (process.platform === "win32") {
|
|
2933
|
+
const candidates = [];
|
|
2934
|
+
const programFiles = process.env.ProgramFiles;
|
|
2935
|
+
if (programFiles) candidates.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
|
2936
|
+
const programFilesX86 = process.env["ProgramFiles(x86)"];
|
|
2937
|
+
if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
|
2938
|
+
for (const candidate of candidates) {
|
|
2939
|
+
if (await pathExists(candidate)) return ok({ shell: candidate, args: ["-c"] });
|
|
2940
|
+
}
|
|
2941
|
+
const comspec = process.env.ComSpec ?? "cmd.exe";
|
|
2942
|
+
return ok({ shell: comspec, args: ["/d", "/s", "/c"] });
|
|
2943
|
+
}
|
|
2944
|
+
if (await pathExists("/bin/bash")) return ok({ shell: "/bin/bash", args: ["-c"] });
|
|
2945
|
+
return ok({ shell: "sh", args: ["-c"] });
|
|
2946
|
+
}
|
|
2947
|
+
async function detectShell(customShellPath) {
|
|
2948
|
+
const resolved = await resolveShell(customShellPath);
|
|
2949
|
+
const shell = resolved.ok ? resolved.value.shell : process.platform === "win32" ? process.env.ComSpec ?? "cmd.exe" : "sh";
|
|
2950
|
+
let kind = "sh";
|
|
2951
|
+
if (/bash(\.exe)?$/i.test(shell)) kind = "bash";
|
|
2952
|
+
else if (/cmd\.exe$/i.test(shell) || shell.toLowerCase() === "cmd") kind = "cmd";
|
|
2953
|
+
return { shell, kind };
|
|
2954
|
+
}
|
|
2955
|
+
var NodeExecutionEnv = class {
|
|
2956
|
+
cwd;
|
|
2957
|
+
shellPath;
|
|
2958
|
+
shellEnv;
|
|
2959
|
+
/** When true (default), the shell blocklist rejects dangerous commands. */
|
|
2960
|
+
enforceBlocklist;
|
|
2961
|
+
/**
|
|
2962
|
+
* When true (default false), {@link writeTextFile} and {@link appendTextFile}
|
|
2963
|
+
* reject any path that resolves outside {@link workspaceRoot}. Off by default
|
|
2964
|
+
* so the write-anywhere behavior is unchanged unless explicitly opted in.
|
|
2965
|
+
*/
|
|
2966
|
+
restrictWritesToWorkspace;
|
|
2967
|
+
/** The boundary used when {@link restrictWritesToWorkspace} is on. */
|
|
2968
|
+
workspaceRoot;
|
|
2969
|
+
constructor(options) {
|
|
2970
|
+
this.cwd = options.cwd;
|
|
2971
|
+
this.shellPath = options.shellPath;
|
|
2972
|
+
this.shellEnv = options.shellEnv;
|
|
2973
|
+
this.enforceBlocklist = options.enforceBlocklist ?? true;
|
|
2974
|
+
this.restrictWritesToWorkspace = options.restrictWritesToWorkspace ?? false;
|
|
2975
|
+
this.workspaceRoot = options.workspaceRoot ?? options.cwd;
|
|
2976
|
+
}
|
|
2977
|
+
resolvePath(path) {
|
|
2978
|
+
return isAbsolute(path) ? path : resolve(this.cwd, path);
|
|
2979
|
+
}
|
|
2980
|
+
/**
|
|
2981
|
+
* Guard a mutating write when {@link restrictWritesToWorkspace} is on: returns
|
|
2982
|
+
* a {@link FileError} when `resolved` falls outside {@link workspaceRoot}, else
|
|
2983
|
+
* `undefined`. Inside/outside is computed with `path.relative` (NOT naive
|
|
2984
|
+
* string prefixing, which mis-classifies `/work2` against `/work`): a path is
|
|
2985
|
+
* inside iff the relative path is empty, is not absolute, and does not begin
|
|
2986
|
+
* with a `..` segment. `path.relative` already normalizes separators and
|
|
2987
|
+
* Windows drive-letter casing, so a cross-drive or traversal escape resolves
|
|
2988
|
+
* to an absolute (or `..`-leading) relative path and is rejected.
|
|
2989
|
+
*/
|
|
2990
|
+
workspaceWriteError(resolved) {
|
|
2991
|
+
if (!this.restrictWritesToWorkspace) return void 0;
|
|
2992
|
+
const rel = relative(this.workspaceRoot, resolved);
|
|
2993
|
+
const escapes = rel === ".." || /^\.\.[/\\]/.test(rel);
|
|
2994
|
+
const inside = rel === "" || !isAbsolute(rel) && !escapes;
|
|
2995
|
+
if (inside) return void 0;
|
|
2996
|
+
return new FileError(
|
|
2997
|
+
"permission_denied",
|
|
2998
|
+
`Refusing to write outside the workspace root (${this.workspaceRoot}): ${resolved}`,
|
|
2999
|
+
resolved
|
|
3000
|
+
);
|
|
3001
|
+
}
|
|
3002
|
+
async readTextFile(path, signal) {
|
|
3003
|
+
const resolved = this.resolvePath(path);
|
|
3004
|
+
if (signal?.aborted) return err(new FileError("aborted", "aborted", resolved));
|
|
3005
|
+
try {
|
|
3006
|
+
return ok(await readFile4(resolved, { encoding: "utf8", signal }));
|
|
3007
|
+
} catch (error) {
|
|
3008
|
+
return err(toFileError(error, resolved));
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
async readBinaryFile(path, signal) {
|
|
3012
|
+
const resolved = this.resolvePath(path);
|
|
3013
|
+
if (signal?.aborted) return err(new FileError("aborted", "aborted", resolved));
|
|
3014
|
+
try {
|
|
3015
|
+
const buf = await readFile4(resolved, { signal });
|
|
3016
|
+
return ok(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
|
|
3017
|
+
} catch (error) {
|
|
3018
|
+
return err(toFileError(error, resolved));
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
async writeTextFile(path, content, signal) {
|
|
3022
|
+
const resolved = this.resolvePath(path);
|
|
3023
|
+
if (signal?.aborted) return err(new FileError("aborted", "aborted", resolved));
|
|
3024
|
+
const boundary = this.workspaceWriteError(resolved);
|
|
3025
|
+
if (boundary) return err(boundary);
|
|
3026
|
+
try {
|
|
3027
|
+
await mkdir(resolve(resolved, ".."), { recursive: true });
|
|
3028
|
+
await writeFile(resolved, content, { signal });
|
|
3029
|
+
return ok(void 0);
|
|
3030
|
+
} catch (error) {
|
|
3031
|
+
return err(toFileError(error, resolved));
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
async appendTextFile(path, content) {
|
|
3035
|
+
const resolved = this.resolvePath(path);
|
|
3036
|
+
const boundary = this.workspaceWriteError(resolved);
|
|
3037
|
+
if (boundary) return err(boundary);
|
|
3038
|
+
try {
|
|
3039
|
+
await mkdir(resolve(resolved, ".."), { recursive: true });
|
|
3040
|
+
await appendFile(resolved, content);
|
|
3041
|
+
return ok(void 0);
|
|
3042
|
+
} catch (error) {
|
|
3043
|
+
return err(toFileError(error, resolved));
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
async exists(path) {
|
|
3047
|
+
return pathExists(this.resolvePath(path));
|
|
3048
|
+
}
|
|
3049
|
+
async listDir(path) {
|
|
3050
|
+
const resolved = this.resolvePath(path);
|
|
3051
|
+
try {
|
|
3052
|
+
return ok(await readdir2(resolved));
|
|
3053
|
+
} catch (error) {
|
|
3054
|
+
return err(toFileError(error, resolved));
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
async createDir(path, options) {
|
|
3058
|
+
const resolved = this.resolvePath(path);
|
|
3059
|
+
const boundary = this.workspaceWriteError(resolved);
|
|
3060
|
+
if (boundary) return err(boundary);
|
|
3061
|
+
try {
|
|
3062
|
+
await mkdir(resolved, { recursive: options?.recursive ?? true });
|
|
3063
|
+
return ok(void 0);
|
|
3064
|
+
} catch (error) {
|
|
3065
|
+
return err(toFileError(error, resolved));
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
async remove(path, options) {
|
|
3069
|
+
const resolved = this.resolvePath(path);
|
|
3070
|
+
const boundary = this.workspaceWriteError(resolved);
|
|
3071
|
+
if (boundary) return err(boundary);
|
|
3072
|
+
try {
|
|
3073
|
+
await rm(resolved, {
|
|
3074
|
+
recursive: options?.recursive ?? false,
|
|
3075
|
+
force: options?.force ?? false
|
|
3076
|
+
});
|
|
3077
|
+
return ok(void 0);
|
|
3078
|
+
} catch (error) {
|
|
3079
|
+
return err(toFileError(error, resolved));
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
async exec(command, options) {
|
|
3083
|
+
if (this.enforceBlocklist) {
|
|
3084
|
+
const blocked = matchBlocklist(command);
|
|
3085
|
+
if (blocked) {
|
|
3086
|
+
return err(new ExecutionError("blocked", `Command blocked: ${blocked.reason}`));
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
if (options?.signal?.aborted) return err(new ExecutionError("aborted", "aborted"));
|
|
3090
|
+
const shellConfig = await resolveShell(this.shellPath);
|
|
3091
|
+
if (!shellConfig.ok) return shellConfig;
|
|
3092
|
+
const cwd = options?.cwd ? this.resolvePath(options.cwd) : this.cwd;
|
|
3093
|
+
return new Promise((resolvePromise) => {
|
|
3094
|
+
let stdout = "";
|
|
3095
|
+
let stderr = "";
|
|
3096
|
+
let settled = false;
|
|
3097
|
+
let timedOut = false;
|
|
3098
|
+
let callbackError;
|
|
3099
|
+
let child;
|
|
3100
|
+
let timer;
|
|
3101
|
+
const onAbort = () => {
|
|
3102
|
+
if (child?.pid) killTree(child.pid);
|
|
3103
|
+
};
|
|
3104
|
+
const settle = (result) => {
|
|
3105
|
+
if (timer) clearTimeout(timer);
|
|
3106
|
+
options?.signal?.removeEventListener("abort", onAbort);
|
|
3107
|
+
if (settled) return;
|
|
3108
|
+
settled = true;
|
|
3109
|
+
resolvePromise(result);
|
|
3110
|
+
};
|
|
3111
|
+
try {
|
|
3112
|
+
child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], {
|
|
3113
|
+
cwd,
|
|
3114
|
+
detached: process.platform !== "win32",
|
|
3115
|
+
env: { ...process.env, ...this.shellEnv, ...options?.env },
|
|
3116
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3117
|
+
windowsHide: true
|
|
3118
|
+
});
|
|
3119
|
+
} catch (error) {
|
|
3120
|
+
const cause = toError(error);
|
|
3121
|
+
settle(err(new ExecutionError("spawn_error", cause.message, cause)));
|
|
3122
|
+
return;
|
|
3123
|
+
}
|
|
3124
|
+
if (typeof options?.timeoutMs === "number") {
|
|
3125
|
+
timer = setTimeout(() => {
|
|
3126
|
+
timedOut = true;
|
|
3127
|
+
if (child?.pid) killTree(child.pid);
|
|
3128
|
+
}, options.timeoutMs);
|
|
3129
|
+
}
|
|
3130
|
+
if (options?.signal) {
|
|
3131
|
+
if (options.signal.aborted) onAbort();
|
|
3132
|
+
else options.signal.addEventListener("abort", onAbort, { once: true });
|
|
3133
|
+
}
|
|
3134
|
+
child.stdout?.setEncoding("utf8");
|
|
3135
|
+
child.stderr?.setEncoding("utf8");
|
|
3136
|
+
child.stdout?.on("data", (chunk) => {
|
|
3137
|
+
stdout += chunk;
|
|
3138
|
+
try {
|
|
3139
|
+
options?.onStdout?.(chunk);
|
|
3140
|
+
} catch (error) {
|
|
3141
|
+
callbackError = new ExecutionError("callback_error", toError(error).message, error);
|
|
3142
|
+
onAbort();
|
|
3143
|
+
}
|
|
3144
|
+
});
|
|
3145
|
+
child.stderr?.on("data", (chunk) => {
|
|
3146
|
+
stderr += chunk;
|
|
3147
|
+
try {
|
|
3148
|
+
options?.onStderr?.(chunk);
|
|
3149
|
+
} catch (error) {
|
|
3150
|
+
callbackError = new ExecutionError("callback_error", toError(error).message, error);
|
|
3151
|
+
onAbort();
|
|
3152
|
+
}
|
|
3153
|
+
});
|
|
3154
|
+
child.on("error", (error) => {
|
|
3155
|
+
settle(err(new ExecutionError("spawn_error", error.message, error)));
|
|
3156
|
+
});
|
|
3157
|
+
child.on("close", (code) => {
|
|
3158
|
+
if (callbackError) return settle(err(callbackError));
|
|
3159
|
+
if (timedOut) {
|
|
3160
|
+
const message = typeof options?.timeoutMs === "number" ? `Command timed out after ${options.timeoutMs}ms` : "Command timed out";
|
|
3161
|
+
return settle(err(new ExecutionError("timeout", message)));
|
|
3162
|
+
}
|
|
3163
|
+
if (options?.signal?.aborted) return settle(err(new ExecutionError("aborted", "aborted")));
|
|
3164
|
+
settle(ok({ stdout, stderr, exitCode: code ?? 0 }));
|
|
3165
|
+
});
|
|
3166
|
+
});
|
|
3167
|
+
}
|
|
3168
|
+
};
|
|
3169
|
+
var DEFAULT_MAX_OUTPUT_BYTES = 50 * 1024;
|
|
3170
|
+
async function executeShellWithCapture(env, command, options) {
|
|
3171
|
+
const maxBytes = options?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
3172
|
+
const controller = new AbortController();
|
|
3173
|
+
const onParentAbort = () => controller.abort();
|
|
3174
|
+
if (options?.signal) {
|
|
3175
|
+
if (options.signal.aborted) controller.abort();
|
|
3176
|
+
else options.signal.addEventListener("abort", onParentAbort, { once: true });
|
|
3177
|
+
}
|
|
3178
|
+
const timer = typeof options?.timeoutMs === "number" ? setTimeout(() => controller.abort(), options.timeoutMs) : void 0;
|
|
3179
|
+
try {
|
|
3180
|
+
const result = await env.exec(command, {
|
|
3181
|
+
cwd: options?.cwd,
|
|
3182
|
+
env: options?.env,
|
|
3183
|
+
timeoutMs: options?.timeoutMs,
|
|
3184
|
+
signal: controller.signal
|
|
3185
|
+
});
|
|
3186
|
+
if (!result.ok) return result;
|
|
3187
|
+
const truncate = (text) => {
|
|
3188
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return { value: text, truncated: false };
|
|
3189
|
+
const buffer = Buffer.from(text, "utf8");
|
|
3190
|
+
const sliced = buffer.subarray(buffer.length - maxBytes).toString("utf8");
|
|
3191
|
+
return { value: `[output truncated]
|
|
3192
|
+
${sliced}`, truncated: true };
|
|
3193
|
+
};
|
|
3194
|
+
const stdout = truncate(result.value.stdout);
|
|
3195
|
+
const stderr = truncate(result.value.stderr);
|
|
3196
|
+
return ok({
|
|
3197
|
+
stdout: stdout.value,
|
|
3198
|
+
stderr: stderr.value,
|
|
3199
|
+
exitCode: result.value.exitCode,
|
|
3200
|
+
truncated: stdout.truncated || stderr.truncated
|
|
3201
|
+
});
|
|
3202
|
+
} finally {
|
|
3203
|
+
if (timer) clearTimeout(timer);
|
|
3204
|
+
options?.signal?.removeEventListener("abort", onParentAbort);
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
// src/conversation.ts
|
|
3209
|
+
import {
|
|
3210
|
+
appendFileSync as appendFileSync2,
|
|
3211
|
+
existsSync as existsSync2,
|
|
3212
|
+
mkdirSync as mkdirSync2,
|
|
3213
|
+
readFileSync as readFileSync2,
|
|
3214
|
+
rmSync as rmSync2,
|
|
3215
|
+
writeFileSync
|
|
3216
|
+
} from "fs";
|
|
3217
|
+
import { dirname as dirname3, join as join4 } from "path";
|
|
3218
|
+
import { parse as yamlParse2, stringify as yamlStringify } from "yaml";
|
|
3219
|
+
function estimateTokens(text) {
|
|
3220
|
+
if (!text) return 0;
|
|
3221
|
+
return Math.ceil(text.length / 4);
|
|
3222
|
+
}
|
|
3223
|
+
function generateConversationId() {
|
|
3224
|
+
return uuidv7();
|
|
3225
|
+
}
|
|
3226
|
+
function getConversationDir() {
|
|
3227
|
+
const dir = join4(process.cwd(), "conversations");
|
|
3228
|
+
try {
|
|
3229
|
+
mkdirSync2(dir, { recursive: true });
|
|
3230
|
+
} catch {
|
|
3231
|
+
}
|
|
3232
|
+
return dir;
|
|
3233
|
+
}
|
|
3234
|
+
function contentToString(content) {
|
|
3235
|
+
if (typeof content === "string") return content;
|
|
3236
|
+
if (content === null || content === void 0) return "";
|
|
3237
|
+
return JSON.stringify(content);
|
|
3238
|
+
}
|
|
3239
|
+
var Conversation = class _Conversation {
|
|
3240
|
+
id;
|
|
3241
|
+
name;
|
|
3242
|
+
systemPrompt;
|
|
3243
|
+
timeEnabled;
|
|
3244
|
+
autosave;
|
|
3245
|
+
saveFilepath;
|
|
3246
|
+
loadFilepath;
|
|
3247
|
+
contextLength;
|
|
3248
|
+
rules;
|
|
3249
|
+
customRulesPrompt;
|
|
3250
|
+
user;
|
|
3251
|
+
saveAsYamlOn;
|
|
3252
|
+
saveAsJsonBool;
|
|
3253
|
+
tokenCount;
|
|
3254
|
+
messageIdOn;
|
|
3255
|
+
tokenizerModelName;
|
|
3256
|
+
conversationsDir;
|
|
3257
|
+
exportMethod;
|
|
3258
|
+
dynamicContextWindow;
|
|
3259
|
+
caching;
|
|
3260
|
+
outputMetadata;
|
|
3261
|
+
memoryMdPath;
|
|
3262
|
+
conversationHistory = [];
|
|
3263
|
+
countTokens;
|
|
3264
|
+
strCache = null;
|
|
3265
|
+
cacheHits = 0;
|
|
3266
|
+
cacheMisses = 0;
|
|
3267
|
+
lastCachedTokens = 0;
|
|
3268
|
+
suppressMemoryMd = true;
|
|
3269
|
+
constructor(options = {}) {
|
|
3270
|
+
this.id = options.id ?? generateConversationId();
|
|
3271
|
+
this.name = options.name ?? this.id;
|
|
3272
|
+
this.systemPrompt = options.systemPrompt;
|
|
3273
|
+
this.timeEnabled = options.timeEnabled ?? false;
|
|
3274
|
+
this.autosave = options.autosave ?? false;
|
|
3275
|
+
this.saveFilepath = options.saveFilepath;
|
|
3276
|
+
this.loadFilepath = options.loadFilepath;
|
|
3277
|
+
this.contextLength = options.contextLength ?? 8192;
|
|
3278
|
+
this.rules = options.rules;
|
|
3279
|
+
this.customRulesPrompt = options.customRulesPrompt;
|
|
3280
|
+
this.user = options.user ?? "User";
|
|
3281
|
+
this.saveAsYamlOn = options.saveAsYamlOn ?? false;
|
|
3282
|
+
this.saveAsJsonBool = options.saveAsJsonBool ?? false;
|
|
3283
|
+
this.tokenCount = options.tokenCount ?? false;
|
|
3284
|
+
this.messageIdOn = options.messageIdOn ?? false;
|
|
3285
|
+
this.tokenizerModelName = options.tokenizerModelName ?? "gpt-4";
|
|
3286
|
+
this.conversationsDir = options.conversationsDir;
|
|
3287
|
+
this.exportMethod = options.exportMethod ?? "json";
|
|
3288
|
+
this.dynamicContextWindow = options.dynamicContextWindow ?? true;
|
|
3289
|
+
this.caching = options.cacheEnabled ?? false;
|
|
3290
|
+
this.outputMetadata = options.outputMetadata ?? false;
|
|
3291
|
+
this.memoryMdPath = options.memoryMdPath;
|
|
3292
|
+
this.countTokens = options.countTokens ?? estimateTokens;
|
|
3293
|
+
if (this.exportMethod !== "json" && this.exportMethod !== "yaml") {
|
|
3294
|
+
throw new Error(`Invalid exportMethod: ${this.exportMethod}. Must be 'json' or 'yaml'`);
|
|
3295
|
+
}
|
|
3296
|
+
if (!this.saveFilepath) {
|
|
3297
|
+
const ext = this.exportMethod === "json" ? ".json" : ".yaml";
|
|
3298
|
+
this.saveFilepath = `conversation_${this.name}${ext}`;
|
|
3299
|
+
}
|
|
3300
|
+
this.initializeNewConversation();
|
|
3301
|
+
this.suppressMemoryMd = false;
|
|
3302
|
+
if (this.memoryMdPath) {
|
|
3303
|
+
this.initMemoryMd();
|
|
3304
|
+
this.preloadMemoryMd();
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
initializeNewConversation() {
|
|
3308
|
+
if (this.systemPrompt !== void 0) this.add("System", this.systemPrompt);
|
|
3309
|
+
if (this.rules !== void 0) this.add(this.user || "User", this.rules);
|
|
3310
|
+
if (this.customRulesPrompt !== void 0) this.add(this.user || "User", this.customRulesPrompt);
|
|
3311
|
+
}
|
|
3312
|
+
// ── Core mutations ──────────────────────────────────────────────────────
|
|
3313
|
+
/** Append a message to the in-memory transcript (no MEMORY.md / autosave). */
|
|
3314
|
+
addInMemory(role, content, category) {
|
|
3315
|
+
const message = { role, content };
|
|
3316
|
+
if (this.timeEnabled) message.timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
3317
|
+
if (this.messageIdOn) message.message_id = uuidv7();
|
|
3318
|
+
if (category) message.category = category;
|
|
3319
|
+
this.conversationHistory.push(message);
|
|
3320
|
+
this.strCache = null;
|
|
3321
|
+
if (this.memoryMdPath) this.appendToMemoryMd(role, content);
|
|
3322
|
+
if (this.tokenCount) {
|
|
3323
|
+
message.token_count = this.countTokens(contentToString(content), this.tokenizerModelName);
|
|
3324
|
+
}
|
|
3325
|
+
return message;
|
|
3326
|
+
}
|
|
3327
|
+
/** Append a message; honors autosave. The primary write entrypoint. */
|
|
3328
|
+
add(role, content, _metadata, category) {
|
|
3329
|
+
const result = this.addInMemory(role, content, category);
|
|
3330
|
+
if (this.autosave) this.export();
|
|
3331
|
+
return result;
|
|
3332
|
+
}
|
|
3333
|
+
/** Append multiple messages in order (roles and contents must be equal length). */
|
|
3334
|
+
addMultiple(roles, contents) {
|
|
3335
|
+
if (roles.length !== contents.length) {
|
|
3336
|
+
throw new Error("Number of roles and contents must match.");
|
|
3337
|
+
}
|
|
3338
|
+
return roles.map((role, i) => this.add(role, contents[i]));
|
|
3339
|
+
}
|
|
3340
|
+
/** Alias of {@link addMultiple} that also honors autosave once. */
|
|
3341
|
+
addMultipleMessages(roles, contents) {
|
|
3342
|
+
const added = this.addMultiple(roles, contents);
|
|
3343
|
+
if (this.autosave) this.export();
|
|
3344
|
+
return added;
|
|
3345
|
+
}
|
|
3346
|
+
/** Append pre-built message objects directly. */
|
|
3347
|
+
batchAdd(messages) {
|
|
3348
|
+
this.conversationHistory.push(...messages);
|
|
3349
|
+
this.strCache = null;
|
|
3350
|
+
}
|
|
3351
|
+
/** Append a tool's output as a message under `role`. */
|
|
3352
|
+
addToolOutputToAgent(role, toolOutput) {
|
|
3353
|
+
return this.add(role, toolOutput);
|
|
3354
|
+
}
|
|
3355
|
+
/** Delete the message at `index`. */
|
|
3356
|
+
delete(index) {
|
|
3357
|
+
this.conversationHistory.splice(index, 1);
|
|
3358
|
+
this.strCache = null;
|
|
3359
|
+
}
|
|
3360
|
+
/** Overwrite the role+content of the message at `index`. */
|
|
3361
|
+
update(index, role, content) {
|
|
3362
|
+
const i = Number(index);
|
|
3363
|
+
if (i >= 0 && i < this.conversationHistory.length) {
|
|
3364
|
+
const message = this.conversationHistory[i];
|
|
3365
|
+
message.role = role;
|
|
3366
|
+
message.content = content;
|
|
3367
|
+
this.strCache = null;
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
/** Return the message at `index`, or undefined. */
|
|
3371
|
+
query(index) {
|
|
3372
|
+
const i = Number(index);
|
|
3373
|
+
if (i >= 0 && i < this.conversationHistory.length) return this.conversationHistory[i];
|
|
3374
|
+
return void 0;
|
|
3375
|
+
}
|
|
3376
|
+
/** Remove every message. */
|
|
3377
|
+
clear() {
|
|
3378
|
+
this.conversationHistory = [];
|
|
3379
|
+
this.strCache = null;
|
|
3380
|
+
}
|
|
3381
|
+
/** Alias of {@link clear}. */
|
|
3382
|
+
clearMemory() {
|
|
3383
|
+
this.clear();
|
|
3384
|
+
}
|
|
3385
|
+
// ── Search ──────────────────────────────────────────────────────────────
|
|
3386
|
+
/** Return messages whose stringified content contains `keyword`. */
|
|
3387
|
+
search(keyword) {
|
|
3388
|
+
return this.conversationHistory.filter((m) => contentToString(m.content).includes(keyword));
|
|
3389
|
+
}
|
|
3390
|
+
/** Alias of {@link search}. */
|
|
3391
|
+
searchKeywordInConversation(keyword) {
|
|
3392
|
+
return this.search(keyword);
|
|
3393
|
+
}
|
|
3394
|
+
/** Filter messages visible to `agentName` at/before `turn`. */
|
|
3395
|
+
getVisibleMessages(agentName, turn) {
|
|
3396
|
+
const prev = this.conversationHistory.filter(
|
|
3397
|
+
(m) => typeof m.turn === "number" && m.turn < turn
|
|
3398
|
+
);
|
|
3399
|
+
return prev.filter((m) => {
|
|
3400
|
+
const visibleTo = m.visible_to;
|
|
3401
|
+
return visibleTo === "all" || Array.isArray(visibleTo) && visibleTo.includes(agentName);
|
|
3402
|
+
});
|
|
3403
|
+
}
|
|
3404
|
+
/** Count messages per role. */
|
|
3405
|
+
countMessagesByRole() {
|
|
3406
|
+
const counts = { system: 0, user: 0, assistant: 0, function: 0 };
|
|
3407
|
+
for (const message of this.conversationHistory) {
|
|
3408
|
+
counts[message.role] = (counts[message.role] ?? 0) + 1;
|
|
3409
|
+
}
|
|
3410
|
+
return counts;
|
|
3411
|
+
}
|
|
3412
|
+
// ── Read / format ─────────────────────────────────────────────────────────
|
|
3413
|
+
/** Return the transcript as a single `role: content` string (with caching). */
|
|
3414
|
+
returnHistoryAsString() {
|
|
3415
|
+
if (!this.caching) return this.buildHistoryString();
|
|
3416
|
+
if (this.strCache === null) {
|
|
3417
|
+
this.cacheMisses += 1;
|
|
3418
|
+
this.strCache = this.buildHistoryString();
|
|
3419
|
+
this.lastCachedTokens = this.countTokens(this.strCache, this.tokenizerModelName);
|
|
3420
|
+
} else {
|
|
3421
|
+
this.cacheHits += 1;
|
|
3422
|
+
}
|
|
3423
|
+
return this.strCache;
|
|
3424
|
+
}
|
|
3425
|
+
/** Alias of {@link returnHistoryAsString}. */
|
|
3426
|
+
getStr() {
|
|
3427
|
+
return this.returnHistoryAsString();
|
|
3428
|
+
}
|
|
3429
|
+
buildHistoryString() {
|
|
3430
|
+
if (this.dynamicContextWindow) return this.dynamicAutoChunking();
|
|
3431
|
+
return this.historyStringWorker();
|
|
3432
|
+
}
|
|
3433
|
+
historyStringWorker() {
|
|
3434
|
+
return this.conversationHistory.map((m) => {
|
|
3435
|
+
const content = contentToString(m.content);
|
|
3436
|
+
return m.timestamp ? `[${m.timestamp}] ${m.role}: ${content}` : `${m.role}: ${content}`;
|
|
3437
|
+
}).join("\n\n");
|
|
3438
|
+
}
|
|
3439
|
+
/** Cache stats for {@link returnHistoryAsString}. */
|
|
3440
|
+
getCacheStats() {
|
|
3441
|
+
const total = this.cacheHits + this.cacheMisses;
|
|
3442
|
+
return {
|
|
3443
|
+
hits: this.cacheHits,
|
|
3444
|
+
misses: this.cacheMisses,
|
|
3445
|
+
cachedTokens: this.lastCachedTokens,
|
|
3446
|
+
hitRate: total > 0 ? this.cacheHits / total : 0
|
|
3447
|
+
};
|
|
3448
|
+
}
|
|
3449
|
+
/** The raw transcript array — the canonical serialized form. */
|
|
3450
|
+
toDict() {
|
|
3451
|
+
return this.conversationHistory;
|
|
3452
|
+
}
|
|
3453
|
+
/** The transcript as a list (alias of {@link toDict}). */
|
|
3454
|
+
toList() {
|
|
3455
|
+
return this.conversationHistory;
|
|
3456
|
+
}
|
|
3457
|
+
/** The transcript serialized to a JSON string. */
|
|
3458
|
+
toJson() {
|
|
3459
|
+
return JSON.stringify(this.conversationHistory);
|
|
3460
|
+
}
|
|
3461
|
+
/** The transcript serialized to a YAML string. */
|
|
3462
|
+
toYaml() {
|
|
3463
|
+
return yamlStringify(this.conversationHistory);
|
|
3464
|
+
}
|
|
3465
|
+
/** Each message as a `role: content` string. */
|
|
3466
|
+
returnMessagesAsList() {
|
|
3467
|
+
return this.conversationHistory.map((m) => `${m.role}: ${contentToString(m.content)}`);
|
|
3468
|
+
}
|
|
3469
|
+
/** Canonical provider format: `[{role, content}]`. */
|
|
3470
|
+
returnMessagesAsDictionary() {
|
|
3471
|
+
return this.conversationHistory.map((m) => ({ role: m.role, content: m.content }));
|
|
3472
|
+
}
|
|
3473
|
+
/** Provider format serialized to a pretty JSON string. */
|
|
3474
|
+
returnJson() {
|
|
3475
|
+
return JSON.stringify(this.returnMessagesAsDictionary(), null, 4);
|
|
3476
|
+
}
|
|
3477
|
+
/** The last message as `role: content`, or "". */
|
|
3478
|
+
getLastMessageAsString() {
|
|
3479
|
+
const last = this.conversationHistory.at(-1);
|
|
3480
|
+
return last ? `${last.role}: ${contentToString(last.content)}` : "";
|
|
3481
|
+
}
|
|
3482
|
+
/** The final message as `role: content`, or "". */
|
|
3483
|
+
getFinalMessage() {
|
|
3484
|
+
return this.getLastMessageAsString();
|
|
3485
|
+
}
|
|
3486
|
+
/** The content of the final message, or "". */
|
|
3487
|
+
getFinalMessageContent() {
|
|
3488
|
+
const last = this.conversationHistory.at(-1);
|
|
3489
|
+
return last ? last.content : "";
|
|
3490
|
+
}
|
|
3491
|
+
/** Final message content as a `[role, content]` 2-tuple. */
|
|
3492
|
+
returnDictFinal() {
|
|
3493
|
+
const last = this.conversationHistory.at(-1);
|
|
3494
|
+
const content = last ? last.content : "";
|
|
3495
|
+
return [content, content];
|
|
3496
|
+
}
|
|
3497
|
+
/** Final message content wrapped in a single-element list. */
|
|
3498
|
+
returnListFinal() {
|
|
3499
|
+
const last = this.conversationHistory.at(-1);
|
|
3500
|
+
return [last ? last.content : ""];
|
|
3501
|
+
}
|
|
3502
|
+
/** All messages except the first two (system + first user). */
|
|
3503
|
+
returnAllExceptFirst() {
|
|
3504
|
+
return this.conversationHistory.slice(2);
|
|
3505
|
+
}
|
|
3506
|
+
/** {@link returnAllExceptFirst} rendered as a newline-joined content string. */
|
|
3507
|
+
returnAllExceptFirstString() {
|
|
3508
|
+
return this.conversationHistory.slice(2).map((m) => contentToString(m.content)).join("\n");
|
|
3509
|
+
}
|
|
3510
|
+
// ── Persistence ───────────────────────────────────────────────────────────
|
|
3511
|
+
/** Save the transcript to `saveFilepath` as JSON. */
|
|
3512
|
+
saveAsJson(force = true) {
|
|
3513
|
+
if (!this.autosave && !force) return;
|
|
3514
|
+
if (!this.saveFilepath) {
|
|
3515
|
+
this.saveFilepath = join4(
|
|
3516
|
+
this.conversationsDir ?? process.cwd(),
|
|
3517
|
+
`conversation_${this.id}.json`
|
|
3518
|
+
);
|
|
3519
|
+
}
|
|
3520
|
+
const dir = dirname3(this.saveFilepath);
|
|
3521
|
+
if (dir) mkdirSync2(dir, { recursive: true });
|
|
3522
|
+
writeFileSync(this.saveFilepath, JSON.stringify(this.conversationHistory, null, 4), "utf-8");
|
|
3523
|
+
}
|
|
3524
|
+
/** Save the transcript to `saveFilepath` as YAML. */
|
|
3525
|
+
saveAsYaml(force = true) {
|
|
3526
|
+
if (!this.autosave && !force) return;
|
|
3527
|
+
if (!this.saveFilepath) {
|
|
3528
|
+
this.saveFilepath = join4(
|
|
3529
|
+
this.conversationsDir ?? process.cwd(),
|
|
3530
|
+
`conversation_${this.id}.yaml`
|
|
3531
|
+
);
|
|
3532
|
+
}
|
|
3533
|
+
const dir = dirname3(this.saveFilepath);
|
|
3534
|
+
if (dir) mkdirSync2(dir, { recursive: true });
|
|
3535
|
+
writeFileSync(this.saveFilepath, yamlStringify(this.conversationHistory), "utf-8");
|
|
3536
|
+
}
|
|
3537
|
+
/** Save using the configured `exportMethod`. */
|
|
3538
|
+
export(force = true) {
|
|
3539
|
+
if (this.exportMethod === "json") this.saveAsJson(force);
|
|
3540
|
+
else this.saveAsYaml(force);
|
|
3541
|
+
}
|
|
3542
|
+
/** Load the transcript from a JSON file. */
|
|
3543
|
+
loadFromJson(filename) {
|
|
3544
|
+
if (!existsSync2(filename)) return;
|
|
3545
|
+
const data = JSON.parse(readFileSync2(filename, "utf-8"));
|
|
3546
|
+
this.conversationHistory = Array.isArray(data) ? data : data.conversation_history ?? [];
|
|
3547
|
+
this.strCache = null;
|
|
3548
|
+
}
|
|
3549
|
+
/** Load the transcript from a YAML file. */
|
|
3550
|
+
loadFromYaml(filename) {
|
|
3551
|
+
if (!existsSync2(filename)) return;
|
|
3552
|
+
const data = yamlParse2(readFileSync2(filename, "utf-8"));
|
|
3553
|
+
this.conversationHistory = Array.isArray(data) ? data : data?.conversation_history ?? [];
|
|
3554
|
+
this.strCache = null;
|
|
3555
|
+
}
|
|
3556
|
+
/** Load the transcript, dispatching on file extension. */
|
|
3557
|
+
load(filename) {
|
|
3558
|
+
if (!existsSync2(filename)) return;
|
|
3559
|
+
const lower = filename.toLowerCase();
|
|
3560
|
+
if (lower.endsWith(".json")) this.loadFromJson(filename);
|
|
3561
|
+
else if (lower.endsWith(".yaml") || lower.endsWith(".yml")) this.loadFromYaml(filename);
|
|
3562
|
+
else throw new Error(`Unsupported file format: ${filename}. Must be .json, .yaml, or .yml`);
|
|
3563
|
+
}
|
|
3564
|
+
/** Export the transcript to `filename` (JSON for `.json`, plain text otherwise). */
|
|
3565
|
+
exportConversation(filename) {
|
|
3566
|
+
if (filename.endsWith(".json")) {
|
|
3567
|
+
const dir = dirname3(filename);
|
|
3568
|
+
if (dir) mkdirSync2(dir, { recursive: true });
|
|
3569
|
+
writeFileSync(filename, JSON.stringify(this.conversationHistory, null, 4), "utf-8");
|
|
3570
|
+
} else {
|
|
3571
|
+
const text = this.conversationHistory.map((m) => `${m.role}: ${contentToString(m.content)}`).join("\n");
|
|
3572
|
+
writeFileSync(filename, `${text}
|
|
3573
|
+
`, "utf-8");
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
/** Import a transcript from a JSON file. */
|
|
3577
|
+
importConversation(filename) {
|
|
3578
|
+
this.loadFromJson(filename);
|
|
3579
|
+
}
|
|
3580
|
+
// ── Token management ──────────────────────────────────────────────────────
|
|
3581
|
+
/** Truncate, from the front, the rendered history so it fits `contextLength`. */
|
|
3582
|
+
dynamicAutoChunking() {
|
|
3583
|
+
try {
|
|
3584
|
+
return this.dynamicAutoChunkingWorker();
|
|
3585
|
+
} catch {
|
|
3586
|
+
return this.historyStringWorker();
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
dynamicAutoChunkingWorker() {
|
|
3590
|
+
const all = this.historyStringWorker();
|
|
3591
|
+
if (this.countTokens(all, this.tokenizerModelName) <= this.contextLength) return all;
|
|
3592
|
+
const target = this.contextLength;
|
|
3593
|
+
let left = 0;
|
|
3594
|
+
let right = all.length;
|
|
3595
|
+
let current = all;
|
|
3596
|
+
while (left < right) {
|
|
3597
|
+
const mid = Math.floor((left + right) / 2);
|
|
3598
|
+
const test = all.slice(mid);
|
|
3599
|
+
if (!test) break;
|
|
3600
|
+
if (this.countTokens(test, this.tokenizerModelName) <= target) {
|
|
3601
|
+
right = mid;
|
|
3602
|
+
current = test;
|
|
3603
|
+
} else {
|
|
3604
|
+
left = mid + 1;
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
return current;
|
|
3608
|
+
}
|
|
3609
|
+
/** Truncate the transcript message-by-message until it fits `contextLength`. */
|
|
3610
|
+
truncateMemoryWithTokenizer() {
|
|
3611
|
+
let totalTokens = 0;
|
|
3612
|
+
const truncated = [];
|
|
3613
|
+
for (const message of this.conversationHistory) {
|
|
3614
|
+
const content = contentToString(message.content);
|
|
3615
|
+
const tokenCount = this.countTokens(content, this.tokenizerModelName);
|
|
3616
|
+
if (totalTokens + tokenCount <= this.contextLength) {
|
|
3617
|
+
truncated.push(message);
|
|
3618
|
+
totalTokens += tokenCount;
|
|
3619
|
+
} else {
|
|
3620
|
+
const remaining = this.contextLength - totalTokens;
|
|
3621
|
+
if (remaining <= 0) break;
|
|
3622
|
+
const truncatedContent = this.binarySearchTruncate(
|
|
3623
|
+
content,
|
|
3624
|
+
remaining,
|
|
3625
|
+
this.tokenizerModelName
|
|
3626
|
+
);
|
|
3627
|
+
truncated.push({ ...message, content: truncatedContent });
|
|
3628
|
+
break;
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
this.conversationHistory = truncated;
|
|
3632
|
+
this.strCache = null;
|
|
3633
|
+
}
|
|
3634
|
+
/** Binary-search the longest prefix of `text` fitting `targetTokens`. */
|
|
3635
|
+
binarySearchTruncate(text, targetTokens, modelName) {
|
|
3636
|
+
if (!text || targetTokens <= 0) return "";
|
|
3637
|
+
if (this.countTokens(text, modelName) <= targetTokens) return text;
|
|
3638
|
+
let left = 0;
|
|
3639
|
+
let right = text.length;
|
|
3640
|
+
let best = "";
|
|
3641
|
+
while (left <= right) {
|
|
3642
|
+
const mid = Math.floor((left + right) / 2);
|
|
3643
|
+
const candidate = text.slice(0, mid);
|
|
3644
|
+
if (this.countTokens(candidate, modelName) <= targetTokens) {
|
|
3645
|
+
best = candidate;
|
|
3646
|
+
left = mid + 1;
|
|
3647
|
+
} else {
|
|
3648
|
+
right = mid - 1;
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
for (const delimiter of [".", "!", "?", "\n"]) {
|
|
3652
|
+
const lastPos = best.lastIndexOf(delimiter);
|
|
3653
|
+
if (lastPos > best.length * 0.75) {
|
|
3654
|
+
const atSentence = best.slice(0, lastPos + 1);
|
|
3655
|
+
if (this.countTokens(atSentence, modelName) <= targetTokens) return atSentence;
|
|
3656
|
+
}
|
|
3657
|
+
}
|
|
3658
|
+
return best;
|
|
3659
|
+
}
|
|
3660
|
+
/** Sum tokens for messages tagged `category: "input"` / `"output"`. */
|
|
3661
|
+
exportAndCountCategories() {
|
|
3662
|
+
const inputs = [];
|
|
3663
|
+
const outputs = [];
|
|
3664
|
+
for (const message of this.conversationHistory) {
|
|
3665
|
+
const content = contentToString(message.content);
|
|
3666
|
+
if (message.category === "input") inputs.push(content);
|
|
3667
|
+
else if (message.category === "output") outputs.push(content);
|
|
3668
|
+
}
|
|
3669
|
+
const inputText = inputs.join(" ");
|
|
3670
|
+
const outputText = outputs.join(" ");
|
|
3671
|
+
const inputTokens = inputText.trim() ? this.countTokens(inputText, this.tokenizerModelName) : 0;
|
|
3672
|
+
const outputTokens = outputText.trim() ? this.countTokens(outputText, this.tokenizerModelName) : 0;
|
|
3673
|
+
return { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens };
|
|
3674
|
+
}
|
|
3675
|
+
// ── MEMORY.md durable persistence ─────────────────────────────────────────
|
|
3676
|
+
initMemoryMd() {
|
|
3677
|
+
if (!this.memoryMdPath) return;
|
|
3678
|
+
const parent = dirname3(this.memoryMdPath);
|
|
3679
|
+
if (parent) mkdirSync2(parent, { recursive: true });
|
|
3680
|
+
if (!existsSync2(this.memoryMdPath)) {
|
|
3681
|
+
const header = `# Agent Memory
|
|
3682
|
+
|
|
3683
|
+
**Conversation:** ${this.name}
|
|
3684
|
+
**Created:** ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
3685
|
+
|
|
3686
|
+
---
|
|
3687
|
+
|
|
3688
|
+
## Interaction Log
|
|
3689
|
+
|
|
3690
|
+
`;
|
|
3691
|
+
try {
|
|
3692
|
+
writeFileSync(this.memoryMdPath, header, "utf-8");
|
|
3693
|
+
} catch {
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
preloadMemoryMd() {
|
|
3698
|
+
if (!this.memoryMdPath || !existsSync2(this.memoryMdPath)) return;
|
|
3699
|
+
let content;
|
|
3700
|
+
try {
|
|
3701
|
+
content = readFileSync2(this.memoryMdPath, "utf-8");
|
|
3702
|
+
} catch {
|
|
3703
|
+
return;
|
|
3704
|
+
}
|
|
3705
|
+
if (!content.includes("### ")) return;
|
|
3706
|
+
this.conversationHistory.push({
|
|
3707
|
+
role: "System",
|
|
3708
|
+
content: `[Persistent Memory \u2014 MEMORY.md]
|
|
3709
|
+
The following is this conversation's persistent memory from prior sessions. Use it to continue context coherently.
|
|
3710
|
+
|
|
3711
|
+
${content}`
|
|
3712
|
+
});
|
|
3713
|
+
this.strCache = null;
|
|
3714
|
+
}
|
|
3715
|
+
appendToMemoryMd(role, content) {
|
|
3716
|
+
if (this.suppressMemoryMd || !this.memoryMdPath) return;
|
|
3717
|
+
if (content === null || content === void 0) return;
|
|
3718
|
+
const contentStr = contentToString(content);
|
|
3719
|
+
if (!contentStr.trim()) return;
|
|
3720
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
3721
|
+
const block = `### ${role} \u2014 ${stamp}
|
|
3722
|
+
|
|
3723
|
+
${contentStr.trim()}
|
|
3724
|
+
|
|
3725
|
+
---
|
|
3726
|
+
|
|
3727
|
+
`;
|
|
3728
|
+
try {
|
|
3729
|
+
appendFileSync2(this.memoryMdPath, block, "utf-8");
|
|
3730
|
+
} catch {
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
archiveMemoryMd() {
|
|
3734
|
+
if (!this.memoryMdPath || !existsSync2(this.memoryMdPath)) return;
|
|
3735
|
+
let content;
|
|
3736
|
+
try {
|
|
3737
|
+
content = readFileSync2(this.memoryMdPath, "utf-8");
|
|
3738
|
+
} catch {
|
|
3739
|
+
return;
|
|
3740
|
+
}
|
|
3741
|
+
if (!content.includes("### ")) return;
|
|
3742
|
+
const agentDir = dirname3(this.memoryMdPath);
|
|
3743
|
+
const archiveDir = join4(agentDir, "archive");
|
|
3744
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
3745
|
+
const archivePath = join4(archiveDir, `history_${stamp}.md`);
|
|
3746
|
+
try {
|
|
3747
|
+
mkdirSync2(archiveDir, { recursive: true });
|
|
3748
|
+
writeFileSync(archivePath, content, "utf-8");
|
|
3749
|
+
} catch {
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
/**
|
|
3753
|
+
* Collapse the interaction history into a single `summary`, preserving the
|
|
3754
|
+
* static context (system prompt → rules → custom rules → summary). When
|
|
3755
|
+
* MEMORY.md is configured it is archived, wiped, and re-seeded so the on-disk
|
|
3756
|
+
* log stops growing across compactions.
|
|
3757
|
+
*/
|
|
3758
|
+
compact(summary, summaryRole = "System") {
|
|
3759
|
+
this.suppressMemoryMd = true;
|
|
3760
|
+
try {
|
|
3761
|
+
this.conversationHistory = [];
|
|
3762
|
+
this.strCache = null;
|
|
3763
|
+
if (this.systemPrompt !== void 0) {
|
|
3764
|
+
this.conversationHistory.push({ role: "System", content: this.systemPrompt });
|
|
3765
|
+
}
|
|
3766
|
+
if (this.rules !== void 0) {
|
|
3767
|
+
this.conversationHistory.push({ role: this.user || "User", content: this.rules });
|
|
3768
|
+
}
|
|
3769
|
+
if (this.customRulesPrompt !== void 0) {
|
|
3770
|
+
this.conversationHistory.push({
|
|
3771
|
+
role: this.user || "User",
|
|
3772
|
+
content: this.customRulesPrompt
|
|
3773
|
+
});
|
|
3774
|
+
}
|
|
3775
|
+
} finally {
|
|
3776
|
+
this.suppressMemoryMd = false;
|
|
3777
|
+
}
|
|
3778
|
+
if (this.memoryMdPath) {
|
|
3779
|
+
try {
|
|
3780
|
+
this.archiveMemoryMd();
|
|
3781
|
+
if (existsSync2(this.memoryMdPath)) rmSync2(this.memoryMdPath, { force: true });
|
|
3782
|
+
this.initMemoryMd();
|
|
3783
|
+
} catch {
|
|
3784
|
+
}
|
|
3785
|
+
}
|
|
3786
|
+
this.add(summaryRole, summary);
|
|
3787
|
+
}
|
|
3788
|
+
// ── Class helpers ─────────────────────────────────────────────────────────
|
|
3789
|
+
/** Load a conversation by name (or explicit file) from a directory. */
|
|
3790
|
+
static loadConversation(name, conversationsDir, loadFilepath) {
|
|
3791
|
+
if (loadFilepath) {
|
|
3792
|
+
const conversation = new _Conversation({ name });
|
|
3793
|
+
conversation.load(loadFilepath);
|
|
3794
|
+
return conversation;
|
|
3795
|
+
}
|
|
3796
|
+
const dir = conversationsDir ?? getConversationDir();
|
|
3797
|
+
for (const ext of [".json", ".yaml", ".yml"]) {
|
|
3798
|
+
const filepath = join4(dir, `${name}${ext}`);
|
|
3799
|
+
if (existsSync2(filepath)) {
|
|
3800
|
+
const conversation = new _Conversation({ name, conversationsDir: dir });
|
|
3801
|
+
conversation.load(filepath);
|
|
3802
|
+
return conversation;
|
|
3803
|
+
}
|
|
3804
|
+
}
|
|
3805
|
+
return new _Conversation({ name, conversationsDir: dir });
|
|
3806
|
+
}
|
|
3807
|
+
};
|
|
3808
|
+
|
|
3809
|
+
// src/output-formatter.ts
|
|
3810
|
+
import { stringify as yamlStringify2 } from "yaml";
|
|
3811
|
+
function escapeXml2(value) {
|
|
3812
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
3813
|
+
}
|
|
3814
|
+
function toXmlValue(value, indent) {
|
|
3815
|
+
if (value === null || value === void 0) return "";
|
|
3816
|
+
if (Array.isArray(value)) {
|
|
3817
|
+
return value.map((item) => `${indent}<item>${toXmlValue(item, `${indent} `)}</item>`).join("\n");
|
|
3818
|
+
}
|
|
3819
|
+
if (typeof value === "object") {
|
|
3820
|
+
const entries = Object.entries(value);
|
|
3821
|
+
return `
|
|
3822
|
+
${entries.map(([k, v]) => `${indent}<${k}>${toXmlValue(v, `${indent} `)}</${k}>`).join("\n")}
|
|
3823
|
+
${indent.slice(0, -2)}`;
|
|
3824
|
+
}
|
|
3825
|
+
return escapeXml2(String(value));
|
|
3826
|
+
}
|
|
3827
|
+
function toXmlString(messages) {
|
|
3828
|
+
const body = messages.map((m) => ` <message>${toXmlValue(m, " ")}</message>`).join("\n");
|
|
3829
|
+
return `<conversation>
|
|
3830
|
+
${body}
|
|
3831
|
+
</conversation>`;
|
|
3832
|
+
}
|
|
3833
|
+
function historyOutputFormatter(conversation, type = "list") {
|
|
3834
|
+
switch (type) {
|
|
3835
|
+
case "list":
|
|
3836
|
+
return conversation.returnMessagesAsList();
|
|
3837
|
+
case "dict":
|
|
3838
|
+
case "dictionary":
|
|
3839
|
+
return conversation.toDict();
|
|
3840
|
+
case "string":
|
|
3841
|
+
case "str":
|
|
3842
|
+
return conversation.getStr();
|
|
3843
|
+
case "final":
|
|
3844
|
+
case "last":
|
|
3845
|
+
return conversation.getFinalMessageContent();
|
|
3846
|
+
case "json":
|
|
3847
|
+
return conversation.toJson();
|
|
3848
|
+
case "all":
|
|
3849
|
+
return conversation.getStr();
|
|
3850
|
+
case "yaml":
|
|
3851
|
+
return yamlStringify2(conversation.toDict());
|
|
3852
|
+
case "xml":
|
|
3853
|
+
return toXmlString(conversation.toDict());
|
|
3854
|
+
case "dict-all-except-first":
|
|
3855
|
+
return conversation.returnAllExceptFirst();
|
|
3856
|
+
case "str-all-except-first":
|
|
3857
|
+
return conversation.returnAllExceptFirstString();
|
|
3858
|
+
case "dict-final":
|
|
3859
|
+
return conversation.returnDictFinal();
|
|
3860
|
+
case "list-final":
|
|
3861
|
+
return conversation.returnListFinal();
|
|
3862
|
+
case "basemodel":
|
|
3863
|
+
return conversation.returnMessagesAsDictionary();
|
|
3864
|
+
default: {
|
|
3865
|
+
const _exhaustive = type;
|
|
3866
|
+
void _exhaustive;
|
|
3867
|
+
return conversation.returnMessagesAsList();
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
// src/serialization.ts
|
|
3873
|
+
import { mkdir as mkdir2, readFile as readFile5, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
3874
|
+
import { dirname as dirname4 } from "path";
|
|
3875
|
+
function hasToDict(value) {
|
|
3876
|
+
return typeof value === "object" && value !== null && typeof value.toDict === "function";
|
|
3877
|
+
}
|
|
3878
|
+
function isJsonSerializable(value) {
|
|
3879
|
+
try {
|
|
3880
|
+
JSON.stringify(value);
|
|
3881
|
+
return true;
|
|
3882
|
+
} catch {
|
|
3883
|
+
return false;
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
function serializeAttr(value) {
|
|
3887
|
+
if (typeof value === "function") {
|
|
3888
|
+
const fn = value;
|
|
3889
|
+
return { name: fn.name || "anonymous", doc: null };
|
|
3890
|
+
}
|
|
3891
|
+
if (hasToDict(value)) {
|
|
3892
|
+
return value.toDict();
|
|
3893
|
+
}
|
|
3894
|
+
if (isJsonSerializable(value)) {
|
|
3895
|
+
return value;
|
|
3896
|
+
}
|
|
3897
|
+
const typeName = value && typeof value === "object" ? value.constructor?.name ?? "object" : typeof value;
|
|
3898
|
+
return `<Non-serializable: ${typeName}>`;
|
|
3899
|
+
}
|
|
3900
|
+
function serializeState(obj, exclude = []) {
|
|
3901
|
+
const excluded = new Set(exclude);
|
|
3902
|
+
const result = {};
|
|
3903
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
3904
|
+
if (excluded.has(key)) continue;
|
|
3905
|
+
result[key] = serializeAttr(value);
|
|
3906
|
+
}
|
|
3907
|
+
return result;
|
|
3908
|
+
}
|
|
3909
|
+
var SerializableMixin = class {
|
|
3910
|
+
/** Field names to omit from `toDict()`. Override in subclasses. */
|
|
3911
|
+
_toDictExclude = [];
|
|
3912
|
+
/** Serialize this instance's own enumerable fields to a plain dictionary. */
|
|
3913
|
+
toDict() {
|
|
3914
|
+
return serializeState(this, this._toDictExclude);
|
|
3915
|
+
}
|
|
3916
|
+
};
|
|
3917
|
+
var SAFE_PRIMITIVE = /* @__PURE__ */ new Set(["boolean", "number", "string"]);
|
|
3918
|
+
function isSafeType(value) {
|
|
3919
|
+
if (value === null || value === void 0) return true;
|
|
3920
|
+
const t = typeof value;
|
|
3921
|
+
if (SAFE_PRIMITIVE.has(t)) return true;
|
|
3922
|
+
if (Array.isArray(value)) return value.every(isSafeType);
|
|
3923
|
+
if (t === "object") {
|
|
3924
|
+
const obj = value;
|
|
3925
|
+
const proto = Object.getPrototypeOf(obj);
|
|
3926
|
+
if (proto !== Object.prototype && proto !== null) return false;
|
|
3927
|
+
return Object.entries(obj).every(([k, v]) => typeof k === "string" && isSafeType(v));
|
|
3928
|
+
}
|
|
3929
|
+
return false;
|
|
3930
|
+
}
|
|
3931
|
+
function isClassInstance(value) {
|
|
3932
|
+
if (value === null || value === void 0) return false;
|
|
3933
|
+
if (typeof value !== "object") return false;
|
|
3934
|
+
if (Array.isArray(value)) return false;
|
|
3935
|
+
const proto = Object.getPrototypeOf(value);
|
|
3936
|
+
return proto !== Object.prototype && proto !== null;
|
|
3937
|
+
}
|
|
3938
|
+
function createStateDict(obj) {
|
|
3939
|
+
const state = {};
|
|
3940
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
3941
|
+
if (key.startsWith("_")) continue;
|
|
3942
|
+
if (isSafeType(value)) state[key] = value;
|
|
3943
|
+
}
|
|
3944
|
+
return state;
|
|
3945
|
+
}
|
|
3946
|
+
function preserveInstances(obj) {
|
|
3947
|
+
const preserved = {};
|
|
3948
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
3949
|
+
if (key.startsWith("_")) continue;
|
|
3950
|
+
if (isClassInstance(value)) preserved[key] = value;
|
|
3951
|
+
}
|
|
3952
|
+
return preserved;
|
|
3953
|
+
}
|
|
3954
|
+
var SafeStateManager = {
|
|
3955
|
+
/**
|
|
3956
|
+
* Atomically save the object's safe state to `filePath`.
|
|
3957
|
+
*
|
|
3958
|
+
* Writes a temp file, backs up any existing file, then renames the temp into
|
|
3959
|
+
* place so a crash mid-write never corrupts the target.
|
|
3960
|
+
*/
|
|
3961
|
+
async saveState(obj, filePath) {
|
|
3962
|
+
const stateDict = createStateDict(obj);
|
|
3963
|
+
const dir = dirname4(filePath);
|
|
3964
|
+
if (dir) await mkdir2(dir, { recursive: true });
|
|
3965
|
+
const tempPath = `${filePath}.tmp`;
|
|
3966
|
+
const backupPath = `${filePath}.bak`;
|
|
3967
|
+
await writeFile2(tempPath, JSON.stringify(stateDict, null, 2), "utf-8");
|
|
3968
|
+
try {
|
|
3969
|
+
await rename(filePath, backupPath);
|
|
3970
|
+
} catch {
|
|
3971
|
+
}
|
|
3972
|
+
await rename(tempPath, filePath);
|
|
3973
|
+
await rm2(backupPath, { force: true });
|
|
3974
|
+
},
|
|
3975
|
+
/**
|
|
3976
|
+
* Load persisted safe state into `obj`, preserving any live class-instance
|
|
3977
|
+
* fields the object already holds.
|
|
3978
|
+
*/
|
|
3979
|
+
async loadState(obj, filePath) {
|
|
3980
|
+
let raw;
|
|
3981
|
+
try {
|
|
3982
|
+
raw = await readFile5(filePath, "utf-8");
|
|
3983
|
+
} catch {
|
|
3984
|
+
throw new Error(`State file not found: ${filePath}`);
|
|
3985
|
+
}
|
|
3986
|
+
const preserved = preserveInstances(obj);
|
|
3987
|
+
const stateDict = JSON.parse(raw);
|
|
3988
|
+
for (const [key, value] of Object.entries(stateDict)) {
|
|
3989
|
+
if (key.startsWith("_")) continue;
|
|
3990
|
+
if (key in preserved) continue;
|
|
3991
|
+
if (isSafeType(value)) obj[key] = value;
|
|
3992
|
+
}
|
|
3993
|
+
for (const [key, value] of Object.entries(preserved)) {
|
|
3994
|
+
obj[key] = value;
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
};
|
|
3998
|
+
|
|
3999
|
+
// src/agent-errors.ts
|
|
4000
|
+
var AgentError = class extends Error {
|
|
4001
|
+
/** Machine-readable, stable error code. */
|
|
4002
|
+
code;
|
|
4003
|
+
cause;
|
|
4004
|
+
constructor(message, code = "agent", cause) {
|
|
4005
|
+
super(message);
|
|
4006
|
+
this.name = new.target.name;
|
|
4007
|
+
this.code = code;
|
|
4008
|
+
this.cause = cause;
|
|
4009
|
+
}
|
|
4010
|
+
};
|
|
4011
|
+
var AgentInitializationError = class extends AgentError {
|
|
4012
|
+
constructor(message, cause) {
|
|
4013
|
+
super(message, "agent_initialization", cause);
|
|
4014
|
+
}
|
|
4015
|
+
};
|
|
4016
|
+
var AgentRunError = class extends AgentError {
|
|
4017
|
+
constructor(message, cause) {
|
|
4018
|
+
super(message, "agent_run", cause);
|
|
4019
|
+
}
|
|
4020
|
+
};
|
|
4021
|
+
var AgentLLMError = class extends AgentError {
|
|
4022
|
+
constructor(message, cause) {
|
|
4023
|
+
super(message, "agent_llm", cause);
|
|
4024
|
+
}
|
|
4025
|
+
};
|
|
4026
|
+
var AgentToolError = class extends AgentError {
|
|
4027
|
+
constructor(message, cause) {
|
|
4028
|
+
super(message, "agent_tool", cause);
|
|
4029
|
+
}
|
|
4030
|
+
};
|
|
4031
|
+
var AgentMemoryError = class extends AgentError {
|
|
4032
|
+
constructor(message, cause) {
|
|
4033
|
+
super(message, "agent_memory", cause);
|
|
4034
|
+
}
|
|
4035
|
+
};
|
|
4036
|
+
var AgentMCPConnectionError = class extends AgentError {
|
|
4037
|
+
constructor(message, cause) {
|
|
4038
|
+
super(message, "agent_mcp_connection", cause);
|
|
4039
|
+
}
|
|
4040
|
+
};
|
|
4041
|
+
var AgentMCPToolError = class extends AgentError {
|
|
4042
|
+
constructor(message, cause) {
|
|
4043
|
+
super(message, "agent_mcp_tool", cause);
|
|
4044
|
+
}
|
|
4045
|
+
};
|
|
4046
|
+
|
|
4047
|
+
// src/index.ts
|
|
4048
|
+
var PACKAGE = "@ap3x/agent-core";
|
|
4049
|
+
var VERSION = "0.1.0";
|
|
4050
|
+
export {
|
|
4051
|
+
Agent,
|
|
4052
|
+
AgentConfigSchema,
|
|
4053
|
+
AgentError,
|
|
4054
|
+
AgentHarness,
|
|
4055
|
+
AgentHarnessError,
|
|
4056
|
+
AgentInitializationError,
|
|
4057
|
+
AgentLLMError,
|
|
4058
|
+
AgentLoader,
|
|
4059
|
+
AgentMCPConnectionError,
|
|
4060
|
+
AgentMCPToolError,
|
|
4061
|
+
AgentMemoryError,
|
|
4062
|
+
AgentRegistry,
|
|
4063
|
+
AgentRegistrySchema,
|
|
4064
|
+
AgentRunError,
|
|
4065
|
+
AgentSpecSchema,
|
|
4066
|
+
AgentToolError,
|
|
4067
|
+
Ap3xError,
|
|
4068
|
+
Ap3xRuntimeBackend,
|
|
4069
|
+
CompactionError,
|
|
4070
|
+
Conversation,
|
|
4071
|
+
DEFAULT_COMPACTION_SETTINGS,
|
|
4072
|
+
DEFAULT_MAX_LOOPS,
|
|
4073
|
+
ExecutionError,
|
|
4074
|
+
FileError,
|
|
4075
|
+
FileJsonlFile,
|
|
4076
|
+
InMemorySessionStorage,
|
|
4077
|
+
JsonlSessionRepo,
|
|
4078
|
+
JsonlSessionStorage,
|
|
4079
|
+
NodeExecutionEnv,
|
|
4080
|
+
PACKAGE,
|
|
4081
|
+
SUMMARIZATION_SYSTEM_PROMPT,
|
|
4082
|
+
SYSTEM_PROMPT_SECTIONS,
|
|
4083
|
+
SafeStateManager,
|
|
4084
|
+
SerializableMixin,
|
|
4085
|
+
Session,
|
|
4086
|
+
SessionError,
|
|
4087
|
+
SkillRegistry,
|
|
4088
|
+
StringJsonlFile,
|
|
4089
|
+
SystemPromptBuilder,
|
|
4090
|
+
VERSION,
|
|
4091
|
+
agentLoop,
|
|
4092
|
+
agentLoopContinue,
|
|
4093
|
+
assembleSystemPrompt,
|
|
4094
|
+
availableParallelism,
|
|
4095
|
+
buildSessionContext,
|
|
4096
|
+
compact,
|
|
4097
|
+
compactionTier,
|
|
4098
|
+
createLimiter,
|
|
4099
|
+
createRuntimeBackend,
|
|
4100
|
+
createSessionId,
|
|
4101
|
+
createStateDict,
|
|
4102
|
+
defineTool,
|
|
4103
|
+
detectShell,
|
|
4104
|
+
discoverSkillFiles,
|
|
4105
|
+
discoverSkills,
|
|
4106
|
+
environmentSection,
|
|
4107
|
+
err,
|
|
4108
|
+
estimateContextTokens,
|
|
4109
|
+
estimateMessageTokens,
|
|
4110
|
+
estimateTokens,
|
|
4111
|
+
executeShellWithCapture,
|
|
4112
|
+
executeToolCalls,
|
|
4113
|
+
finalTextOf,
|
|
4114
|
+
findCutPoint,
|
|
4115
|
+
formatSkillsForSystemPrompt,
|
|
4116
|
+
generateConversationId,
|
|
4117
|
+
getConversationDir,
|
|
4118
|
+
getLogLevel,
|
|
4119
|
+
getOrThrow,
|
|
4120
|
+
getOrUndefined,
|
|
4121
|
+
historyOutputFormatter,
|
|
4122
|
+
isBlockedCommand,
|
|
4123
|
+
isClassInstance,
|
|
4124
|
+
isSafeType,
|
|
4125
|
+
lastAssistant,
|
|
4126
|
+
loadPromptTemplate,
|
|
4127
|
+
loadSkill,
|
|
4128
|
+
loadSkills,
|
|
4129
|
+
logger,
|
|
4130
|
+
mapLimit,
|
|
4131
|
+
matchBlocklist,
|
|
4132
|
+
ok,
|
|
4133
|
+
parseCommandArgs,
|
|
4134
|
+
parsePromptTemplate,
|
|
4135
|
+
parseSkill,
|
|
4136
|
+
parseYamlFrontmatter,
|
|
4137
|
+
prepareCompaction,
|
|
4138
|
+
preserveInstances,
|
|
4139
|
+
producesContextMessage,
|
|
4140
|
+
renderPromptTemplate,
|
|
4141
|
+
resolveAutoCompact,
|
|
4142
|
+
resolveCompactionSettings,
|
|
4143
|
+
runAgentLoop,
|
|
4144
|
+
runAgentLoopContinue,
|
|
4145
|
+
serializeAttr,
|
|
4146
|
+
serializeState,
|
|
4147
|
+
setLogLevel,
|
|
4148
|
+
shortId,
|
|
4149
|
+
shouldCompact,
|
|
4150
|
+
substituteArgs,
|
|
4151
|
+
toError,
|
|
4152
|
+
uuidv7
|
|
4153
|
+
};
|