@electric-ax/agents 0.1.5 → 0.2.2
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/dist/entrypoint.js +653 -408
- package/dist/index.cjs +671 -419
- package/dist/index.d.cts +36 -3
- package/dist/index.d.ts +36 -3
- package/dist/index.js +656 -411
- package/docs/entities/agents/horton.md +89 -0
- package/docs/entities/agents/worker.md +102 -0
- package/docs/entities/patterns/blackboard.md +111 -0
- package/docs/entities/patterns/dispatcher.md +77 -0
- package/docs/entities/patterns/manager-worker.md +127 -0
- package/docs/entities/patterns/map-reduce.md +81 -0
- package/docs/entities/patterns/pipeline.md +101 -0
- package/docs/entities/patterns/reactive-observers.md +125 -0
- package/docs/examples/mega-draw.md +106 -0
- package/docs/examples/playground.md +46 -0
- package/docs/index.md +208 -0
- package/docs/quickstart.md +201 -0
- package/docs/reference/agent-config.md +82 -0
- package/docs/reference/agent-tool.md +58 -0
- package/docs/reference/built-in-collections.md +334 -0
- package/docs/reference/cli.md +238 -0
- package/docs/reference/entity-definition.md +57 -0
- package/docs/reference/entity-handle.md +63 -0
- package/docs/reference/entity-registry.md +73 -0
- package/docs/reference/handler-context.md +108 -0
- package/docs/reference/runtime-handler.md +136 -0
- package/docs/reference/shared-state-handle.md +74 -0
- package/docs/reference/state-collection-proxy.md +41 -0
- package/docs/reference/wake-event.md +132 -0
- package/docs/usage/app-setup.md +165 -0
- package/docs/usage/clients-and-react.md +191 -0
- package/docs/usage/configuring-the-agent.md +136 -0
- package/docs/usage/context-composition.md +204 -0
- package/docs/usage/defining-entities.md +181 -0
- package/docs/usage/defining-tools.md +229 -0
- package/docs/usage/embedded-builtins.md +180 -0
- package/docs/usage/managing-state.md +93 -0
- package/docs/usage/overview.md +284 -0
- package/docs/usage/programmatic-runtime-client.md +216 -0
- package/docs/usage/shared-state.md +169 -0
- package/docs/usage/spawning-and-coordinating.md +165 -0
- package/docs/usage/testing.md +76 -0
- package/docs/usage/waking-entities.md +148 -0
- package/docs/usage/writing-handlers.md +267 -0
- package/package.json +6 -9
- package/skills/init.md +71 -0
- package/skills/quickstart/scaffold/package.json +30 -0
- package/skills/{tutorial → quickstart}/scaffold/tsconfig.json +8 -3
- package/skills/quickstart/scaffold/vite.config.ts +21 -0
- package/skills/quickstart/scaffold-ui/index.html +12 -0
- package/skills/quickstart/scaffold-ui/main.tsx +235 -0
- package/skills/quickstart.md +582 -0
- package/skills/tutorial/scaffold/package.json +0 -17
- package/skills/tutorial.md +0 -282
- /package/skills/{tutorial → quickstart}/scaffold/entities/.gitkeep +0 -0
- /package/skills/{tutorial → quickstart}/scaffold/lib/electric-tools.ts +0 -0
- /package/skills/{tutorial → quickstart}/scaffold/server.ts +0 -0
package/dist/entrypoint.js
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
-
import path
|
|
4
|
-
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import fs, { promises, watch } from "node:fs";
|
|
5
5
|
import pino from "pino";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { createEntityRegistry, createRuntimeHandler, db } from "@electric-ax/agents-runtime";
|
|
7
|
+
import { CODING_SESSION_CURSOR_COLLECTION_TYPE, CODING_SESSION_EVENT_COLLECTION_TYPE, CODING_SESSION_META_COLLECTION_TYPE, createEntityRegistry, createRuntimeHandler, db } from "@electric-ax/agents-runtime";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { deserializeCursor, discoverSessions, importLocalSession, loadSession, resolveSession, serializeCursor, tailSession } from "agent-session-protocol";
|
|
8
12
|
import Anthropic from "@anthropic-ai/sdk";
|
|
9
13
|
import { createHash } from "node:crypto";
|
|
10
|
-
import fs$1
|
|
14
|
+
import fs$1 from "node:fs/promises";
|
|
11
15
|
import Database from "better-sqlite3";
|
|
12
16
|
import { Type } from "@sinclair/typebox";
|
|
13
17
|
import { load } from "sqlite-vec";
|
|
14
|
-
import { exec } from "node:child_process";
|
|
15
|
-
import { createRequire } from "node:module";
|
|
16
|
-
import { Readability } from "@mozilla/readability";
|
|
17
|
-
import { JSDOM, VirtualConsole } from "jsdom";
|
|
18
|
-
import TurndownService from "turndown";
|
|
19
18
|
import { nanoid } from "nanoid";
|
|
19
|
+
import { braveSearchTool, createBashTool, createEditTool, createReadFileTool, createWriteTool, fetchUrlTool } from "@electric-ax/agents-runtime/tools";
|
|
20
20
|
|
|
21
21
|
//#region src/log.ts
|
|
22
22
|
const LOG_DIR = process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`);
|
|
@@ -67,6 +67,516 @@ const serverLog = {
|
|
|
67
67
|
}
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/agents/coding-session.ts
|
|
72
|
+
const defaultCliRunner = { async run(opts) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const isClaude = opts.agent === `claude`;
|
|
75
|
+
const bin = isClaude ? `claude` : `codex`;
|
|
76
|
+
const args = isClaude ? opts.sessionId ? [
|
|
77
|
+
`-r`,
|
|
78
|
+
opts.sessionId,
|
|
79
|
+
`--dangerously-skip-permissions`,
|
|
80
|
+
`-p`
|
|
81
|
+
] : [`--dangerously-skip-permissions`, `-p`] : opts.sessionId ? [
|
|
82
|
+
`exec`,
|
|
83
|
+
`--skip-git-repo-check`,
|
|
84
|
+
`resume`,
|
|
85
|
+
opts.sessionId,
|
|
86
|
+
opts.prompt
|
|
87
|
+
] : [
|
|
88
|
+
`exec`,
|
|
89
|
+
`--skip-git-repo-check`,
|
|
90
|
+
opts.prompt
|
|
91
|
+
];
|
|
92
|
+
const child = spawn(bin, args, {
|
|
93
|
+
cwd: opts.cwd,
|
|
94
|
+
stdio: [
|
|
95
|
+
isClaude ? `pipe` : `ignore`,
|
|
96
|
+
`pipe`,
|
|
97
|
+
`pipe`
|
|
98
|
+
]
|
|
99
|
+
});
|
|
100
|
+
const MAX_BUF_CHARS = 4096;
|
|
101
|
+
let stdout = ``;
|
|
102
|
+
let stderr = ``;
|
|
103
|
+
child.stdout?.on(`data`, (d) => {
|
|
104
|
+
if (stdout.length < MAX_BUF_CHARS) stdout += d.toString().slice(0, MAX_BUF_CHARS - stdout.length);
|
|
105
|
+
});
|
|
106
|
+
child.stderr?.on(`data`, (d) => {
|
|
107
|
+
if (stderr.length < MAX_BUF_CHARS) stderr += d.toString().slice(0, MAX_BUF_CHARS - stderr.length);
|
|
108
|
+
});
|
|
109
|
+
child.on(`error`, reject);
|
|
110
|
+
child.on(`exit`, (code) => {
|
|
111
|
+
resolve({
|
|
112
|
+
exitCode: code ?? -1,
|
|
113
|
+
stdout,
|
|
114
|
+
stderr
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
if (isClaude && child.stdin) {
|
|
118
|
+
child.stdin.write(opts.prompt);
|
|
119
|
+
child.stdin.end();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
} };
|
|
123
|
+
async function discoverNewestSession(agent, cwd, excludeIds) {
|
|
124
|
+
const all = await discoverSessions(agent);
|
|
125
|
+
const candidates = all.filter((s) => !excludeIds.has(s.sessionId) && (!s.cwd || s.cwd === cwd));
|
|
126
|
+
if (candidates.length === 0) return null;
|
|
127
|
+
return candidates[0].sessionId;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Compute the candidate directories where Claude Code stores per-cwd
|
|
131
|
+
* session JSONL files. Claude resolves the cwd to its realpath when
|
|
132
|
+
* choosing the directory name (so /tmp/foo on macOS lands under
|
|
133
|
+
* `-private-tmp-foo`), but the entity may have been spawned with the
|
|
134
|
+
* non-realpath form. Return both candidates so the caller can union
|
|
135
|
+
* their contents.
|
|
136
|
+
*/
|
|
137
|
+
async function getClaudeProjectDirs(cwd) {
|
|
138
|
+
const home = homedir();
|
|
139
|
+
const make = (c) => path.join(home, `.claude`, `projects`, c.replace(/\//g, `-`));
|
|
140
|
+
const dirs = [make(cwd)];
|
|
141
|
+
try {
|
|
142
|
+
const real = await promises.realpath(cwd);
|
|
143
|
+
if (real !== cwd) dirs.push(make(real));
|
|
144
|
+
} catch {}
|
|
145
|
+
return dirs;
|
|
146
|
+
}
|
|
147
|
+
async function listClaudeJsonlIdsByCwd(cwd) {
|
|
148
|
+
const ids = new Set();
|
|
149
|
+
for (const dir of await getClaudeProjectDirs(cwd)) try {
|
|
150
|
+
const files = await promises.readdir(dir);
|
|
151
|
+
for (const f of files) if (f.endsWith(`.jsonl`)) ids.add(f.slice(0, -`.jsonl`.length));
|
|
152
|
+
} catch {}
|
|
153
|
+
return ids;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Deterministic-path discovery for a freshly created session. After the
|
|
157
|
+
* Claude CLI runs in `-p` mode it writes the new JSONL straight into
|
|
158
|
+
* `~/.claude/projects/<sanitize(cwd)>/<id>.jsonl` *without* leaving a
|
|
159
|
+
* `~/.claude/sessions/<pid>.json` lock file (those are interactive-only),
|
|
160
|
+
* so `discoverSessions` can miss it. Compute the expected dir directly
|
|
161
|
+
* and diff its contents against a pre-run snapshot. Returns the newest
|
|
162
|
+
* fresh sessionId or null. Codex falls back to discoverNewestSession.
|
|
163
|
+
*/
|
|
164
|
+
async function findNewSessionAfterRun(agent, cwd, preDirectIds, preDiscoveredIds) {
|
|
165
|
+
if (agent === `claude`) {
|
|
166
|
+
const dirs = await getClaudeProjectDirs(cwd);
|
|
167
|
+
let best = null;
|
|
168
|
+
for (const dir of dirs) try {
|
|
169
|
+
const files = await promises.readdir(dir);
|
|
170
|
+
for (const f of files) {
|
|
171
|
+
if (!f.endsWith(`.jsonl`)) continue;
|
|
172
|
+
const id = f.slice(0, -`.jsonl`.length);
|
|
173
|
+
if (preDirectIds.has(id)) continue;
|
|
174
|
+
const st = await promises.stat(path.join(dir, f)).catch(() => null);
|
|
175
|
+
if (!st) continue;
|
|
176
|
+
if (!best || st.mtimeMs > best.mtime) best = {
|
|
177
|
+
id,
|
|
178
|
+
mtime: st.mtimeMs
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
} catch {}
|
|
182
|
+
if (best) return best.id;
|
|
183
|
+
}
|
|
184
|
+
return discoverNewestSession(agent, cwd, preDiscoveredIds);
|
|
185
|
+
}
|
|
186
|
+
const sessionMetaRowSchema = z.object({
|
|
187
|
+
key: z.literal(`current`),
|
|
188
|
+
electricSessionId: z.string(),
|
|
189
|
+
nativeSessionId: z.string().optional(),
|
|
190
|
+
agent: z.enum([`claude`, `codex`]),
|
|
191
|
+
cwd: z.string(),
|
|
192
|
+
status: z.enum([
|
|
193
|
+
`initializing`,
|
|
194
|
+
`idle`,
|
|
195
|
+
`running`,
|
|
196
|
+
`error`
|
|
197
|
+
]),
|
|
198
|
+
error: z.string().optional(),
|
|
199
|
+
currentPromptInboxKey: z.string().optional()
|
|
200
|
+
});
|
|
201
|
+
const cursorStateRowSchema = z.object({
|
|
202
|
+
key: z.literal(`current`),
|
|
203
|
+
cursor: z.string(),
|
|
204
|
+
lastProcessedInboxKey: z.string().optional()
|
|
205
|
+
});
|
|
206
|
+
const eventRowSchema = z.object({
|
|
207
|
+
key: z.string(),
|
|
208
|
+
ts: z.number(),
|
|
209
|
+
type: z.string(),
|
|
210
|
+
callId: z.string().optional(),
|
|
211
|
+
payload: z.looseObject({})
|
|
212
|
+
});
|
|
213
|
+
const creationArgsSchema = z.object({
|
|
214
|
+
agent: z.enum([`claude`, `codex`]),
|
|
215
|
+
cwd: z.string().optional(),
|
|
216
|
+
nativeSessionId: z.string().optional(),
|
|
217
|
+
importFrom: z.object({
|
|
218
|
+
agent: z.enum([`claude`, `codex`]),
|
|
219
|
+
sessionId: z.string()
|
|
220
|
+
}).optional()
|
|
221
|
+
});
|
|
222
|
+
const promptMessageSchema = z.object({ text: z.string() });
|
|
223
|
+
/**
|
|
224
|
+
* Stable key for an events-collection row, derived from the event's content.
|
|
225
|
+
* Lets us re-insert the same event without producing duplicates — the caller
|
|
226
|
+
* (or the collection's uniqueness guard) uses this to de-dup across retries,
|
|
227
|
+
* replays, and crash recovery. Sorts chronologically by ts, then by type.
|
|
228
|
+
*/
|
|
229
|
+
function eventKey(event) {
|
|
230
|
+
const tsPart = String(event.ts).padStart(16, `0`);
|
|
231
|
+
return `${tsPart}_${event.type}_${contentHashHex(event)}`;
|
|
232
|
+
}
|
|
233
|
+
function contentHashHex(event) {
|
|
234
|
+
const json = JSON.stringify(event);
|
|
235
|
+
let h = 5381;
|
|
236
|
+
for (let i = 0; i < json.length; i++) h = (h * 33 ^ json.charCodeAt(i)) >>> 0;
|
|
237
|
+
return h.toString(16).padStart(8, `0`);
|
|
238
|
+
}
|
|
239
|
+
function buildEventRow(event) {
|
|
240
|
+
const callId = `callId` in event && typeof event.callId === `string` ? event.callId : void 0;
|
|
241
|
+
return {
|
|
242
|
+
key: eventKey(event),
|
|
243
|
+
ts: event.ts,
|
|
244
|
+
type: event.type,
|
|
245
|
+
...callId !== void 0 ? { callId } : {},
|
|
246
|
+
payload: event
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function appendIfNew(ctx, event) {
|
|
250
|
+
const row = buildEventRow(event);
|
|
251
|
+
if (ctx.events.get(row.key) !== void 0) return;
|
|
252
|
+
ctx.actions.events_insert({ row });
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Mirror every event that lands in the JSONL file while `runWork` is
|
|
256
|
+
* executing (i.e. while the CLI is running). Returns the advanced cursor
|
|
257
|
+
* and the `runWork` result once everything has settled and every append
|
|
258
|
+
* has been persisted to the entity's durable stream.
|
|
259
|
+
*
|
|
260
|
+
* If setup fails (e.g. the session file can't be resolved), `runWork`
|
|
261
|
+
* still runs — but nothing is mirrored and `setupError` is populated so
|
|
262
|
+
* the caller can surface the condition. If `runWork` throws, the error
|
|
263
|
+
* propagates after the watcher has been cleaned up.
|
|
264
|
+
*/
|
|
265
|
+
async function runWithLiveMirror(opts) {
|
|
266
|
+
let cursor = null;
|
|
267
|
+
let setupError = void 0;
|
|
268
|
+
try {
|
|
269
|
+
const session = await resolveSession(opts.nativeSessionId, opts.agent);
|
|
270
|
+
if (opts.serializedCursor) cursor = deserializeCursor({
|
|
271
|
+
...opts.serializedCursor,
|
|
272
|
+
path: session.path
|
|
273
|
+
});
|
|
274
|
+
else {
|
|
275
|
+
const initial = await loadSession({
|
|
276
|
+
sessionId: opts.nativeSessionId,
|
|
277
|
+
agent: opts.agent
|
|
278
|
+
});
|
|
279
|
+
for (const ev of initial.events) appendIfNew(opts.ctx, ev);
|
|
280
|
+
cursor = initial.cursor;
|
|
281
|
+
}
|
|
282
|
+
} catch (e) {
|
|
283
|
+
setupError = e;
|
|
284
|
+
}
|
|
285
|
+
if (!cursor) {
|
|
286
|
+
const result$1 = await opts.runWork();
|
|
287
|
+
return {
|
|
288
|
+
cursor: opts.serializedCursor,
|
|
289
|
+
setupError,
|
|
290
|
+
result: result$1
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
let activeCursor = cursor;
|
|
294
|
+
let busy = false;
|
|
295
|
+
let pending = false;
|
|
296
|
+
let stopped = false;
|
|
297
|
+
const drainOnce = async () => {
|
|
298
|
+
if (stopped && busy) return;
|
|
299
|
+
if (busy) {
|
|
300
|
+
pending = true;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
busy = true;
|
|
304
|
+
try {
|
|
305
|
+
const res = await tailSession({ cursor: activeCursor });
|
|
306
|
+
activeCursor = res.cursor;
|
|
307
|
+
for (const ev of res.newEvents) appendIfNew(opts.ctx, ev);
|
|
308
|
+
} catch {} finally {
|
|
309
|
+
busy = false;
|
|
310
|
+
if (pending && !stopped) {
|
|
311
|
+
pending = false;
|
|
312
|
+
drainOnce();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
const fileWatcher = watch(activeCursor.path, () => {
|
|
317
|
+
drainOnce();
|
|
318
|
+
});
|
|
319
|
+
const pollHandle = setInterval(() => {
|
|
320
|
+
drainOnce();
|
|
321
|
+
}, 1500);
|
|
322
|
+
let result;
|
|
323
|
+
try {
|
|
324
|
+
result = await opts.runWork();
|
|
325
|
+
} finally {
|
|
326
|
+
stopped = true;
|
|
327
|
+
clearInterval(pollHandle);
|
|
328
|
+
fileWatcher.close();
|
|
329
|
+
while (busy) await new Promise((r) => setTimeout(r, 10));
|
|
330
|
+
try {
|
|
331
|
+
const final = await tailSession({ cursor: activeCursor });
|
|
332
|
+
activeCursor = final.cursor;
|
|
333
|
+
for (const ev of final.newEvents) appendIfNew(opts.ctx, ev);
|
|
334
|
+
} catch {}
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
cursor: serializeCursor(activeCursor),
|
|
338
|
+
setupError,
|
|
339
|
+
result
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function registerCodingSession(registry, options = {}) {
|
|
343
|
+
const runner = options.cliRunner ?? defaultCliRunner;
|
|
344
|
+
const defaultCwd = options.defaultWorkingDirectory ?? process.cwd();
|
|
345
|
+
registry.define(`coder`, {
|
|
346
|
+
description: `Runs a Claude Code / Codex CLI session and mirrors its normalized event stream into a durable store. Prompts arrive via message_received (type: "prompt") and are executed serially.`,
|
|
347
|
+
creationSchema: creationArgsSchema,
|
|
348
|
+
inboxSchemas: { prompt: promptMessageSchema },
|
|
349
|
+
state: {
|
|
350
|
+
sessionMeta: {
|
|
351
|
+
schema: sessionMetaRowSchema,
|
|
352
|
+
type: CODING_SESSION_META_COLLECTION_TYPE,
|
|
353
|
+
primaryKey: `key`
|
|
354
|
+
},
|
|
355
|
+
cursorState: {
|
|
356
|
+
schema: cursorStateRowSchema,
|
|
357
|
+
type: CODING_SESSION_CURSOR_COLLECTION_TYPE,
|
|
358
|
+
primaryKey: `key`
|
|
359
|
+
},
|
|
360
|
+
events: {
|
|
361
|
+
schema: eventRowSchema,
|
|
362
|
+
type: CODING_SESSION_EVENT_COLLECTION_TYPE,
|
|
363
|
+
primaryKey: `key`
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
async handler(ctx, _wake) {
|
|
367
|
+
const existingMeta = ctx.db.collections.sessionMeta.get(`current`);
|
|
368
|
+
if (!existingMeta) {
|
|
369
|
+
const args = creationArgsSchema.parse(ctx.args);
|
|
370
|
+
const cwd = args.cwd ?? defaultCwd;
|
|
371
|
+
const electricSessionId = ctx.entityUrl.split(`/`).pop() ?? ctx.entityUrl;
|
|
372
|
+
let resolvedNativeId = args.nativeSessionId;
|
|
373
|
+
if (args.importFrom) {
|
|
374
|
+
const result = await importLocalSession({
|
|
375
|
+
source: {
|
|
376
|
+
sessionId: args.importFrom.sessionId,
|
|
377
|
+
agent: args.importFrom.agent
|
|
378
|
+
},
|
|
379
|
+
target: {
|
|
380
|
+
agent: args.agent,
|
|
381
|
+
cwd
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
resolvedNativeId = result.sessionId;
|
|
385
|
+
}
|
|
386
|
+
const hasNative = resolvedNativeId !== void 0;
|
|
387
|
+
ctx.db.actions.sessionMeta_insert({ row: {
|
|
388
|
+
key: `current`,
|
|
389
|
+
electricSessionId,
|
|
390
|
+
...hasNative ? { nativeSessionId: resolvedNativeId } : {},
|
|
391
|
+
agent: args.agent,
|
|
392
|
+
cwd,
|
|
393
|
+
status: hasNative ? `idle` : `initializing`
|
|
394
|
+
} });
|
|
395
|
+
}
|
|
396
|
+
if (!ctx.db.collections.cursorState.get(`current`)) ctx.db.actions.cursorState_insert({ row: {
|
|
397
|
+
key: `current`,
|
|
398
|
+
cursor: ``
|
|
399
|
+
} });
|
|
400
|
+
const metaRow = ctx.db.collections.sessionMeta.get(`current`);
|
|
401
|
+
const cursorRow = ctx.db.collections.cursorState.get(`current`);
|
|
402
|
+
if (!metaRow || !cursorRow) throw new Error(`[coding-session] expected sessionMeta and cursorState rows to exist after init`);
|
|
403
|
+
if (metaRow.nativeSessionId && !cursorRow.cursor) {
|
|
404
|
+
const mirrorCtx = {
|
|
405
|
+
events: { get: (k) => ctx.db.collections.events.get(k) },
|
|
406
|
+
actions: { events_insert: ctx.db.actions.events_insert }
|
|
407
|
+
};
|
|
408
|
+
try {
|
|
409
|
+
const initial = await loadSession({
|
|
410
|
+
sessionId: metaRow.nativeSessionId,
|
|
411
|
+
agent: metaRow.agent
|
|
412
|
+
});
|
|
413
|
+
for (const ev of initial.events) appendIfNew(mirrorCtx, ev);
|
|
414
|
+
const serialized = serializeCursor(initial.cursor);
|
|
415
|
+
ctx.db.actions.cursorState_update({
|
|
416
|
+
key: `current`,
|
|
417
|
+
updater: (d) => {
|
|
418
|
+
d.cursor = JSON.stringify(serialized);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
} catch (e) {
|
|
422
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
423
|
+
ctx.db.actions.sessionMeta_update({
|
|
424
|
+
key: `current`,
|
|
425
|
+
updater: (d) => {
|
|
426
|
+
d.error = `initial mirror failed: ${message}`;
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const inboxRows = ctx.db.collections.inbox.toArray.slice().sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
432
|
+
const lastKey = cursorRow.lastProcessedInboxKey ?? ``;
|
|
433
|
+
const pending = inboxRows.filter((m) => m.key > lastKey);
|
|
434
|
+
if (pending.length === 0) {
|
|
435
|
+
if (metaRow.status === `running` || metaRow.status === `error`) ctx.db.actions.sessionMeta_update({
|
|
436
|
+
key: `current`,
|
|
437
|
+
updater: (d) => {
|
|
438
|
+
d.status = `idle`;
|
|
439
|
+
delete d.currentPromptInboxKey;
|
|
440
|
+
delete d.error;
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
let runningMeta = metaRow;
|
|
446
|
+
let runningCursor = cursorRow;
|
|
447
|
+
for (const inboxMsg of pending) {
|
|
448
|
+
const parsed = promptMessageSchema.safeParse(inboxMsg.payload);
|
|
449
|
+
if (!parsed.success) {
|
|
450
|
+
ctx.db.actions.cursorState_update({
|
|
451
|
+
key: `current`,
|
|
452
|
+
updater: (d) => {
|
|
453
|
+
d.lastProcessedInboxKey = inboxMsg.key;
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
runningCursor = {
|
|
457
|
+
...runningCursor,
|
|
458
|
+
lastProcessedInboxKey: inboxMsg.key
|
|
459
|
+
};
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const prompt = parsed.data.text;
|
|
463
|
+
const existingTitle = ctx.tags.title;
|
|
464
|
+
if (typeof existingTitle !== `string` || existingTitle.length === 0) ctx.setTag(`title`, prompt.slice(0, 80));
|
|
465
|
+
ctx.db.actions.sessionMeta_update({
|
|
466
|
+
key: `current`,
|
|
467
|
+
updater: (d) => {
|
|
468
|
+
d.status = `running`;
|
|
469
|
+
d.currentPromptInboxKey = inboxMsg.key;
|
|
470
|
+
delete d.error;
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
const recordedRun = ctx.recordRun();
|
|
474
|
+
const eventKeysBefore = new Set(ctx.db.collections.events.toArray.map((e) => e.key));
|
|
475
|
+
try {
|
|
476
|
+
const mirrorCtx = {
|
|
477
|
+
events: { get: (k) => ctx.db.collections.events.get(k) },
|
|
478
|
+
actions: { events_insert: ctx.db.actions.events_insert }
|
|
479
|
+
};
|
|
480
|
+
let nextCursorJson = runningCursor.cursor;
|
|
481
|
+
if (!runningMeta.nativeSessionId) {
|
|
482
|
+
const preDirectIds = runningMeta.agent === `claude` ? await listClaudeJsonlIdsByCwd(runningMeta.cwd) : new Set();
|
|
483
|
+
const preDiscoveredIds = new Set((await discoverSessions(runningMeta.agent)).map((s) => s.sessionId));
|
|
484
|
+
const cliResult = await runner.run({
|
|
485
|
+
agent: runningMeta.agent,
|
|
486
|
+
cwd: runningMeta.cwd,
|
|
487
|
+
prompt
|
|
488
|
+
});
|
|
489
|
+
if (cliResult.exitCode !== 0) throw new Error(`[coding-session] ${runningMeta.agent} CLI exited ${cliResult.exitCode}. stderr=${cliResult.stderr.slice(0, 800) || `<empty>`} stdout=${cliResult.stdout.slice(0, 800) || `<empty>`}`);
|
|
490
|
+
const foundId = await findNewSessionAfterRun(runningMeta.agent, runningMeta.cwd, preDirectIds, preDiscoveredIds);
|
|
491
|
+
if (!foundId) throw new Error(`[coding-session] ${runningMeta.agent} CLI succeeded but no new session file was found`);
|
|
492
|
+
ctx.db.actions.sessionMeta_update({
|
|
493
|
+
key: `current`,
|
|
494
|
+
updater: (d) => {
|
|
495
|
+
d.nativeSessionId = foundId;
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
runningMeta = {
|
|
499
|
+
...runningMeta,
|
|
500
|
+
nativeSessionId: foundId
|
|
501
|
+
};
|
|
502
|
+
const initial = await loadSession({
|
|
503
|
+
sessionId: foundId,
|
|
504
|
+
agent: runningMeta.agent
|
|
505
|
+
});
|
|
506
|
+
for (const ev of initial.events) appendIfNew(mirrorCtx, ev);
|
|
507
|
+
nextCursorJson = JSON.stringify(serializeCursor(initial.cursor));
|
|
508
|
+
} else {
|
|
509
|
+
const serializedCursor = runningCursor.cursor ? JSON.parse(runningCursor.cursor) : null;
|
|
510
|
+
const { cursor: nextSerialized, setupError, result: cliResult } = await runWithLiveMirror({
|
|
511
|
+
agent: runningMeta.agent,
|
|
512
|
+
nativeSessionId: runningMeta.nativeSessionId,
|
|
513
|
+
serializedCursor,
|
|
514
|
+
ctx: mirrorCtx,
|
|
515
|
+
runWork: () => runner.run({
|
|
516
|
+
agent: runningMeta.agent,
|
|
517
|
+
sessionId: runningMeta.nativeSessionId,
|
|
518
|
+
cwd: runningMeta.cwd,
|
|
519
|
+
prompt
|
|
520
|
+
})
|
|
521
|
+
});
|
|
522
|
+
if (setupError) throw setupError instanceof Error ? setupError : new Error(String(setupError));
|
|
523
|
+
if (cliResult.exitCode !== 0) throw new Error(`[coding-session] ${runningMeta.agent} CLI exited ${cliResult.exitCode}. stderr=${cliResult.stderr.slice(0, 800) || `<empty>`} stdout=${cliResult.stdout.slice(0, 800) || `<empty>`}`);
|
|
524
|
+
const persistedCursor = nextSerialized ?? serializedCursor;
|
|
525
|
+
nextCursorJson = persistedCursor ? JSON.stringify(persistedCursor) : ``;
|
|
526
|
+
}
|
|
527
|
+
ctx.db.actions.cursorState_update({
|
|
528
|
+
key: `current`,
|
|
529
|
+
updater: (d) => {
|
|
530
|
+
d.cursor = nextCursorJson;
|
|
531
|
+
d.lastProcessedInboxKey = inboxMsg.key;
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
runningCursor = {
|
|
535
|
+
...runningCursor,
|
|
536
|
+
cursor: nextCursorJson,
|
|
537
|
+
lastProcessedInboxKey: inboxMsg.key
|
|
538
|
+
};
|
|
539
|
+
for (const row of ctx.db.collections.events.toArray) {
|
|
540
|
+
if (eventKeysBefore.has(row.key)) continue;
|
|
541
|
+
if (row.type !== `assistant_message`) continue;
|
|
542
|
+
const text = row.payload?.text;
|
|
543
|
+
if (typeof text === `string` && text.length > 0) recordedRun.attachResponse(text);
|
|
544
|
+
}
|
|
545
|
+
recordedRun.end({ status: `completed` });
|
|
546
|
+
} catch (e) {
|
|
547
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
548
|
+
recordedRun.end({
|
|
549
|
+
status: `failed`,
|
|
550
|
+
finishReason: `error`
|
|
551
|
+
});
|
|
552
|
+
ctx.db.actions.sessionMeta_update({
|
|
553
|
+
key: `current`,
|
|
554
|
+
updater: (d) => {
|
|
555
|
+
d.status = `error`;
|
|
556
|
+
d.error = message;
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
ctx.db.actions.cursorState_update({
|
|
560
|
+
key: `current`,
|
|
561
|
+
updater: (d) => {
|
|
562
|
+
d.lastProcessedInboxKey = inboxMsg.key;
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
throw e;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
ctx.db.actions.sessionMeta_update({
|
|
569
|
+
key: `current`,
|
|
570
|
+
updater: (d) => {
|
|
571
|
+
d.status = `idle`;
|
|
572
|
+
delete d.currentPromptInboxKey;
|
|
573
|
+
delete d.error;
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
70
580
|
//#endregion
|
|
71
581
|
//#region src/docs/embed.ts
|
|
72
582
|
const EMBEDDING_DIMENSIONS = 128;
|
|
@@ -286,6 +796,8 @@ function resolveDocsRoot(workingDirectory) {
|
|
|
286
796
|
process.env.HORTON_DOCS_ROOT,
|
|
287
797
|
path.resolve(workingDirectory, `electric-agents-docs/docs`),
|
|
288
798
|
path.resolve(process.cwd(), `electric-agents-docs/docs`),
|
|
799
|
+
path.resolve(MODULE_DIR, `../docs`),
|
|
800
|
+
path.resolve(MODULE_DIR, `../../docs`),
|
|
289
801
|
path.resolve(MODULE_DIR, `../../../../../electric-agents-docs/docs`)
|
|
290
802
|
].filter((value) => typeof value === `string`);
|
|
291
803
|
for (const candidate of candidates) if (fs.existsSync(candidate)) return candidate;
|
|
@@ -839,319 +1351,6 @@ function listRefFiles(dir, prefix = ``) {
|
|
|
839
1351
|
}
|
|
840
1352
|
}
|
|
841
1353
|
|
|
842
|
-
//#endregion
|
|
843
|
-
//#region src/tools/bash.ts
|
|
844
|
-
const TIMEOUT_MS = 3e4;
|
|
845
|
-
const MAX_OUTPUT_CHARS = 5e4;
|
|
846
|
-
function createBashTool(workingDirectory) {
|
|
847
|
-
return {
|
|
848
|
-
name: `bash`,
|
|
849
|
-
label: `Bash`,
|
|
850
|
-
description: `Execute a shell command and return its output. Commands run in a sandboxed working directory with a 30-second timeout.`,
|
|
851
|
-
parameters: Type.Object({ command: Type.String({ description: `The shell command to execute` }) }),
|
|
852
|
-
execute: async (_toolCallId, params) => {
|
|
853
|
-
const { command } = params;
|
|
854
|
-
return new Promise((resolve$1) => {
|
|
855
|
-
const child = exec(command, {
|
|
856
|
-
cwd: workingDirectory,
|
|
857
|
-
timeout: TIMEOUT_MS,
|
|
858
|
-
maxBuffer: 1024 * 1024,
|
|
859
|
-
env: {
|
|
860
|
-
...process.env,
|
|
861
|
-
HOME: workingDirectory
|
|
862
|
-
}
|
|
863
|
-
});
|
|
864
|
-
let stdout = ``;
|
|
865
|
-
let stderr = ``;
|
|
866
|
-
child.stdout?.on(`data`, (data) => {
|
|
867
|
-
stdout += data;
|
|
868
|
-
});
|
|
869
|
-
child.stderr?.on(`data`, (data) => {
|
|
870
|
-
stderr += data;
|
|
871
|
-
});
|
|
872
|
-
child.on(`close`, (code, signal) => {
|
|
873
|
-
const timedOut = signal === `SIGTERM`;
|
|
874
|
-
let output = stdout;
|
|
875
|
-
if (stderr) output += output ? `\n\nSTDERR:\n${stderr}` : stderr;
|
|
876
|
-
if (timedOut) output += `\n\n[Command timed out after ${TIMEOUT_MS / 1e3}s]`;
|
|
877
|
-
output = output.slice(0, MAX_OUTPUT_CHARS);
|
|
878
|
-
resolve$1({
|
|
879
|
-
content: [{
|
|
880
|
-
type: `text`,
|
|
881
|
-
text: output || `(no output)`
|
|
882
|
-
}],
|
|
883
|
-
details: {
|
|
884
|
-
exitCode: code ?? 1,
|
|
885
|
-
timedOut
|
|
886
|
-
}
|
|
887
|
-
});
|
|
888
|
-
});
|
|
889
|
-
child.on(`error`, (err) => {
|
|
890
|
-
resolve$1({
|
|
891
|
-
content: [{
|
|
892
|
-
type: `text`,
|
|
893
|
-
text: `Command failed: ${err.message}`
|
|
894
|
-
}],
|
|
895
|
-
details: {
|
|
896
|
-
exitCode: 1,
|
|
897
|
-
timedOut: false
|
|
898
|
-
}
|
|
899
|
-
});
|
|
900
|
-
});
|
|
901
|
-
});
|
|
902
|
-
}
|
|
903
|
-
};
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
//#endregion
|
|
907
|
-
//#region src/tools/edit.ts
|
|
908
|
-
const READ_GUARD_MESSAGE = (rel) => `File ${rel} has not been read in this session (sessions are per-wake — re-read after waking from a worker).`;
|
|
909
|
-
function createEditTool(workingDirectory, readSet) {
|
|
910
|
-
return {
|
|
911
|
-
name: `edit`,
|
|
912
|
-
label: `Edit File`,
|
|
913
|
-
description: `Replace text in a file. The file must have been read with the read tool earlier in this session. By default the old_string must occur exactly once; set replace_all to true to replace every occurrence.`,
|
|
914
|
-
parameters: Type.Object({
|
|
915
|
-
path: Type.String({ description: `File path (relative to working directory)` }),
|
|
916
|
-
old_string: Type.String({ description: `The literal text to find. Must be unique unless replace_all is true.` }),
|
|
917
|
-
new_string: Type.String({ description: `The replacement text.` }),
|
|
918
|
-
replace_all: Type.Optional(Type.Boolean({ description: `Replace every occurrence (default false).` }))
|
|
919
|
-
}),
|
|
920
|
-
execute: async (_toolCallId, params) => {
|
|
921
|
-
const { path: filePath, old_string, new_string, replace_all } = params;
|
|
922
|
-
try {
|
|
923
|
-
const resolved = resolve(workingDirectory, filePath);
|
|
924
|
-
const rel = relative(workingDirectory, resolved);
|
|
925
|
-
if (rel.startsWith(`..`)) return {
|
|
926
|
-
content: [{
|
|
927
|
-
type: `text`,
|
|
928
|
-
text: `Error: Path "${filePath}" is outside the working directory`
|
|
929
|
-
}],
|
|
930
|
-
details: { replacements: 0 }
|
|
931
|
-
};
|
|
932
|
-
if (!readSet.has(resolved)) return {
|
|
933
|
-
content: [{
|
|
934
|
-
type: `text`,
|
|
935
|
-
text: READ_GUARD_MESSAGE(rel)
|
|
936
|
-
}],
|
|
937
|
-
details: { replacements: 0 }
|
|
938
|
-
};
|
|
939
|
-
const original = await readFile(resolved, `utf-8`);
|
|
940
|
-
if (!replace_all) {
|
|
941
|
-
const first = original.indexOf(old_string);
|
|
942
|
-
if (first === -1) return {
|
|
943
|
-
content: [{
|
|
944
|
-
type: `text`,
|
|
945
|
-
text: `Error: old_string not found in ${rel}`
|
|
946
|
-
}],
|
|
947
|
-
details: { replacements: 0 }
|
|
948
|
-
};
|
|
949
|
-
const second = original.indexOf(old_string, first + 1);
|
|
950
|
-
if (second !== -1) {
|
|
951
|
-
const matches = original.split(old_string).length - 1;
|
|
952
|
-
return {
|
|
953
|
-
content: [{
|
|
954
|
-
type: `text`,
|
|
955
|
-
text: `Error: found ${matches} matches for old_string in ${rel}; pass replace_all=true to replace all, or provide a more specific old_string.`
|
|
956
|
-
}],
|
|
957
|
-
details: { replacements: 0 }
|
|
958
|
-
};
|
|
959
|
-
}
|
|
960
|
-
const updated = original.slice(0, first) + new_string + original.slice(first + old_string.length);
|
|
961
|
-
await writeFile(resolved, updated, `utf-8`);
|
|
962
|
-
return {
|
|
963
|
-
content: [{
|
|
964
|
-
type: `text`,
|
|
965
|
-
text: `Edited ${rel}: 1 replacement`
|
|
966
|
-
}],
|
|
967
|
-
details: { replacements: 1 }
|
|
968
|
-
};
|
|
969
|
-
}
|
|
970
|
-
const parts = original.split(old_string);
|
|
971
|
-
const count = parts.length - 1;
|
|
972
|
-
if (count === 0) return {
|
|
973
|
-
content: [{
|
|
974
|
-
type: `text`,
|
|
975
|
-
text: `Error: old_string not found in ${rel}`
|
|
976
|
-
}],
|
|
977
|
-
details: { replacements: 0 }
|
|
978
|
-
};
|
|
979
|
-
await writeFile(resolved, parts.join(new_string), `utf-8`);
|
|
980
|
-
return {
|
|
981
|
-
content: [{
|
|
982
|
-
type: `text`,
|
|
983
|
-
text: `Edited ${rel}: ${count} occurrences replaced`
|
|
984
|
-
}],
|
|
985
|
-
details: { replacements: count }
|
|
986
|
-
};
|
|
987
|
-
} catch (err) {
|
|
988
|
-
serverLog.warn(`[edit tool] failed to edit ${filePath}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
989
|
-
return {
|
|
990
|
-
content: [{
|
|
991
|
-
type: `text`,
|
|
992
|
-
text: `Error editing file: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
993
|
-
}],
|
|
994
|
-
details: { replacements: 0 }
|
|
995
|
-
};
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
};
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
//#endregion
|
|
1002
|
-
//#region src/tools/fetch-url.ts
|
|
1003
|
-
const MAX_RAW_CHARS = 1e5;
|
|
1004
|
-
const require = createRequire(import.meta.url);
|
|
1005
|
-
const { gfm } = require(`turndown-plugin-gfm`);
|
|
1006
|
-
function htmlToMarkdown(html, url) {
|
|
1007
|
-
const virtualConsole = new VirtualConsole();
|
|
1008
|
-
const dom = new JSDOM(html, {
|
|
1009
|
-
url,
|
|
1010
|
-
virtualConsole
|
|
1011
|
-
});
|
|
1012
|
-
const reader = new Readability(dom.window.document);
|
|
1013
|
-
const article = reader.parse();
|
|
1014
|
-
const turndown = new TurndownService({ headingStyle: `atx` });
|
|
1015
|
-
turndown.use(gfm);
|
|
1016
|
-
return turndown.turndown(article?.content ?? html);
|
|
1017
|
-
}
|
|
1018
|
-
let anthropic$1 = null;
|
|
1019
|
-
function getClient$1() {
|
|
1020
|
-
if (!anthropic$1) anthropic$1 = new Anthropic();
|
|
1021
|
-
return anthropic$1;
|
|
1022
|
-
}
|
|
1023
|
-
async function extractWithLLM(text, prompt) {
|
|
1024
|
-
const client = getClient$1();
|
|
1025
|
-
const res = await client.messages.create({
|
|
1026
|
-
model: `claude-haiku-4-5-20251001`,
|
|
1027
|
-
max_tokens: 2048,
|
|
1028
|
-
messages: [{
|
|
1029
|
-
role: `user`,
|
|
1030
|
-
content: `${prompt}\n\n<page_content>\n${text.slice(0, MAX_RAW_CHARS)}\n</page_content>`
|
|
1031
|
-
}]
|
|
1032
|
-
});
|
|
1033
|
-
const block = res.content[0];
|
|
1034
|
-
return block?.type === `text` ? block.text : ``;
|
|
1035
|
-
}
|
|
1036
|
-
const fetchUrlTool = {
|
|
1037
|
-
name: `fetch_url`,
|
|
1038
|
-
label: `Fetch URL`,
|
|
1039
|
-
description: `Fetch a web page and extract its key content using AI. Provide a prompt describing what information you want from the page. Returns a focused extraction rather than raw HTML.`,
|
|
1040
|
-
parameters: Type.Object({
|
|
1041
|
-
url: Type.String({ description: `The URL to fetch` }),
|
|
1042
|
-
prompt: Type.String({ description: `What to extract from the page, e.g. 'Extract the main article content' or 'Find the pricing information'` })
|
|
1043
|
-
}),
|
|
1044
|
-
execute: async (_toolCallId, params) => {
|
|
1045
|
-
const { url, prompt } = params;
|
|
1046
|
-
try {
|
|
1047
|
-
const res = await fetch(url, {
|
|
1048
|
-
headers: {
|
|
1049
|
-
"User-Agent": `Mozilla/5.0 (compatible; DurableStreamsAgent/1.0)`,
|
|
1050
|
-
Accept: `text/html,application/xhtml+xml,text/plain,*/*`
|
|
1051
|
-
},
|
|
1052
|
-
redirect: `follow`,
|
|
1053
|
-
signal: AbortSignal.timeout(1e4)
|
|
1054
|
-
});
|
|
1055
|
-
if (!res.ok) return {
|
|
1056
|
-
content: [{
|
|
1057
|
-
type: `text`,
|
|
1058
|
-
text: `Failed to fetch: ${res.status} ${res.statusText}`
|
|
1059
|
-
}],
|
|
1060
|
-
details: {
|
|
1061
|
-
charCount: 0,
|
|
1062
|
-
usedLLM: false
|
|
1063
|
-
}
|
|
1064
|
-
};
|
|
1065
|
-
const contentType = res.headers.get(`content-type`) ?? ``;
|
|
1066
|
-
const raw = await res.text();
|
|
1067
|
-
const markdown = contentType.includes(`text/html`) ? htmlToMarkdown(raw, url) : raw;
|
|
1068
|
-
const extracted = await extractWithLLM(markdown, prompt);
|
|
1069
|
-
return {
|
|
1070
|
-
content: [{
|
|
1071
|
-
type: `text`,
|
|
1072
|
-
text: extracted
|
|
1073
|
-
}],
|
|
1074
|
-
details: {
|
|
1075
|
-
charCount: extracted.length,
|
|
1076
|
-
usedLLM: true
|
|
1077
|
-
}
|
|
1078
|
-
};
|
|
1079
|
-
} catch (err) {
|
|
1080
|
-
return {
|
|
1081
|
-
content: [{
|
|
1082
|
-
type: `text`,
|
|
1083
|
-
text: `Error fetching URL: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
1084
|
-
}],
|
|
1085
|
-
details: {
|
|
1086
|
-
charCount: 0,
|
|
1087
|
-
usedLLM: false
|
|
1088
|
-
}
|
|
1089
|
-
};
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
};
|
|
1093
|
-
|
|
1094
|
-
//#endregion
|
|
1095
|
-
//#region src/tools/read-file.ts
|
|
1096
|
-
const MAX_FILE_SIZE = 512 * 1024;
|
|
1097
|
-
function createReadFileTool(workingDirectory, readSet) {
|
|
1098
|
-
return {
|
|
1099
|
-
name: `read`,
|
|
1100
|
-
label: `Read File`,
|
|
1101
|
-
description: `Read the contents of a file. Path must be relative to or within the working directory. Binary files and files over 512KB are rejected.`,
|
|
1102
|
-
parameters: Type.Object({ path: Type.String({ description: `File path (relative to working directory)` }) }),
|
|
1103
|
-
execute: async (_toolCallId, params) => {
|
|
1104
|
-
const { path: filePath } = params;
|
|
1105
|
-
try {
|
|
1106
|
-
const resolved = resolve(workingDirectory, filePath);
|
|
1107
|
-
const rel = relative(workingDirectory, resolved);
|
|
1108
|
-
if (rel.startsWith(`..`)) return {
|
|
1109
|
-
content: [{
|
|
1110
|
-
type: `text`,
|
|
1111
|
-
text: `Error: Path "${filePath}" is outside the working directory`
|
|
1112
|
-
}],
|
|
1113
|
-
details: { charCount: 0 }
|
|
1114
|
-
};
|
|
1115
|
-
const fileStat = await stat(resolved);
|
|
1116
|
-
if (fileStat.size > MAX_FILE_SIZE) return {
|
|
1117
|
-
content: [{
|
|
1118
|
-
type: `text`,
|
|
1119
|
-
text: `Error: File is too large (${(fileStat.size / 1024).toFixed(0)}KB > ${MAX_FILE_SIZE / 1024}KB limit)`
|
|
1120
|
-
}],
|
|
1121
|
-
details: { charCount: 0 }
|
|
1122
|
-
};
|
|
1123
|
-
const buffer = await readFile(resolved);
|
|
1124
|
-
const sample = buffer.subarray(0, 8192);
|
|
1125
|
-
if (sample.includes(0)) return {
|
|
1126
|
-
content: [{
|
|
1127
|
-
type: `text`,
|
|
1128
|
-
text: `Error: "${filePath}" appears to be a binary file`
|
|
1129
|
-
}],
|
|
1130
|
-
details: { charCount: 0 }
|
|
1131
|
-
};
|
|
1132
|
-
const text = buffer.toString(`utf-8`);
|
|
1133
|
-
readSet?.add(resolved);
|
|
1134
|
-
return {
|
|
1135
|
-
content: [{
|
|
1136
|
-
type: `text`,
|
|
1137
|
-
text
|
|
1138
|
-
}],
|
|
1139
|
-
details: { charCount: text.length }
|
|
1140
|
-
};
|
|
1141
|
-
} catch (err) {
|
|
1142
|
-
serverLog.warn(`[read tool] failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
1143
|
-
return {
|
|
1144
|
-
content: [{
|
|
1145
|
-
type: `text`,
|
|
1146
|
-
text: `Error reading file: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
1147
|
-
}],
|
|
1148
|
-
details: { charCount: 0 }
|
|
1149
|
-
};
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
};
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
1354
|
//#endregion
|
|
1156
1355
|
//#region src/tools/spawn-worker.ts
|
|
1157
1356
|
const WORKER_TOOL_NAMES = [
|
|
@@ -1227,114 +1426,117 @@ function createSpawnWorkerTool(ctx) {
|
|
|
1227
1426
|
}
|
|
1228
1427
|
|
|
1229
1428
|
//#endregion
|
|
1230
|
-
//#region src/tools/
|
|
1231
|
-
|
|
1429
|
+
//#region src/tools/spawn-coder.ts
|
|
1430
|
+
const CODER_AGENT_NAMES = [`claude`, `codex`];
|
|
1431
|
+
function createSpawnCoderTool(ctx) {
|
|
1232
1432
|
return {
|
|
1233
|
-
name: `
|
|
1234
|
-
label: `
|
|
1235
|
-
description: `
|
|
1433
|
+
name: `spawn_coder`,
|
|
1434
|
+
label: `Spawn Coder`,
|
|
1435
|
+
description: `Spawn a coding-session subagent (a coder) that drives a Claude Code or Codex CLI session in a working directory. Use when the user asks for code changes, file edits, debugging, or any task that benefits from a real coding agent with tool access. The coder is long-lived — its URL stays valid across many turns, so you can keep prompting it via prompt_coder without re-spawning. End your turn after spawning; you'll be woken when the coder finishes its first reply.`,
|
|
1236
1436
|
parameters: Type.Object({
|
|
1237
|
-
|
|
1238
|
-
|
|
1437
|
+
prompt: Type.String({ description: `First user message sent to the coder. This is what kicks off the run — without it the coder will idle. Be concrete: describe the task, mention the files/paths involved, and what form of answer you want back.` }),
|
|
1438
|
+
agent: Type.Optional(Type.Union(CODER_AGENT_NAMES.map((n) => Type.Literal(n)), { description: `Which coding agent to use. Defaults to "claude". Use "codex" only if the user explicitly asks for it.` })),
|
|
1439
|
+
cwd: Type.Optional(Type.String({ description: `Working directory the coder runs in. Defaults to the runtime's cwd (the same directory Horton is running in). Set this when the user wants the coder to operate on a different repo.` }))
|
|
1239
1440
|
}),
|
|
1240
1441
|
execute: async (_toolCallId, params) => {
|
|
1241
|
-
const {
|
|
1442
|
+
const { prompt, agent, cwd } = params;
|
|
1443
|
+
if (typeof prompt !== `string` || prompt.length === 0) return {
|
|
1444
|
+
content: [{
|
|
1445
|
+
type: `text`,
|
|
1446
|
+
text: `Error: prompt is required and must be a non-empty string.`
|
|
1447
|
+
}],
|
|
1448
|
+
details: { spawned: false }
|
|
1449
|
+
};
|
|
1450
|
+
const id = nanoid(10);
|
|
1451
|
+
const spawnArgs = { agent: agent ?? `claude` };
|
|
1452
|
+
if (cwd) spawnArgs.cwd = cwd;
|
|
1242
1453
|
try {
|
|
1243
|
-
const
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
};
|
|
1252
|
-
await mkdir(dirname(resolved), { recursive: true });
|
|
1253
|
-
await writeFile(resolved, content, `utf-8`);
|
|
1254
|
-
readSet?.add(resolved);
|
|
1255
|
-
const bytesWritten = Buffer.byteLength(content, `utf-8`);
|
|
1454
|
+
const handle = await ctx.spawn(`coder`, id, spawnArgs, {
|
|
1455
|
+
initialMessage: { text: prompt },
|
|
1456
|
+
wake: {
|
|
1457
|
+
on: `runFinished`,
|
|
1458
|
+
includeResponse: true
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
const coderUrl = handle.entityUrl;
|
|
1256
1462
|
return {
|
|
1257
1463
|
content: [{
|
|
1258
1464
|
type: `text`,
|
|
1259
|
-
text: `
|
|
1465
|
+
text: `Coder dispatched at ${coderUrl}. End your turn — when the coder finishes its current reply you'll be woken with the response. To send follow-up prompts to the same coder, call prompt_coder with this URL.`
|
|
1260
1466
|
}],
|
|
1261
|
-
details: {
|
|
1467
|
+
details: {
|
|
1468
|
+
spawned: true,
|
|
1469
|
+
coderUrl
|
|
1470
|
+
}
|
|
1262
1471
|
};
|
|
1263
1472
|
} catch (err) {
|
|
1264
|
-
serverLog.warn(`[
|
|
1473
|
+
serverLog.warn(`[spawn_coder tool] failed to spawn coder ${id}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
1265
1474
|
return {
|
|
1266
1475
|
content: [{
|
|
1267
1476
|
type: `text`,
|
|
1268
|
-
text: `Error
|
|
1477
|
+
text: `Error spawning coder: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
1269
1478
|
}],
|
|
1270
|
-
details: {
|
|
1479
|
+
details: { spawned: false }
|
|
1271
1480
|
};
|
|
1272
1481
|
}
|
|
1273
1482
|
}
|
|
1274
1483
|
};
|
|
1275
1484
|
}
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
content: [{
|
|
1289
|
-
type: `text`,
|
|
1290
|
-
text: `Search failed: BRAVE_SEARCH_API_KEY not set`
|
|
1291
|
-
}],
|
|
1292
|
-
details: { resultCount: 0 }
|
|
1293
|
-
};
|
|
1294
|
-
const { query } = params;
|
|
1295
|
-
try {
|
|
1296
|
-
const url = `${BRAVE_API_URL}?q=${encodeURIComponent(query)}&count=5`;
|
|
1297
|
-
const res = await fetch(url, { headers: { "X-Subscription-Token": apiKey } });
|
|
1298
|
-
if (!res.ok) return {
|
|
1299
|
-
content: [{
|
|
1300
|
-
type: `text`,
|
|
1301
|
-
text: `Search failed: ${res.status} ${res.statusText}`
|
|
1302
|
-
}],
|
|
1303
|
-
details: { resultCount: 0 }
|
|
1304
|
-
};
|
|
1305
|
-
const data = await res.json();
|
|
1306
|
-
const results = data.web?.results ?? [];
|
|
1307
|
-
if (results.length === 0) return {
|
|
1308
|
-
content: [{
|
|
1309
|
-
type: `text`,
|
|
1310
|
-
text: `No results found for "${query}"`
|
|
1311
|
-
}],
|
|
1312
|
-
details: { resultCount: 0 }
|
|
1313
|
-
};
|
|
1314
|
-
const formatted = results.map((r, i) => `${i + 1}. **${r.title}**\n ${r.url}\n ${r.description}`).join(`\n\n`);
|
|
1315
|
-
return {
|
|
1485
|
+
function createPromptCoderTool(ctx) {
|
|
1486
|
+
return {
|
|
1487
|
+
name: `prompt_coder`,
|
|
1488
|
+
label: `Prompt Coder`,
|
|
1489
|
+
description: `Send a follow-up prompt to a coder you previously spawned. The prompt is queued on the coder's inbox and runs as the next CLI turn. End your turn after calling — you'll be woken when the coder's reply lands.`,
|
|
1490
|
+
parameters: Type.Object({
|
|
1491
|
+
coder_url: Type.String({ description: `Entity URL returned by spawn_coder, e.g. "/coder/abc123". Must be the URL of a coder you previously spawned in this conversation.` }),
|
|
1492
|
+
prompt: Type.String({ description: `Follow-up message to send to the coder. Treat this like the next turn in a chat — reference earlier context the coder already saw rather than restating it.` })
|
|
1493
|
+
}),
|
|
1494
|
+
execute: async (_toolCallId, params) => {
|
|
1495
|
+
const { coder_url, prompt } = params;
|
|
1496
|
+
if (typeof coder_url !== `string` || !coder_url.startsWith(`/coder/`)) return {
|
|
1316
1497
|
content: [{
|
|
1317
1498
|
type: `text`,
|
|
1318
|
-
text:
|
|
1499
|
+
text: `Error: coder_url must be a path like "/coder/<id>".`
|
|
1319
1500
|
}],
|
|
1320
|
-
details: {
|
|
1501
|
+
details: { sent: false }
|
|
1321
1502
|
};
|
|
1322
|
-
|
|
1323
|
-
return {
|
|
1503
|
+
if (typeof prompt !== `string` || prompt.length === 0) return {
|
|
1324
1504
|
content: [{
|
|
1325
1505
|
type: `text`,
|
|
1326
|
-
text: `
|
|
1506
|
+
text: `Error: prompt is required and must be a non-empty string.`
|
|
1327
1507
|
}],
|
|
1328
|
-
details: {
|
|
1508
|
+
details: { sent: false }
|
|
1329
1509
|
};
|
|
1510
|
+
try {
|
|
1511
|
+
ctx.send(coder_url, { text: prompt });
|
|
1512
|
+
return {
|
|
1513
|
+
content: [{
|
|
1514
|
+
type: `text`,
|
|
1515
|
+
text: `Prompt queued for ${coder_url}. End your turn — you'll be woken when the coder's reply lands.`
|
|
1516
|
+
}],
|
|
1517
|
+
details: {
|
|
1518
|
+
sent: true,
|
|
1519
|
+
coderUrl: coder_url
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
} catch (err) {
|
|
1523
|
+
serverLog.warn(`[prompt_coder tool] failed to send to ${coder_url}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
1524
|
+
return {
|
|
1525
|
+
content: [{
|
|
1526
|
+
type: `text`,
|
|
1527
|
+
text: `Error sending prompt to coder: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
1528
|
+
}],
|
|
1529
|
+
details: { sent: false }
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1330
1532
|
}
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1333
1535
|
|
|
1334
1536
|
//#endregion
|
|
1335
1537
|
//#region src/agents/horton.ts
|
|
1336
1538
|
const TITLE_MODEL = `claude-haiku-4-5-20251001`;
|
|
1337
|
-
const HORTON_MODEL = `claude-sonnet-4-
|
|
1539
|
+
const HORTON_MODEL = `claude-sonnet-4-6`;
|
|
1338
1540
|
let anthropic = null;
|
|
1339
1541
|
function getClient() {
|
|
1340
1542
|
if (!anthropic) anthropic = new Anthropic();
|
|
@@ -1437,10 +1639,10 @@ async function generateTitle(userMessage, llmCall = defaultHaikuCall) {
|
|
|
1437
1639
|
function buildHortonSystemPrompt(workingDirectory, opts = {}) {
|
|
1438
1640
|
const docsTools = opts.hasDocsSupport ? `\n- search_durable_agents_docs: hybrid search over the built-in Durable Agents docs index` : ``;
|
|
1439
1641
|
const skillsTools = opts.hasSkills ? `\n- use_skill: load a skill (knowledge, instructions, or a tutorial) into your context to help with the user's request\n- remove_skill: unload a skill from context when you're done with it` : ``;
|
|
1440
|
-
const docsGuidance = opts.hasDocsSupport ? `\n-
|
|
1642
|
+
const docsGuidance = opts.hasDocsSupport ? `\n- For ANY question about Electric Agents, Durable Agents, or this framework, ALWAYS use search_durable_agents_docs FIRST. Do not use brave_search or fetch_url for Electric Agents topics unless the docs search returns no useful results.\n- The search tool returns chunk content directly — you do not need to read the source files.\n- Use repo read/bash tools only for non-doc files or when you need to inspect exact implementation code in the workspace.` : ``;
|
|
1441
1643
|
const skillsGuidance = opts.hasSkills ? `\n# Skills\nYou have access to skills — specialized knowledge and guided workflows you can load on demand. Your context includes a skills catalog listing what's available. When the user's request matches a skill's description or keywords, load it with use_skill.
|
|
1442
1644
|
|
|
1443
|
-
Some skills are user-invocable — the user can trigger them with a slash command like \`/
|
|
1645
|
+
Some skills are user-invocable — the user can trigger them with a slash command like \`/quickstart\`. When you see a message starting with \`/\` followed by a skill name, load that skill immediately with use_skill. Pass any text after the skill name as args.
|
|
1444
1646
|
|
|
1445
1647
|
## IMPORTANT: How to use a loaded skill
|
|
1446
1648
|
|
|
@@ -1452,8 +1654,33 @@ When you load a skill, it becomes your primary directive for that interaction. F
|
|
|
1452
1654
|
4. **Unload when done.** Use remove_skill to free context space when the skill's workflow is complete.
|
|
1453
1655
|
|
|
1454
1656
|
Do NOT load a skill and then ignore its instructions. The skill is there because it contains a tested, specific workflow. Your job is to execute it faithfully.` : ``;
|
|
1657
|
+
const onboardingGuidance = `\n# Onboarding
|
|
1658
|
+
When a user is new or asks how to get started with Electric Agents, **don't assume a single path**. Present the options and let them choose:
|
|
1659
|
+
|
|
1660
|
+
- **Learn the concepts first** → Explain what Electric Agents is, answer questions, point to docs.
|
|
1661
|
+
Use search_durable_agents_docs to look up answers. Only load the quickstart skill if the user explicitly asks for a hands-on guided tutorial.
|
|
1662
|
+
|
|
1663
|
+
- **Hands-on guided tutorial** → Load the quickstart skill (or tell them to type \`/quickstart\`).
|
|
1664
|
+
This is a step-by-step build that takes them from zero to a running app.
|
|
1665
|
+
Only load it when the user explicitly wants to build something hands-on.
|
|
1666
|
+
|
|
1667
|
+
- **Scaffold a new project** → Load the init skill.
|
|
1668
|
+
This sets up project structure and orients them in the codebase.
|
|
1669
|
+
|
|
1670
|
+
- **Have a specific question?** → Answer it directly.
|
|
1671
|
+
Use search_durable_agents_docs first, then fall back to fetch_url or general knowledge if needed.
|
|
1672
|
+
|
|
1673
|
+
Don't force onboarding. If someone just wants to chat or code, let them. When in doubt, ask what they'd like to do rather than picking a path for them.`;
|
|
1674
|
+
const docsUrlGuidance = opts.docsUrl ? `\n# Electric Agents documentation
|
|
1675
|
+
- ${opts.hasDocsSupport ? `If search_durable_agents_docs is available, use it first (faster, hybrid search).` : `Use fetch_url to look up documentation pages.`}
|
|
1676
|
+
- The Electric Agents docs site is at ${opts.docsUrl}
|
|
1677
|
+
- The docs site covers: Usage (entity definition, handlers, tools, state, spawning, coordination, waking, shared state, client integration, app setup), Reference (handler context, entity definitions, configurations, tools, state proxies, wake events, registries), Entities (Horton, Worker), and Patterns (Manager-Worker, Pipeline, Map-Reduce, Dispatcher, Blackboard, Reactive Observers).
|
|
1678
|
+
- For general coding questions unrelated to Electric Agents, use brave_search or your own knowledge.` : ``;
|
|
1455
1679
|
return `You are Horton, a friendly and capable assistant. You can chat, research the web, read and edit code, run shell commands, and dispatch subagents (workers) for isolated subtasks. Be warm and engaging in conversation; be precise and concrete when working with code.
|
|
1456
1680
|
|
|
1681
|
+
# Greetings
|
|
1682
|
+
When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statement like "I want to learn about Electric Agents", respond warmly and introduce yourself. Briefly explain what you can help with and ask what they'd like to do — don't jump straight into a skill or workflow. Let the user tell you what they need before you start loading skills or running tools.
|
|
1683
|
+
|
|
1457
1684
|
# Tools
|
|
1458
1685
|
- bash: run shell commands
|
|
1459
1686
|
- read: read a file
|
|
@@ -1462,13 +1689,15 @@ Do NOT load a skill and then ignore its instructions. The skill is there because
|
|
|
1462
1689
|
- brave_search: search the web
|
|
1463
1690
|
- fetch_url: fetch and convert a URL to markdown
|
|
1464
1691
|
- spawn_worker: dispatch a subagent for an isolated task
|
|
1692
|
+
- spawn_coder: spawn a long-lived coding agent (Claude Code or Codex CLI) for code changes, file edits, debugging
|
|
1693
|
+
- prompt_coder: send a follow-up prompt to a coder you previously spawned
|
|
1465
1694
|
${docsTools}${skillsTools}
|
|
1466
1695
|
|
|
1467
1696
|
# Working with files
|
|
1468
1697
|
- Prefer edit over write when modifying existing files.
|
|
1469
1698
|
- You must read a file before you can edit it.
|
|
1470
1699
|
- Use absolute paths or paths relative to the current working directory.
|
|
1471
|
-
${docsGuidance}${skillsGuidance}
|
|
1700
|
+
${docsGuidance}${skillsGuidance}${onboardingGuidance}${docsUrlGuidance}
|
|
1472
1701
|
|
|
1473
1702
|
# Risky actions
|
|
1474
1703
|
Pause and confirm with the user before:
|
|
@@ -1489,6 +1718,13 @@ When you spawn a worker, write its system prompt the way you'd brief a colleague
|
|
|
1489
1718
|
|
|
1490
1719
|
After spawning, end your turn (optionally with a brief "I've dispatched a worker for X; I'll respond when it finishes"). When the worker finishes, you'll receive a message describing which worker completed and what it returned. Multiple workers may finish at different times — check the message for the worker URL to know which one you're hearing about.
|
|
1491
1720
|
|
|
1721
|
+
# When to spawn a coder
|
|
1722
|
+
Spawn a coder when the user asks for code changes, file edits, debugging, or any task that benefits from a real coding agent with full tool access (bash, file edits, etc.). A coder runs Claude Code or Codex CLI under the hood.
|
|
1723
|
+
|
|
1724
|
+
Unlike a worker, a coder is **long-lived**: its URL stays valid across many turns. Spawn once with spawn_coder, then keep prompting it via prompt_coder for follow-ups — don't spawn a new coder for each turn. Treat the coder URL like a chat handle.
|
|
1725
|
+
|
|
1726
|
+
After calling spawn_coder or prompt_coder, end your turn. When the coder's reply lands, you'll be woken with the response in the wake message — relay it (or a summary) back to the user, and call prompt_coder again if there's a follow-up.
|
|
1727
|
+
|
|
1492
1728
|
# Reporting
|
|
1493
1729
|
Report outcomes faithfully. If a command failed, say so with the relevant output. If you didn't run a verification step, say that rather than implying you did. Don't hedge confirmed results with unnecessary disclaimers.
|
|
1494
1730
|
|
|
@@ -1504,6 +1740,8 @@ function createHortonTools(workingDirectory, ctx, readSet, opts = {}) {
|
|
|
1504
1740
|
braveSearchTool,
|
|
1505
1741
|
fetchUrlTool,
|
|
1506
1742
|
createSpawnWorkerTool(ctx),
|
|
1743
|
+
createSpawnCoderTool(ctx),
|
|
1744
|
+
createPromptCoderTool(ctx),
|
|
1507
1745
|
...opts.docsSearchTool ? [opts.docsSearchTool] : []
|
|
1508
1746
|
];
|
|
1509
1747
|
}
|
|
@@ -1519,7 +1757,7 @@ function extractFirstUserMessage(events) {
|
|
|
1519
1757
|
return null;
|
|
1520
1758
|
}
|
|
1521
1759
|
function createAssistantHandler(options) {
|
|
1522
|
-
const { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry } = options;
|
|
1760
|
+
const { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry, docsUrl } = options;
|
|
1523
1761
|
const hasSkills = Boolean(skillsRegistry && skillsRegistry.catalog.size > 0);
|
|
1524
1762
|
return async function assistantHandler(ctx, wake) {
|
|
1525
1763
|
const readSet = new Set();
|
|
@@ -1569,7 +1807,8 @@ function createAssistantHandler(options) {
|
|
|
1569
1807
|
ctx.useAgent({
|
|
1570
1808
|
systemPrompt: buildHortonSystemPrompt(workingDirectory, {
|
|
1571
1809
|
hasDocsSupport: Boolean(docsSupport),
|
|
1572
|
-
hasSkills
|
|
1810
|
+
hasSkills,
|
|
1811
|
+
docsUrl
|
|
1573
1812
|
}),
|
|
1574
1813
|
model: HORTON_MODEL,
|
|
1575
1814
|
tools,
|
|
@@ -1597,6 +1836,9 @@ function createAssistantHandler(options) {
|
|
|
1597
1836
|
}
|
|
1598
1837
|
function registerHorton(registry, options) {
|
|
1599
1838
|
const { workingDirectory, streamFn, skillsRegistry = null } = options;
|
|
1839
|
+
const docsUrl = options.docsUrl ?? process.env.HORTON_DOCS_URL;
|
|
1840
|
+
if (process.env.BRAVE_SEARCH_API_KEY) serverLog.info(`[horton] Web search: using Brave Search API`);
|
|
1841
|
+
else serverLog.warn(`[horton] BRAVE_SEARCH_API_KEY not set — web search will fall back to Anthropic built-in search (uses your ANTHROPIC_API_KEY)`);
|
|
1600
1842
|
const docsSupport = createHortonDocsSupport(workingDirectory);
|
|
1601
1843
|
const docsSearchTool = docsSupport?.createSearchTool();
|
|
1602
1844
|
docsSupport?.ensureReady().catch((error) => {
|
|
@@ -1607,7 +1849,8 @@ function registerHorton(registry, options) {
|
|
|
1607
1849
|
streamFn,
|
|
1608
1850
|
docsSupport,
|
|
1609
1851
|
docsSearchTool,
|
|
1610
|
-
skillsRegistry
|
|
1852
|
+
skillsRegistry,
|
|
1853
|
+
docsUrl
|
|
1611
1854
|
});
|
|
1612
1855
|
registry.define(`horton`, {
|
|
1613
1856
|
description: `Friendly capable assistant — chat, code, research, dispatch`,
|
|
@@ -2093,6 +2336,8 @@ async function createBuiltinAgentHandler(options) {
|
|
|
2093
2336
|
streamFn
|
|
2094
2337
|
});
|
|
2095
2338
|
typeNames.push(`worker`);
|
|
2339
|
+
registerCodingSession(registry, { defaultWorkingDirectory: cwd });
|
|
2340
|
+
typeNames.push(`coder`);
|
|
2096
2341
|
const runtime = createRuntimeHandler({
|
|
2097
2342
|
baseUrl: agentServerUrl,
|
|
2098
2343
|
serveEndpoint,
|
|
@@ -2135,7 +2380,7 @@ var BuiltinAgentsServer = class {
|
|
|
2135
2380
|
}
|
|
2136
2381
|
async start() {
|
|
2137
2382
|
if (this.server) throw new Error(`Builtin agents server already started`);
|
|
2138
|
-
return new Promise((resolve
|
|
2383
|
+
return new Promise((resolve, reject) => {
|
|
2139
2384
|
this.server = createServer((req, res) => {
|
|
2140
2385
|
this.handleRequest(req, res).catch((error) => {
|
|
2141
2386
|
serverLog.error(`[builtin-agents] unhandled request error`, error);
|
|
@@ -2168,7 +2413,7 @@ var BuiltinAgentsServer = class {
|
|
|
2168
2413
|
if (!this.bootstrap) throw new Error(`ANTHROPIC_API_KEY must be set before starting builtin agents`);
|
|
2169
2414
|
await registerBuiltinAgentTypes(this.bootstrap);
|
|
2170
2415
|
serverLog.info(`[builtin-agents] webhook handler listening at ${serveEndpoint}`);
|
|
2171
|
-
resolve
|
|
2416
|
+
resolve(this._url);
|
|
2172
2417
|
} catch (error) {
|
|
2173
2418
|
await this.stop().catch(() => {});
|
|
2174
2419
|
reject(error);
|
|
@@ -2179,13 +2424,13 @@ var BuiltinAgentsServer = class {
|
|
|
2179
2424
|
async stop() {
|
|
2180
2425
|
if (this.bootstrap) {
|
|
2181
2426
|
this.bootstrap.runtime.abortWakes();
|
|
2182
|
-
await Promise.race([this.bootstrap.runtime.drainWakes().catch(() => {}), new Promise((resolve
|
|
2427
|
+
await Promise.race([this.bootstrap.runtime.drainWakes().catch(() => {}), new Promise((resolve) => setTimeout(resolve, 5e3))]);
|
|
2183
2428
|
this.bootstrap = null;
|
|
2184
2429
|
}
|
|
2185
2430
|
if (this.server) {
|
|
2186
2431
|
const server = this.server;
|
|
2187
|
-
await new Promise((resolve
|
|
2188
|
-
server.close(() => resolve
|
|
2432
|
+
await new Promise((resolve) => {
|
|
2433
|
+
server.close(() => resolve());
|
|
2189
2434
|
});
|
|
2190
2435
|
this.server = null;
|
|
2191
2436
|
}
|