@electric-ax/agents 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 +177 -0
- package/dist/entrypoint.js +1707 -0
- package/dist/index.cjs +1713 -0
- package/dist/index.d.cts +184 -0
- package/dist/index.d.ts +184 -0
- package/dist/index.js +1673 -0
- package/package.json +65 -0
|
@@ -0,0 +1,1707 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import path, { dirname, relative, resolve } from "node:path";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import pino from "pino";
|
|
6
|
+
import { createEntityRegistry, createRuntimeHandler } from "@electric-ax/agents-runtime";
|
|
7
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import fs$1, { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import Database from "better-sqlite3";
|
|
12
|
+
import { Type } from "@sinclair/typebox";
|
|
13
|
+
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
|
+
import { nanoid } from "nanoid";
|
|
20
|
+
|
|
21
|
+
//#region src/log.ts
|
|
22
|
+
const LOG_DIR = process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`);
|
|
23
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
24
|
+
const LOG_FILE = path.join(LOG_DIR, `builtin-agents-${Date.now()}.jsonl`);
|
|
25
|
+
const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
|
|
26
|
+
const USE_PRETTY_LOGS = LOG_LEVEL !== `silent` && !process.env.VITEST;
|
|
27
|
+
const streams = [{ stream: pino.destination(LOG_FILE) }];
|
|
28
|
+
if (USE_PRETTY_LOGS) streams.push({ stream: pino.transport({
|
|
29
|
+
target: `pino-pretty`,
|
|
30
|
+
options: {
|
|
31
|
+
colorize: true,
|
|
32
|
+
ignore: `pid,hostname,name`,
|
|
33
|
+
translateTime: `SYS:HH:MM:ss`
|
|
34
|
+
}
|
|
35
|
+
}) });
|
|
36
|
+
const logger = pino({
|
|
37
|
+
base: void 0,
|
|
38
|
+
level: LOG_LEVEL
|
|
39
|
+
}, pino.multistream(streams));
|
|
40
|
+
function formatArgs(args) {
|
|
41
|
+
const errors = [];
|
|
42
|
+
const parts = [];
|
|
43
|
+
for (const value of args) if (value instanceof Error) errors.push(value);
|
|
44
|
+
else parts.push(typeof value === `string` ? value : JSON.stringify(value));
|
|
45
|
+
return {
|
|
46
|
+
err: errors[0],
|
|
47
|
+
msg: parts.join(` `)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const serverLog = {
|
|
51
|
+
info(...args) {
|
|
52
|
+
const { msg } = formatArgs(args);
|
|
53
|
+
logger.info(msg);
|
|
54
|
+
},
|
|
55
|
+
warn(...args) {
|
|
56
|
+
const { err, msg } = formatArgs(args);
|
|
57
|
+
if (err) logger.warn({ err }, msg);
|
|
58
|
+
else logger.warn(msg);
|
|
59
|
+
},
|
|
60
|
+
error(...args) {
|
|
61
|
+
const { err, msg } = formatArgs(args);
|
|
62
|
+
if (err) logger.error({ err }, msg);
|
|
63
|
+
else logger.error(msg);
|
|
64
|
+
},
|
|
65
|
+
event(obj, msg) {
|
|
66
|
+
logger.info(obj, msg);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/docs/embed.ts
|
|
72
|
+
const EMBEDDING_DIMENSIONS = 128;
|
|
73
|
+
function tokenize(value) {
|
|
74
|
+
return value.toLowerCase().replace(/[^a-z0-9\s]/g, ` `).split(/\s+/).filter((token) => token.length > 0);
|
|
75
|
+
}
|
|
76
|
+
function hashToken(token) {
|
|
77
|
+
return createHash(`sha256`).update(token).digest();
|
|
78
|
+
}
|
|
79
|
+
function embedText(value) {
|
|
80
|
+
const vector = new Float32Array(EMBEDDING_DIMENSIONS);
|
|
81
|
+
const tokens = tokenize(value);
|
|
82
|
+
for (const token of tokens) {
|
|
83
|
+
const hash = hashToken(token);
|
|
84
|
+
for (let index = 0; index < 4; index++) {
|
|
85
|
+
const raw = hash.readUInt32BE(index * 4);
|
|
86
|
+
const dimension = raw % EMBEDDING_DIMENSIONS;
|
|
87
|
+
const sign = (hash[index + 16] & 1) === 0 ? 1 : -1;
|
|
88
|
+
vector[dimension] += sign;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
let norm = 0;
|
|
92
|
+
for (const value$1 of vector) norm += value$1 * value$1;
|
|
93
|
+
norm = Math.sqrt(norm);
|
|
94
|
+
if (norm > 0) for (let index = 0; index < vector.length; index++) vector[index] /= norm;
|
|
95
|
+
return vector;
|
|
96
|
+
}
|
|
97
|
+
function embeddingToSqlInput(embedding) {
|
|
98
|
+
return Buffer.from(embedding.buffer.slice(0));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/docs/knowledge-base.ts
|
|
103
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
104
|
+
const INDEX_VERSION = `1`;
|
|
105
|
+
const DOCS_FINGERPRINT_KEY = `docs_fingerprint`;
|
|
106
|
+
const INDEX_VERSION_KEY = `index_version`;
|
|
107
|
+
const DEFAULT_K = 8;
|
|
108
|
+
function parseFrontmatter(value) {
|
|
109
|
+
if (!value.startsWith(`---\n`)) return { body: value };
|
|
110
|
+
const end = value.indexOf(`\n---\n`, 4);
|
|
111
|
+
if (end === -1) return { body: value };
|
|
112
|
+
const frontmatter = value.slice(4, end);
|
|
113
|
+
const titleMatch = frontmatter.match(/^title:\s*(.+)$/m);
|
|
114
|
+
return {
|
|
115
|
+
title: titleMatch?.[1]?.trim().replace(/^['"]|['"]$/g, ``),
|
|
116
|
+
body: value.slice(end + 5)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function firstHeading(value) {
|
|
120
|
+
const match = value.match(/^#\s+(.+)$/m);
|
|
121
|
+
return match?.[1]?.trim();
|
|
122
|
+
}
|
|
123
|
+
function collectHeadings(value) {
|
|
124
|
+
const headings = [];
|
|
125
|
+
for (const line of value.split(`\n`)) {
|
|
126
|
+
const match = line.match(/^#{1,6}\s+(.+)$/);
|
|
127
|
+
if (match) headings.push(match[1].trim());
|
|
128
|
+
}
|
|
129
|
+
return headings;
|
|
130
|
+
}
|
|
131
|
+
function toDocTitle(relativePath, content) {
|
|
132
|
+
const parsed = parseFrontmatter(content);
|
|
133
|
+
return parsed.title ?? firstHeading(parsed.body) ?? relativePath.replace(/\.md$/, ``).split(`/`).at(-1) ?? relativePath;
|
|
134
|
+
}
|
|
135
|
+
function normalizeWhitespace(value) {
|
|
136
|
+
return value.replace(/\r\n/g, `\n`).replace(/\n{3,}/g, `\n\n`).trim();
|
|
137
|
+
}
|
|
138
|
+
async function collectMarkdownFiles(root) {
|
|
139
|
+
async function walk(dir) {
|
|
140
|
+
const entries = await fs$1.readdir(dir, { withFileTypes: true });
|
|
141
|
+
const files = [];
|
|
142
|
+
for (const entry of entries) {
|
|
143
|
+
const fullPath = path.join(dir, entry.name);
|
|
144
|
+
if (entry.isDirectory()) {
|
|
145
|
+
files.push(...await walk(fullPath));
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (entry.isFile() && entry.name.endsWith(`.md`)) files.push(fullPath);
|
|
149
|
+
}
|
|
150
|
+
return files;
|
|
151
|
+
}
|
|
152
|
+
return walk(root);
|
|
153
|
+
}
|
|
154
|
+
function chunkMarkdown(input) {
|
|
155
|
+
const parsed = parseFrontmatter(input.content);
|
|
156
|
+
const title = toDocTitle(input.relativePath, input.content);
|
|
157
|
+
const lines = parsed.body.split(`\n`);
|
|
158
|
+
const sections = [];
|
|
159
|
+
let currentHeading = title;
|
|
160
|
+
let currentLines = [];
|
|
161
|
+
function pushSection() {
|
|
162
|
+
const text = normalizeWhitespace(currentLines.join(`\n`));
|
|
163
|
+
if (!text) return;
|
|
164
|
+
sections.push({
|
|
165
|
+
heading: currentHeading,
|
|
166
|
+
text
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
171
|
+
if (headingMatch) {
|
|
172
|
+
pushSection();
|
|
173
|
+
currentHeading = headingMatch[2].trim();
|
|
174
|
+
currentLines = [line];
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
currentLines.push(line);
|
|
178
|
+
}
|
|
179
|
+
pushSection();
|
|
180
|
+
const chunks = [];
|
|
181
|
+
let chunkIndex = 0;
|
|
182
|
+
for (const section of sections) {
|
|
183
|
+
const paragraphs = section.text.split(/\n\s*\n/).map((paragraph) => normalizeWhitespace(paragraph)).filter(Boolean);
|
|
184
|
+
let current = ``;
|
|
185
|
+
for (const paragraph of paragraphs) {
|
|
186
|
+
const candidate = current ? `${current}\n\n${paragraph}` : paragraph;
|
|
187
|
+
if (candidate.length <= 1200) {
|
|
188
|
+
current = candidate;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (current) chunks.push({
|
|
192
|
+
docPath: input.relativePath,
|
|
193
|
+
title,
|
|
194
|
+
heading: section.heading,
|
|
195
|
+
chunkIndex: chunkIndex++,
|
|
196
|
+
content: current
|
|
197
|
+
});
|
|
198
|
+
if (paragraph.length <= 1200) {
|
|
199
|
+
current = paragraph;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const overlap = 150;
|
|
203
|
+
let start = 0;
|
|
204
|
+
while (start < paragraph.length) {
|
|
205
|
+
const end = Math.min(start + 1200, paragraph.length);
|
|
206
|
+
const slice = paragraph.slice(start, end).trim();
|
|
207
|
+
if (slice) chunks.push({
|
|
208
|
+
docPath: input.relativePath,
|
|
209
|
+
title,
|
|
210
|
+
heading: section.heading,
|
|
211
|
+
chunkIndex: chunkIndex++,
|
|
212
|
+
content: slice
|
|
213
|
+
});
|
|
214
|
+
if (end >= paragraph.length) {
|
|
215
|
+
current = ``;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
start = Math.max(end - overlap, start + 1);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (current) chunks.push({
|
|
222
|
+
docPath: input.relativePath,
|
|
223
|
+
title,
|
|
224
|
+
heading: section.heading,
|
|
225
|
+
chunkIndex: chunkIndex++,
|
|
226
|
+
content: current
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return chunks;
|
|
230
|
+
}
|
|
231
|
+
function createFingerprint(entries) {
|
|
232
|
+
const hash = createHash(`sha256`);
|
|
233
|
+
for (const entry of entries) {
|
|
234
|
+
hash.update(entry.path);
|
|
235
|
+
hash.update(`\0`);
|
|
236
|
+
hash.update(entry.content);
|
|
237
|
+
hash.update(`\0`);
|
|
238
|
+
}
|
|
239
|
+
return hash.digest(`hex`);
|
|
240
|
+
}
|
|
241
|
+
function getMeta(db, key) {
|
|
242
|
+
const row = db.prepare(`select value from index_meta where key = ?`).get(key);
|
|
243
|
+
return row?.value ?? null;
|
|
244
|
+
}
|
|
245
|
+
function setMeta(db, key, value) {
|
|
246
|
+
db.prepare(`insert into index_meta(key, value) values(?, ?)
|
|
247
|
+
on conflict(key) do update set value = excluded.value`).run(key, value);
|
|
248
|
+
}
|
|
249
|
+
function reciprocalRank(rank, k = 60) {
|
|
250
|
+
return rank === null ? 0 : 1 / (k + rank);
|
|
251
|
+
}
|
|
252
|
+
function sanitizeSnippet(value) {
|
|
253
|
+
return value.replace(/\s+/g, ` `).trim();
|
|
254
|
+
}
|
|
255
|
+
function vectorQuerySql() {
|
|
256
|
+
return `
|
|
257
|
+
select
|
|
258
|
+
id,
|
|
259
|
+
doc_path as docPath,
|
|
260
|
+
title,
|
|
261
|
+
heading,
|
|
262
|
+
content
|
|
263
|
+
from chunks
|
|
264
|
+
order by vec_distance_cosine(embedding, vec_f32(?)) asc
|
|
265
|
+
limit ?
|
|
266
|
+
`;
|
|
267
|
+
}
|
|
268
|
+
function payloadToText(payload) {
|
|
269
|
+
if (typeof payload === `string`) return payload;
|
|
270
|
+
if (payload && typeof payload === `object`) {
|
|
271
|
+
const text = payload.text;
|
|
272
|
+
if (typeof text === `string`) return text;
|
|
273
|
+
return JSON.stringify(payload);
|
|
274
|
+
}
|
|
275
|
+
return String(payload ?? ``);
|
|
276
|
+
}
|
|
277
|
+
function findLatestQuestion(items) {
|
|
278
|
+
for (let index = items.length - 1; index >= 0; index--) {
|
|
279
|
+
const text = payloadToText(items[index]?.payload).trim();
|
|
280
|
+
if (text.length > 0) return text;
|
|
281
|
+
}
|
|
282
|
+
return void 0;
|
|
283
|
+
}
|
|
284
|
+
function resolveDocsRoot(workingDirectory) {
|
|
285
|
+
const candidates = [
|
|
286
|
+
process.env.HORTON_DOCS_ROOT,
|
|
287
|
+
path.resolve(workingDirectory, `electric-agents-docs/docs`),
|
|
288
|
+
path.resolve(process.cwd(), `electric-agents-docs/docs`),
|
|
289
|
+
path.resolve(MODULE_DIR, `../../../../../electric-agents-docs/docs`)
|
|
290
|
+
].filter((value) => typeof value === `string`);
|
|
291
|
+
for (const candidate of candidates) if (fs.existsSync(candidate)) return candidate;
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
var DocsKnowledgeBase = class {
|
|
295
|
+
db;
|
|
296
|
+
docsRoot;
|
|
297
|
+
dbPath;
|
|
298
|
+
logPrefix;
|
|
299
|
+
fallbackDocs = [];
|
|
300
|
+
fallbackChunks = [];
|
|
301
|
+
fallbackFingerprint = ``;
|
|
302
|
+
readyPromise;
|
|
303
|
+
constructor(options) {
|
|
304
|
+
this.docsRoot = options.docsRoot;
|
|
305
|
+
this.dbPath = options.dbPath;
|
|
306
|
+
this.logPrefix = options.logPrefix ?? `[horton-docs]`;
|
|
307
|
+
this.db = this.openDatabase();
|
|
308
|
+
this.createSchema();
|
|
309
|
+
this.readyPromise = this.ensureIngested();
|
|
310
|
+
}
|
|
311
|
+
openDatabase() {
|
|
312
|
+
fs.mkdirSync(path.dirname(this.dbPath), { recursive: true });
|
|
313
|
+
try {
|
|
314
|
+
const db = new Database(this.dbPath);
|
|
315
|
+
load(db);
|
|
316
|
+
db.pragma(`journal_mode = WAL`);
|
|
317
|
+
db.pragma(`synchronous = NORMAL`);
|
|
318
|
+
return db;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
321
|
+
console.warn(`${this.logPrefix} falling back to in-memory docs index: ${message}`);
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
createSchema() {
|
|
326
|
+
if (!this.db) return;
|
|
327
|
+
this.db.exec(`
|
|
328
|
+
create table if not exists index_meta (
|
|
329
|
+
key text primary key,
|
|
330
|
+
value text not null
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
create table if not exists docs (
|
|
334
|
+
path text primary key,
|
|
335
|
+
title text not null,
|
|
336
|
+
content text not null
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
create table if not exists chunks (
|
|
340
|
+
id integer primary key,
|
|
341
|
+
doc_path text not null,
|
|
342
|
+
title text not null,
|
|
343
|
+
heading text not null,
|
|
344
|
+
chunk_index integer not null,
|
|
345
|
+
content text not null,
|
|
346
|
+
embedding blob not null check(vec_length(embedding) = ${EMBEDDING_DIMENSIONS}),
|
|
347
|
+
foreign key (doc_path) references docs(path) on delete cascade
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
create index if not exists chunks_doc_path_idx on chunks(doc_path);
|
|
351
|
+
|
|
352
|
+
create virtual table if not exists chunks_fts using fts5(
|
|
353
|
+
doc_path,
|
|
354
|
+
title,
|
|
355
|
+
heading,
|
|
356
|
+
content,
|
|
357
|
+
tokenize = 'porter unicode61'
|
|
358
|
+
);
|
|
359
|
+
`);
|
|
360
|
+
}
|
|
361
|
+
async ensureReady() {
|
|
362
|
+
await this.readyPromise;
|
|
363
|
+
}
|
|
364
|
+
stats() {
|
|
365
|
+
if (!this.db) return {
|
|
366
|
+
docCount: this.fallbackDocs.length,
|
|
367
|
+
chunkCount: this.fallbackChunks.length,
|
|
368
|
+
fingerprint: this.fallbackFingerprint
|
|
369
|
+
};
|
|
370
|
+
const docCount = Number(this.db.prepare(`select count(*) as count from docs`).get().count);
|
|
371
|
+
const chunkCount = Number(this.db.prepare(`select count(*) as count from chunks`).get().count);
|
|
372
|
+
return {
|
|
373
|
+
docCount,
|
|
374
|
+
chunkCount,
|
|
375
|
+
fingerprint: getMeta(this.db, DOCS_FINGERPRINT_KEY) ?? ``
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
async ensureIngested() {
|
|
379
|
+
await fs$1.mkdir(path.dirname(this.dbPath), { recursive: true });
|
|
380
|
+
const files = (await collectMarkdownFiles(this.docsRoot)).sort();
|
|
381
|
+
const docs = await Promise.all(files.map(async (filePath) => ({
|
|
382
|
+
path: path.relative(this.docsRoot, filePath),
|
|
383
|
+
content: await fs$1.readFile(filePath, `utf8`)
|
|
384
|
+
})));
|
|
385
|
+
const fingerprint = createFingerprint(docs);
|
|
386
|
+
if (!this.db) {
|
|
387
|
+
if (this.fallbackFingerprint === fingerprint && this.fallbackChunks.length > 0) return this.stats();
|
|
388
|
+
this.fallbackDocs = docs.map((doc) => ({
|
|
389
|
+
path: doc.path,
|
|
390
|
+
title: toDocTitle(doc.path, doc.content),
|
|
391
|
+
content: doc.content
|
|
392
|
+
}));
|
|
393
|
+
this.fallbackChunks = [];
|
|
394
|
+
let nextId = 1;
|
|
395
|
+
for (const doc of docs) for (const chunk of chunkMarkdown({
|
|
396
|
+
relativePath: doc.path,
|
|
397
|
+
content: doc.content
|
|
398
|
+
})) {
|
|
399
|
+
const searchableText = `${chunk.title}\n${chunk.heading}\n${chunk.content}`;
|
|
400
|
+
this.fallbackChunks.push({
|
|
401
|
+
...chunk,
|
|
402
|
+
id: nextId++,
|
|
403
|
+
embedding: embedText(searchableText)
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
this.fallbackFingerprint = fingerprint;
|
|
407
|
+
const stats$1 = this.stats();
|
|
408
|
+
console.log(`${this.logPrefix} indexed ${stats$1.docCount} docs into ${stats$1.chunkCount} chunks (${stats$1.fingerprint.slice(0, 12)}...)`);
|
|
409
|
+
return stats$1;
|
|
410
|
+
}
|
|
411
|
+
const db = this.db;
|
|
412
|
+
const currentFingerprint = getMeta(db, DOCS_FINGERPRINT_KEY);
|
|
413
|
+
const currentVersion = getMeta(db, INDEX_VERSION_KEY);
|
|
414
|
+
if (currentFingerprint === fingerprint && currentVersion === INDEX_VERSION && this.stats().chunkCount > 0) return this.stats();
|
|
415
|
+
const insertDoc = db.prepare(`insert into docs(path, title, content) values(?, ?, ?)`);
|
|
416
|
+
const insertChunk = db.prepare(`insert into chunks(doc_path, title, heading, chunk_index, content, embedding)
|
|
417
|
+
values(?, ?, ?, ?, ?, vec_f32(?))`);
|
|
418
|
+
const insertFts = db.prepare(`insert into chunks_fts(rowid, doc_path, title, heading, content)
|
|
419
|
+
values(?, ?, ?, ?, ?)`);
|
|
420
|
+
const reset = db.transaction(() => {
|
|
421
|
+
db.exec(`
|
|
422
|
+
delete from chunks_fts;
|
|
423
|
+
delete from chunks;
|
|
424
|
+
delete from docs;
|
|
425
|
+
`);
|
|
426
|
+
for (const doc of docs) {
|
|
427
|
+
const title = toDocTitle(doc.path, doc.content);
|
|
428
|
+
insertDoc.run(doc.path, title, doc.content);
|
|
429
|
+
const chunks = chunkMarkdown({
|
|
430
|
+
relativePath: doc.path,
|
|
431
|
+
content: doc.content
|
|
432
|
+
});
|
|
433
|
+
for (const chunk of chunks) {
|
|
434
|
+
const searchableText = `${chunk.title}\n${chunk.heading}\n${chunk.content}`;
|
|
435
|
+
const embedding = embeddingToSqlInput(embedText(searchableText));
|
|
436
|
+
const result = insertChunk.run(chunk.docPath, chunk.title, chunk.heading, chunk.chunkIndex, chunk.content, embedding);
|
|
437
|
+
insertFts.run(Number(result.lastInsertRowid), chunk.docPath, chunk.title, chunk.heading, chunk.content);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
setMeta(db, DOCS_FINGERPRINT_KEY, fingerprint);
|
|
441
|
+
setMeta(db, INDEX_VERSION_KEY, INDEX_VERSION);
|
|
442
|
+
});
|
|
443
|
+
reset();
|
|
444
|
+
const stats = this.stats();
|
|
445
|
+
console.log(`${this.logPrefix} indexed ${stats.docCount} docs into ${stats.chunkCount} chunks (${stats.fingerprint.slice(0, 12)}...)`);
|
|
446
|
+
return stats;
|
|
447
|
+
}
|
|
448
|
+
hybridSearch(query, limit = DEFAULT_K) {
|
|
449
|
+
const cleanedQuery = query.trim();
|
|
450
|
+
if (!cleanedQuery) return [];
|
|
451
|
+
if (!this.db) {
|
|
452
|
+
const queryEmbedding = embedText(cleanedQuery);
|
|
453
|
+
const vectorMatches$1 = [...this.fallbackChunks].map((chunk) => ({
|
|
454
|
+
...chunk,
|
|
455
|
+
vectorScore: dotProduct(queryEmbedding, chunk.embedding)
|
|
456
|
+
})).sort((left, right) => right.vectorScore - left.vectorScore).slice(0, limit * 3);
|
|
457
|
+
const tokens$1 = cleanedQuery.toLowerCase().replace(/[^a-z0-9\s]/g, ` `).split(/\s+/).filter((token) => token.length >= 2);
|
|
458
|
+
const bm25Matches$1 = tokens$1.length > 0 ? [...this.fallbackChunks].map((chunk) => ({
|
|
459
|
+
...chunk,
|
|
460
|
+
bm25Score: keywordScore(`${chunk.title}\n${chunk.heading}\n${chunk.content}`, tokens$1)
|
|
461
|
+
})).filter((chunk) => chunk.bm25Score > 0).sort((left, right) => right.bm25Score - left.bm25Score).slice(0, limit * 3) : [];
|
|
462
|
+
const merged$1 = new Map();
|
|
463
|
+
for (const [index, row] of bm25Matches$1.entries()) {
|
|
464
|
+
const existing = merged$1.get(row.id);
|
|
465
|
+
merged$1.set(row.id, {
|
|
466
|
+
id: row.id,
|
|
467
|
+
docPath: row.docPath,
|
|
468
|
+
title: row.title,
|
|
469
|
+
heading: row.heading,
|
|
470
|
+
content: row.content,
|
|
471
|
+
hybridScore: reciprocalRank(index + 1) + reciprocalRank(existing?.vectorRank ?? null),
|
|
472
|
+
bm25Rank: index + 1,
|
|
473
|
+
vectorRank: existing?.vectorRank ?? null
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
for (const [index, row] of vectorMatches$1.entries()) {
|
|
477
|
+
const existing = merged$1.get(row.id);
|
|
478
|
+
merged$1.set(row.id, {
|
|
479
|
+
id: row.id,
|
|
480
|
+
docPath: row.docPath,
|
|
481
|
+
title: row.title,
|
|
482
|
+
heading: row.heading,
|
|
483
|
+
content: row.content,
|
|
484
|
+
hybridScore: reciprocalRank(existing?.bm25Rank ?? null) + reciprocalRank(index + 1),
|
|
485
|
+
bm25Rank: existing?.bm25Rank ?? null,
|
|
486
|
+
vectorRank: index + 1
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
return [...merged$1.values()].sort((left, right) => right.hybridScore - left.hybridScore).slice(0, limit);
|
|
490
|
+
}
|
|
491
|
+
const vectorMatches = this.db.prepare(vectorQuerySql()).all(embeddingToSqlInput(embedText(cleanedQuery)), limit * 3);
|
|
492
|
+
const tokens = cleanedQuery.toLowerCase().replace(/[^a-z0-9\s]/g, ` `).split(/\s+/).filter((token) => token.length >= 2);
|
|
493
|
+
const ftsQuery = tokens.length > 0 ? tokens.map((token) => `"${token}"`).join(` OR `) : null;
|
|
494
|
+
const bm25Matches = ftsQuery ? this.db.prepare(`
|
|
495
|
+
select
|
|
496
|
+
c.id as id,
|
|
497
|
+
c.doc_path as docPath,
|
|
498
|
+
c.title as title,
|
|
499
|
+
c.heading as heading,
|
|
500
|
+
c.content as content,
|
|
501
|
+
bm25(chunks_fts) as bm25Score
|
|
502
|
+
from chunks_fts
|
|
503
|
+
join chunks c on c.id = chunks_fts.rowid
|
|
504
|
+
where chunks_fts match ?
|
|
505
|
+
order by bm25Score
|
|
506
|
+
limit ?
|
|
507
|
+
`).all(ftsQuery, limit * 3) : [];
|
|
508
|
+
const merged = new Map();
|
|
509
|
+
for (const [index, row] of bm25Matches.entries()) {
|
|
510
|
+
const existing = merged.get(row.id);
|
|
511
|
+
merged.set(row.id, {
|
|
512
|
+
id: row.id,
|
|
513
|
+
docPath: row.docPath,
|
|
514
|
+
title: row.title,
|
|
515
|
+
heading: row.heading,
|
|
516
|
+
content: row.content,
|
|
517
|
+
hybridScore: reciprocalRank(index + 1) + reciprocalRank(existing?.vectorRank ?? null),
|
|
518
|
+
bm25Rank: index + 1,
|
|
519
|
+
vectorRank: existing?.vectorRank ?? null
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
for (const [index, row] of vectorMatches.entries()) {
|
|
523
|
+
const existing = merged.get(row.id);
|
|
524
|
+
merged.set(row.id, {
|
|
525
|
+
id: row.id,
|
|
526
|
+
docPath: row.docPath,
|
|
527
|
+
title: row.title,
|
|
528
|
+
heading: row.heading,
|
|
529
|
+
content: row.content,
|
|
530
|
+
hybridScore: reciprocalRank(existing?.bm25Rank ?? null) + reciprocalRank(index + 1),
|
|
531
|
+
bm25Rank: existing?.bm25Rank ?? null,
|
|
532
|
+
vectorRank: index + 1
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
return [...merged.values()].sort((left, right) => right.hybridScore - left.hybridScore).slice(0, limit);
|
|
536
|
+
}
|
|
537
|
+
outline() {
|
|
538
|
+
if (!this.db) return this.fallbackDocs.map((doc) => ({
|
|
539
|
+
path: doc.path,
|
|
540
|
+
title: doc.title,
|
|
541
|
+
headings: collectHeadings(parseFrontmatter(doc.content).body)
|
|
542
|
+
})).sort((left, right) => left.path.localeCompare(right.path));
|
|
543
|
+
const docs = this.db.prepare(`select path, title, content from docs order by path`).all();
|
|
544
|
+
return docs.map((doc) => ({
|
|
545
|
+
path: doc.path,
|
|
546
|
+
title: doc.title,
|
|
547
|
+
headings: collectHeadings(parseFrontmatter(doc.content).body)
|
|
548
|
+
}));
|
|
549
|
+
}
|
|
550
|
+
renderCompressedToc(maxHeadingsPerDoc = 4) {
|
|
551
|
+
const lines = [`<docs_toc>`];
|
|
552
|
+
for (const doc of this.outline()) {
|
|
553
|
+
const headings = doc.headings.slice(0, maxHeadingsPerDoc).map((heading) => heading.replace(/"/g, `"`));
|
|
554
|
+
const attrs = [`path="${path.resolve(this.docsRoot, doc.path).replace(/"/g, `"`)}"`, `title="${doc.title.replace(/"/g, `"`)}"`];
|
|
555
|
+
if (headings.length > 0) attrs.push(`headings="${headings.join(` | `)}"`);
|
|
556
|
+
if (doc.headings.length > maxHeadingsPerDoc) attrs.push(`more_headings="${doc.headings.length - maxHeadingsPerDoc}"`);
|
|
557
|
+
lines.push(`<doc ${attrs.join(` `)} />`);
|
|
558
|
+
}
|
|
559
|
+
lines.push(`</docs_toc>`);
|
|
560
|
+
return lines.join(`\n`);
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
function dotProduct(left, right) {
|
|
564
|
+
let sum = 0;
|
|
565
|
+
for (let index = 0; index < left.length; index++) sum += (left[index] ?? 0) * (right[index] ?? 0);
|
|
566
|
+
return sum;
|
|
567
|
+
}
|
|
568
|
+
function keywordScore(haystack, tokens) {
|
|
569
|
+
const normalized = haystack.toLowerCase();
|
|
570
|
+
let score = 0;
|
|
571
|
+
for (const token of tokens) if (normalized.includes(token)) score += 1;
|
|
572
|
+
return score;
|
|
573
|
+
}
|
|
574
|
+
function renderSearchResults(query, results, docsRoot) {
|
|
575
|
+
if (results.length === 0) return `<docs_search query="${query.replace(/"/g, `"`)}"><no_results /></docs_search>`;
|
|
576
|
+
const lines = [`<docs_search query="${query.replace(/"/g, `"`)}">`];
|
|
577
|
+
for (const [index, result] of results.entries()) {
|
|
578
|
+
const renderedPath = docsRoot ? path.resolve(docsRoot, result.docPath) : result.docPath;
|
|
579
|
+
lines.push(`<chunk rank="${index + 1}" path="${renderedPath.replace(/"/g, `"`)}" title="${result.title.replace(/"/g, `"`)}" heading="${result.heading.replace(/"/g, `"`)}" chunk_id="${result.id}" hybrid_score="${result.hybridScore.toFixed(5)}" bm25_rank="${result.bm25Rank ?? ``}" vector_rank="${result.vectorRank ?? ``}">`, sanitizeSnippet(result.content), `</chunk>`);
|
|
580
|
+
}
|
|
581
|
+
lines.push(`</docs_search>`);
|
|
582
|
+
return lines.join(`\n`);
|
|
583
|
+
}
|
|
584
|
+
function logSearchResults(kind, query, output) {
|
|
585
|
+
console.log(`[horton-docs] ${kind} search for "${query}"\n${output}\n`);
|
|
586
|
+
}
|
|
587
|
+
function createHortonDocsSupport(workingDirectory, opts = {}) {
|
|
588
|
+
const docsRoot = opts.docsRoot ?? resolveDocsRoot(workingDirectory);
|
|
589
|
+
if (!docsRoot) return null;
|
|
590
|
+
const dbPath = opts.dbPath ?? path.resolve(workingDirectory, `.electric-agents/horton-docs.sqlite`);
|
|
591
|
+
const kb = new DocsKnowledgeBase({
|
|
592
|
+
docsRoot,
|
|
593
|
+
dbPath,
|
|
594
|
+
logPrefix: `[horton-docs]`
|
|
595
|
+
});
|
|
596
|
+
function resolveCurrentQuestion(wake, events, inbox) {
|
|
597
|
+
if (wake.type === `message_received`) {
|
|
598
|
+
const eventQuestion = findLatestQuestion(events.filter((event) => event.type === `message_received`).map((event) => event.value));
|
|
599
|
+
if (eventQuestion) return eventQuestion;
|
|
600
|
+
}
|
|
601
|
+
const wakeQuestion = payloadToText(wake.payload).trim();
|
|
602
|
+
if (wakeQuestion.length > 0) return wakeQuestion;
|
|
603
|
+
return findLatestQuestion(inbox) ?? ``;
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
async ensureReady() {
|
|
607
|
+
await kb.ensureReady();
|
|
608
|
+
},
|
|
609
|
+
resolveCurrentQuestion,
|
|
610
|
+
async renderRetrievedDocsSource(wake, events, inbox) {
|
|
611
|
+
await kb.ensureReady();
|
|
612
|
+
const question = resolveCurrentQuestion(wake, events, inbox);
|
|
613
|
+
if (!question) return `<docs_search><no_query /></docs_search>`;
|
|
614
|
+
const rendered = renderSearchResults(question, kb.hybridSearch(question, 6), kb.docsRoot);
|
|
615
|
+
logSearchResults(`initial`, question, rendered);
|
|
616
|
+
return rendered;
|
|
617
|
+
},
|
|
618
|
+
async renderCompressedToc() {
|
|
619
|
+
await kb.ensureReady();
|
|
620
|
+
return kb.renderCompressedToc();
|
|
621
|
+
},
|
|
622
|
+
createSearchTool() {
|
|
623
|
+
return {
|
|
624
|
+
name: `search_durable_agents_docs`,
|
|
625
|
+
label: `Search Durable Agents Docs`,
|
|
626
|
+
description: `Run a hybrid BM25 plus vector search over the local Durable Agents documentation index.`,
|
|
627
|
+
parameters: Type.Object({
|
|
628
|
+
query: Type.String({ description: `The docs question or search query to run.` }),
|
|
629
|
+
limit: Type.Optional(Type.Number({
|
|
630
|
+
minimum: 1,
|
|
631
|
+
maximum: 12,
|
|
632
|
+
description: `Maximum number of chunks to return.`
|
|
633
|
+
}))
|
|
634
|
+
}),
|
|
635
|
+
execute: async (_toolCallId, params) => {
|
|
636
|
+
await kb.ensureReady();
|
|
637
|
+
const query = String(params.query ?? ``).trim();
|
|
638
|
+
const limit = Number(params.limit ?? 6);
|
|
639
|
+
const results = kb.hybridSearch(query, Math.min(Math.max(limit, 1), 12));
|
|
640
|
+
const rendered = renderSearchResults(query, results, kb.docsRoot);
|
|
641
|
+
logSearchResults(`tool`, query, rendered);
|
|
642
|
+
return {
|
|
643
|
+
content: [{
|
|
644
|
+
type: `text`,
|
|
645
|
+
text: rendered
|
|
646
|
+
}],
|
|
647
|
+
details: {
|
|
648
|
+
query,
|
|
649
|
+
resultCount: results.length,
|
|
650
|
+
results: results.map((result) => ({
|
|
651
|
+
id: result.id,
|
|
652
|
+
docPath: result.docPath,
|
|
653
|
+
heading: result.heading,
|
|
654
|
+
hybridScore: result.hybridScore
|
|
655
|
+
}))
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
//#endregion
|
|
665
|
+
//#region src/tools/bash.ts
|
|
666
|
+
const TIMEOUT_MS = 3e4;
|
|
667
|
+
const MAX_OUTPUT_CHARS = 5e4;
|
|
668
|
+
function createBashTool(workingDirectory) {
|
|
669
|
+
return {
|
|
670
|
+
name: `bash`,
|
|
671
|
+
label: `Bash`,
|
|
672
|
+
description: `Execute a shell command and return its output. Commands run in a sandboxed working directory with a 30-second timeout.`,
|
|
673
|
+
parameters: Type.Object({ command: Type.String({ description: `The shell command to execute` }) }),
|
|
674
|
+
execute: async (_toolCallId, params) => {
|
|
675
|
+
const { command } = params;
|
|
676
|
+
return new Promise((resolve$1) => {
|
|
677
|
+
const child = exec(command, {
|
|
678
|
+
cwd: workingDirectory,
|
|
679
|
+
timeout: TIMEOUT_MS,
|
|
680
|
+
maxBuffer: 1024 * 1024,
|
|
681
|
+
env: {
|
|
682
|
+
...process.env,
|
|
683
|
+
HOME: workingDirectory
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
let stdout = ``;
|
|
687
|
+
let stderr = ``;
|
|
688
|
+
child.stdout?.on(`data`, (data) => {
|
|
689
|
+
stdout += data;
|
|
690
|
+
});
|
|
691
|
+
child.stderr?.on(`data`, (data) => {
|
|
692
|
+
stderr += data;
|
|
693
|
+
});
|
|
694
|
+
child.on(`close`, (code, signal) => {
|
|
695
|
+
const timedOut = signal === `SIGTERM`;
|
|
696
|
+
let output = stdout;
|
|
697
|
+
if (stderr) output += output ? `\n\nSTDERR:\n${stderr}` : stderr;
|
|
698
|
+
if (timedOut) output += `\n\n[Command timed out after ${TIMEOUT_MS / 1e3}s]`;
|
|
699
|
+
output = output.slice(0, MAX_OUTPUT_CHARS);
|
|
700
|
+
resolve$1({
|
|
701
|
+
content: [{
|
|
702
|
+
type: `text`,
|
|
703
|
+
text: output || `(no output)`
|
|
704
|
+
}],
|
|
705
|
+
details: {
|
|
706
|
+
exitCode: code ?? 1,
|
|
707
|
+
timedOut
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
child.on(`error`, (err) => {
|
|
712
|
+
resolve$1({
|
|
713
|
+
content: [{
|
|
714
|
+
type: `text`,
|
|
715
|
+
text: `Command failed: ${err.message}`
|
|
716
|
+
}],
|
|
717
|
+
details: {
|
|
718
|
+
exitCode: 1,
|
|
719
|
+
timedOut: false
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
//#endregion
|
|
729
|
+
//#region src/tools/edit.ts
|
|
730
|
+
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).`;
|
|
731
|
+
function createEditTool(workingDirectory, readSet) {
|
|
732
|
+
return {
|
|
733
|
+
name: `edit`,
|
|
734
|
+
label: `Edit File`,
|
|
735
|
+
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.`,
|
|
736
|
+
parameters: Type.Object({
|
|
737
|
+
path: Type.String({ description: `File path (relative to working directory)` }),
|
|
738
|
+
old_string: Type.String({ description: `The literal text to find. Must be unique unless replace_all is true.` }),
|
|
739
|
+
new_string: Type.String({ description: `The replacement text.` }),
|
|
740
|
+
replace_all: Type.Optional(Type.Boolean({ description: `Replace every occurrence (default false).` }))
|
|
741
|
+
}),
|
|
742
|
+
execute: async (_toolCallId, params) => {
|
|
743
|
+
const { path: filePath, old_string, new_string, replace_all } = params;
|
|
744
|
+
try {
|
|
745
|
+
const resolved = resolve(workingDirectory, filePath);
|
|
746
|
+
const rel = relative(workingDirectory, resolved);
|
|
747
|
+
if (rel.startsWith(`..`)) return {
|
|
748
|
+
content: [{
|
|
749
|
+
type: `text`,
|
|
750
|
+
text: `Error: Path "${filePath}" is outside the working directory`
|
|
751
|
+
}],
|
|
752
|
+
details: { replacements: 0 }
|
|
753
|
+
};
|
|
754
|
+
if (!readSet.has(resolved)) return {
|
|
755
|
+
content: [{
|
|
756
|
+
type: `text`,
|
|
757
|
+
text: READ_GUARD_MESSAGE(rel)
|
|
758
|
+
}],
|
|
759
|
+
details: { replacements: 0 }
|
|
760
|
+
};
|
|
761
|
+
const original = await readFile(resolved, `utf-8`);
|
|
762
|
+
if (!replace_all) {
|
|
763
|
+
const first = original.indexOf(old_string);
|
|
764
|
+
if (first === -1) return {
|
|
765
|
+
content: [{
|
|
766
|
+
type: `text`,
|
|
767
|
+
text: `Error: old_string not found in ${rel}`
|
|
768
|
+
}],
|
|
769
|
+
details: { replacements: 0 }
|
|
770
|
+
};
|
|
771
|
+
const second = original.indexOf(old_string, first + 1);
|
|
772
|
+
if (second !== -1) {
|
|
773
|
+
const matches = original.split(old_string).length - 1;
|
|
774
|
+
return {
|
|
775
|
+
content: [{
|
|
776
|
+
type: `text`,
|
|
777
|
+
text: `Error: found ${matches} matches for old_string in ${rel}; pass replace_all=true to replace all, or provide a more specific old_string.`
|
|
778
|
+
}],
|
|
779
|
+
details: { replacements: 0 }
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
const updated = original.slice(0, first) + new_string + original.slice(first + old_string.length);
|
|
783
|
+
await writeFile(resolved, updated, `utf-8`);
|
|
784
|
+
return {
|
|
785
|
+
content: [{
|
|
786
|
+
type: `text`,
|
|
787
|
+
text: `Edited ${rel}: 1 replacement`
|
|
788
|
+
}],
|
|
789
|
+
details: { replacements: 1 }
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
const parts = original.split(old_string);
|
|
793
|
+
const count = parts.length - 1;
|
|
794
|
+
if (count === 0) return {
|
|
795
|
+
content: [{
|
|
796
|
+
type: `text`,
|
|
797
|
+
text: `Error: old_string not found in ${rel}`
|
|
798
|
+
}],
|
|
799
|
+
details: { replacements: 0 }
|
|
800
|
+
};
|
|
801
|
+
await writeFile(resolved, parts.join(new_string), `utf-8`);
|
|
802
|
+
return {
|
|
803
|
+
content: [{
|
|
804
|
+
type: `text`,
|
|
805
|
+
text: `Edited ${rel}: ${count} occurrences replaced`
|
|
806
|
+
}],
|
|
807
|
+
details: { replacements: count }
|
|
808
|
+
};
|
|
809
|
+
} catch (err) {
|
|
810
|
+
serverLog.warn(`[edit tool] failed to edit ${filePath}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
811
|
+
return {
|
|
812
|
+
content: [{
|
|
813
|
+
type: `text`,
|
|
814
|
+
text: `Error editing file: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
815
|
+
}],
|
|
816
|
+
details: { replacements: 0 }
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
//#endregion
|
|
824
|
+
//#region src/tools/fetch-url.ts
|
|
825
|
+
const MAX_RAW_CHARS = 1e5;
|
|
826
|
+
const require = createRequire(import.meta.url);
|
|
827
|
+
const { gfm } = require(`turndown-plugin-gfm`);
|
|
828
|
+
function htmlToMarkdown(html, url) {
|
|
829
|
+
const virtualConsole = new VirtualConsole();
|
|
830
|
+
const dom = new JSDOM(html, {
|
|
831
|
+
url,
|
|
832
|
+
virtualConsole
|
|
833
|
+
});
|
|
834
|
+
const reader = new Readability(dom.window.document);
|
|
835
|
+
const article = reader.parse();
|
|
836
|
+
const turndown = new TurndownService({ headingStyle: `atx` });
|
|
837
|
+
turndown.use(gfm);
|
|
838
|
+
return turndown.turndown(article?.content ?? html);
|
|
839
|
+
}
|
|
840
|
+
let anthropic$1 = null;
|
|
841
|
+
function getClient$1() {
|
|
842
|
+
if (!anthropic$1) anthropic$1 = new Anthropic();
|
|
843
|
+
return anthropic$1;
|
|
844
|
+
}
|
|
845
|
+
async function extractWithLLM(text, prompt) {
|
|
846
|
+
const client = getClient$1();
|
|
847
|
+
const res = await client.messages.create({
|
|
848
|
+
model: `claude-haiku-4-5-20251001`,
|
|
849
|
+
max_tokens: 2048,
|
|
850
|
+
messages: [{
|
|
851
|
+
role: `user`,
|
|
852
|
+
content: `${prompt}\n\n<page_content>\n${text.slice(0, MAX_RAW_CHARS)}\n</page_content>`
|
|
853
|
+
}]
|
|
854
|
+
});
|
|
855
|
+
const block = res.content[0];
|
|
856
|
+
return block?.type === `text` ? block.text : ``;
|
|
857
|
+
}
|
|
858
|
+
const fetchUrlTool = {
|
|
859
|
+
name: `fetch_url`,
|
|
860
|
+
label: `Fetch URL`,
|
|
861
|
+
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.`,
|
|
862
|
+
parameters: Type.Object({
|
|
863
|
+
url: Type.String({ description: `The URL to fetch` }),
|
|
864
|
+
prompt: Type.String({ description: `What to extract from the page, e.g. 'Extract the main article content' or 'Find the pricing information'` })
|
|
865
|
+
}),
|
|
866
|
+
execute: async (_toolCallId, params) => {
|
|
867
|
+
const { url, prompt } = params;
|
|
868
|
+
try {
|
|
869
|
+
const res = await fetch(url, {
|
|
870
|
+
headers: {
|
|
871
|
+
"User-Agent": `Mozilla/5.0 (compatible; DurableStreamsAgent/1.0)`,
|
|
872
|
+
Accept: `text/html,application/xhtml+xml,text/plain,*/*`
|
|
873
|
+
},
|
|
874
|
+
redirect: `follow`,
|
|
875
|
+
signal: AbortSignal.timeout(1e4)
|
|
876
|
+
});
|
|
877
|
+
if (!res.ok) return {
|
|
878
|
+
content: [{
|
|
879
|
+
type: `text`,
|
|
880
|
+
text: `Failed to fetch: ${res.status} ${res.statusText}`
|
|
881
|
+
}],
|
|
882
|
+
details: {
|
|
883
|
+
charCount: 0,
|
|
884
|
+
usedLLM: false
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
const contentType = res.headers.get(`content-type`) ?? ``;
|
|
888
|
+
const raw = await res.text();
|
|
889
|
+
const markdown = contentType.includes(`text/html`) ? htmlToMarkdown(raw, url) : raw;
|
|
890
|
+
const extracted = await extractWithLLM(markdown, prompt);
|
|
891
|
+
return {
|
|
892
|
+
content: [{
|
|
893
|
+
type: `text`,
|
|
894
|
+
text: extracted
|
|
895
|
+
}],
|
|
896
|
+
details: {
|
|
897
|
+
charCount: extracted.length,
|
|
898
|
+
usedLLM: true
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
} catch (err) {
|
|
902
|
+
return {
|
|
903
|
+
content: [{
|
|
904
|
+
type: `text`,
|
|
905
|
+
text: `Error fetching URL: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
906
|
+
}],
|
|
907
|
+
details: {
|
|
908
|
+
charCount: 0,
|
|
909
|
+
usedLLM: false
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
//#endregion
|
|
917
|
+
//#region src/tools/read-file.ts
|
|
918
|
+
const MAX_FILE_SIZE = 512 * 1024;
|
|
919
|
+
function createReadFileTool(workingDirectory, readSet) {
|
|
920
|
+
return {
|
|
921
|
+
name: `read`,
|
|
922
|
+
label: `Read File`,
|
|
923
|
+
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.`,
|
|
924
|
+
parameters: Type.Object({ path: Type.String({ description: `File path (relative to working directory)` }) }),
|
|
925
|
+
execute: async (_toolCallId, params) => {
|
|
926
|
+
const { path: filePath } = params;
|
|
927
|
+
try {
|
|
928
|
+
const resolved = resolve(workingDirectory, filePath);
|
|
929
|
+
const rel = relative(workingDirectory, resolved);
|
|
930
|
+
if (rel.startsWith(`..`)) return {
|
|
931
|
+
content: [{
|
|
932
|
+
type: `text`,
|
|
933
|
+
text: `Error: Path "${filePath}" is outside the working directory`
|
|
934
|
+
}],
|
|
935
|
+
details: { charCount: 0 }
|
|
936
|
+
};
|
|
937
|
+
const fileStat = await stat(resolved);
|
|
938
|
+
if (fileStat.size > MAX_FILE_SIZE) return {
|
|
939
|
+
content: [{
|
|
940
|
+
type: `text`,
|
|
941
|
+
text: `Error: File is too large (${(fileStat.size / 1024).toFixed(0)}KB > ${MAX_FILE_SIZE / 1024}KB limit)`
|
|
942
|
+
}],
|
|
943
|
+
details: { charCount: 0 }
|
|
944
|
+
};
|
|
945
|
+
const buffer = await readFile(resolved);
|
|
946
|
+
const sample = buffer.subarray(0, 8192);
|
|
947
|
+
if (sample.includes(0)) return {
|
|
948
|
+
content: [{
|
|
949
|
+
type: `text`,
|
|
950
|
+
text: `Error: "${filePath}" appears to be a binary file`
|
|
951
|
+
}],
|
|
952
|
+
details: { charCount: 0 }
|
|
953
|
+
};
|
|
954
|
+
const text = buffer.toString(`utf-8`);
|
|
955
|
+
readSet?.add(resolved);
|
|
956
|
+
return {
|
|
957
|
+
content: [{
|
|
958
|
+
type: `text`,
|
|
959
|
+
text
|
|
960
|
+
}],
|
|
961
|
+
details: { charCount: text.length }
|
|
962
|
+
};
|
|
963
|
+
} catch (err) {
|
|
964
|
+
serverLog.warn(`[read tool] failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
965
|
+
return {
|
|
966
|
+
content: [{
|
|
967
|
+
type: `text`,
|
|
968
|
+
text: `Error reading file: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
969
|
+
}],
|
|
970
|
+
details: { charCount: 0 }
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
//#endregion
|
|
978
|
+
//#region src/tools/spawn-worker.ts
|
|
979
|
+
const WORKER_TOOL_NAMES = [
|
|
980
|
+
`bash`,
|
|
981
|
+
`read`,
|
|
982
|
+
`write`,
|
|
983
|
+
`edit`,
|
|
984
|
+
`brave_search`,
|
|
985
|
+
`fetch_url`,
|
|
986
|
+
`spawn_worker`
|
|
987
|
+
];
|
|
988
|
+
function createSpawnWorkerTool(ctx) {
|
|
989
|
+
return {
|
|
990
|
+
name: `spawn_worker`,
|
|
991
|
+
label: `Spawn Worker`,
|
|
992
|
+
description: `Dispatch a subagent (worker) to perform an isolated subtask. Provide a system prompt that briefs the worker like a colleague who just walked into the room (file paths, line numbers, what specifically to do, what form of answer you want back) and pick the subset of tools the worker needs.`,
|
|
993
|
+
parameters: Type.Object({
|
|
994
|
+
systemPrompt: Type.String({ description: `System prompt for the worker. Be concrete: include file paths, line numbers, and the form of answer you want back.` }),
|
|
995
|
+
tools: Type.Array(Type.Union(WORKER_TOOL_NAMES.map((n) => Type.Literal(n))), { description: `Subset of tool names to enable for the worker. Must include at least one.` }),
|
|
996
|
+
initialMessage: Type.String({ description: `First user message sent to the worker. This is what kicks off its run — without it the worker will idle. Describe the concrete task to perform.` })
|
|
997
|
+
}),
|
|
998
|
+
execute: async (_toolCallId, params) => {
|
|
999
|
+
const { systemPrompt, tools, initialMessage } = params;
|
|
1000
|
+
if (!Array.isArray(tools) || tools.length === 0) return {
|
|
1001
|
+
content: [{
|
|
1002
|
+
type: `text`,
|
|
1003
|
+
text: `Error: provide at least one tool for the worker.`
|
|
1004
|
+
}],
|
|
1005
|
+
details: { spawned: false }
|
|
1006
|
+
};
|
|
1007
|
+
if (typeof initialMessage !== `string` || initialMessage.length === 0) return {
|
|
1008
|
+
content: [{
|
|
1009
|
+
type: `text`,
|
|
1010
|
+
text: `Error: initialMessage is required and must be a non-empty string.`
|
|
1011
|
+
}],
|
|
1012
|
+
details: { spawned: false }
|
|
1013
|
+
};
|
|
1014
|
+
const id = nanoid(10);
|
|
1015
|
+
try {
|
|
1016
|
+
const handle = await ctx.spawn(`worker`, id, {
|
|
1017
|
+
systemPrompt,
|
|
1018
|
+
tools
|
|
1019
|
+
}, {
|
|
1020
|
+
initialMessage,
|
|
1021
|
+
wake: {
|
|
1022
|
+
on: `runFinished`,
|
|
1023
|
+
includeResponse: true
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
const workerUrl = handle.entityUrl;
|
|
1027
|
+
return {
|
|
1028
|
+
content: [{
|
|
1029
|
+
type: `text`,
|
|
1030
|
+
text: `Worker dispatched at ${workerUrl}. End your turn — when you next wake, the wake message will tell you the worker has finished and include its response.`
|
|
1031
|
+
}],
|
|
1032
|
+
details: {
|
|
1033
|
+
spawned: true,
|
|
1034
|
+
workerUrl
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
serverLog.warn(`[spawn_worker tool] failed to spawn worker ${id}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
1039
|
+
return {
|
|
1040
|
+
content: [{
|
|
1041
|
+
type: `text`,
|
|
1042
|
+
text: `Error spawning worker: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
1043
|
+
}],
|
|
1044
|
+
details: { spawned: false }
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
//#endregion
|
|
1052
|
+
//#region src/tools/write.ts
|
|
1053
|
+
function createWriteTool(workingDirectory, readSet) {
|
|
1054
|
+
return {
|
|
1055
|
+
name: `write`,
|
|
1056
|
+
label: `Write File`,
|
|
1057
|
+
description: `Create or overwrite a file. Path must be within the working directory. Parent directories are created as needed.`,
|
|
1058
|
+
parameters: Type.Object({
|
|
1059
|
+
path: Type.String({ description: `File path (relative to working directory)` }),
|
|
1060
|
+
content: Type.String({ description: `Full file contents to write` })
|
|
1061
|
+
}),
|
|
1062
|
+
execute: async (_toolCallId, params) => {
|
|
1063
|
+
const { path: filePath, content } = params;
|
|
1064
|
+
try {
|
|
1065
|
+
const resolved = resolve(workingDirectory, filePath);
|
|
1066
|
+
const rel = relative(workingDirectory, resolved);
|
|
1067
|
+
if (rel.startsWith(`..`)) return {
|
|
1068
|
+
content: [{
|
|
1069
|
+
type: `text`,
|
|
1070
|
+
text: `Error: Path "${filePath}" is outside the working directory`
|
|
1071
|
+
}],
|
|
1072
|
+
details: { bytesWritten: 0 }
|
|
1073
|
+
};
|
|
1074
|
+
await mkdir(dirname(resolved), { recursive: true });
|
|
1075
|
+
await writeFile(resolved, content, `utf-8`);
|
|
1076
|
+
readSet?.add(resolved);
|
|
1077
|
+
const bytesWritten = Buffer.byteLength(content, `utf-8`);
|
|
1078
|
+
return {
|
|
1079
|
+
content: [{
|
|
1080
|
+
type: `text`,
|
|
1081
|
+
text: `Wrote ${bytesWritten} bytes to ${rel}`
|
|
1082
|
+
}],
|
|
1083
|
+
details: { bytesWritten }
|
|
1084
|
+
};
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
serverLog.warn(`[write tool] failed to write ${filePath}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
1087
|
+
return {
|
|
1088
|
+
content: [{
|
|
1089
|
+
type: `text`,
|
|
1090
|
+
text: `Error writing file: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
1091
|
+
}],
|
|
1092
|
+
details: { bytesWritten: 0 }
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
//#endregion
|
|
1100
|
+
//#region src/tools/brave-search.ts
|
|
1101
|
+
const BRAVE_API_URL = `https://api.search.brave.com/res/v1/web/search`;
|
|
1102
|
+
const braveSearchTool = {
|
|
1103
|
+
name: `web_search`,
|
|
1104
|
+
label: `Web Search`,
|
|
1105
|
+
description: `Search the web for current information using Brave Search. Returns titles, URLs, and snippets from top results.`,
|
|
1106
|
+
parameters: Type.Object({ query: Type.String({ description: `The search query` }) }),
|
|
1107
|
+
execute: async (_toolCallId, params) => {
|
|
1108
|
+
const apiKey = process.env.BRAVE_SEARCH_API_KEY;
|
|
1109
|
+
if (!apiKey) return {
|
|
1110
|
+
content: [{
|
|
1111
|
+
type: `text`,
|
|
1112
|
+
text: `Search failed: BRAVE_SEARCH_API_KEY not set`
|
|
1113
|
+
}],
|
|
1114
|
+
details: { resultCount: 0 }
|
|
1115
|
+
};
|
|
1116
|
+
const { query } = params;
|
|
1117
|
+
try {
|
|
1118
|
+
const url = `${BRAVE_API_URL}?q=${encodeURIComponent(query)}&count=5`;
|
|
1119
|
+
const res = await fetch(url, { headers: { "X-Subscription-Token": apiKey } });
|
|
1120
|
+
if (!res.ok) return {
|
|
1121
|
+
content: [{
|
|
1122
|
+
type: `text`,
|
|
1123
|
+
text: `Search failed: ${res.status} ${res.statusText}`
|
|
1124
|
+
}],
|
|
1125
|
+
details: { resultCount: 0 }
|
|
1126
|
+
};
|
|
1127
|
+
const data = await res.json();
|
|
1128
|
+
const results = data.web?.results ?? [];
|
|
1129
|
+
if (results.length === 0) return {
|
|
1130
|
+
content: [{
|
|
1131
|
+
type: `text`,
|
|
1132
|
+
text: `No results found for "${query}"`
|
|
1133
|
+
}],
|
|
1134
|
+
details: { resultCount: 0 }
|
|
1135
|
+
};
|
|
1136
|
+
const formatted = results.map((r, i) => `${i + 1}. **${r.title}**\n ${r.url}\n ${r.description}`).join(`\n\n`);
|
|
1137
|
+
return {
|
|
1138
|
+
content: [{
|
|
1139
|
+
type: `text`,
|
|
1140
|
+
text: formatted
|
|
1141
|
+
}],
|
|
1142
|
+
details: { resultCount: results.length }
|
|
1143
|
+
};
|
|
1144
|
+
} catch (err) {
|
|
1145
|
+
return {
|
|
1146
|
+
content: [{
|
|
1147
|
+
type: `text`,
|
|
1148
|
+
text: `Search failed: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
1149
|
+
}],
|
|
1150
|
+
details: { resultCount: 0 }
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
|
|
1156
|
+
//#endregion
|
|
1157
|
+
//#region src/agents/horton.ts
|
|
1158
|
+
const TITLE_MODEL = `claude-haiku-4-5-20251001`;
|
|
1159
|
+
const HORTON_MODEL = `claude-sonnet-4-5-20250929`;
|
|
1160
|
+
let anthropic = null;
|
|
1161
|
+
function getClient() {
|
|
1162
|
+
if (!anthropic) anthropic = new Anthropic();
|
|
1163
|
+
return anthropic;
|
|
1164
|
+
}
|
|
1165
|
+
async function defaultHaikuCall(prompt) {
|
|
1166
|
+
const client = getClient();
|
|
1167
|
+
const res = await client.messages.create({
|
|
1168
|
+
model: TITLE_MODEL,
|
|
1169
|
+
max_tokens: 64,
|
|
1170
|
+
messages: [{
|
|
1171
|
+
role: `user`,
|
|
1172
|
+
content: prompt
|
|
1173
|
+
}]
|
|
1174
|
+
});
|
|
1175
|
+
const block = res.content[0];
|
|
1176
|
+
return block?.type === `text` ? block.text : ``;
|
|
1177
|
+
}
|
|
1178
|
+
const TITLE_PROMPT = (userMessage) => `Summarize the following user request in 3-5 words for use as a chat session title.
|
|
1179
|
+
Respond with only the title, no quotes, no punctuation, no preamble.
|
|
1180
|
+
|
|
1181
|
+
User request:
|
|
1182
|
+
${userMessage}`;
|
|
1183
|
+
const TITLE_STOP_WORDS = new Set([
|
|
1184
|
+
`a`,
|
|
1185
|
+
`an`,
|
|
1186
|
+
`and`,
|
|
1187
|
+
`are`,
|
|
1188
|
+
`can`,
|
|
1189
|
+
`for`,
|
|
1190
|
+
`from`,
|
|
1191
|
+
`help`,
|
|
1192
|
+
`i`,
|
|
1193
|
+
`in`,
|
|
1194
|
+
`into`,
|
|
1195
|
+
`is`,
|
|
1196
|
+
`it`,
|
|
1197
|
+
`look`,
|
|
1198
|
+
`me`,
|
|
1199
|
+
`my`,
|
|
1200
|
+
`need`,
|
|
1201
|
+
`of`,
|
|
1202
|
+
`on`,
|
|
1203
|
+
`or`,
|
|
1204
|
+
`please`,
|
|
1205
|
+
`the`,
|
|
1206
|
+
`this`,
|
|
1207
|
+
`to`,
|
|
1208
|
+
`we`,
|
|
1209
|
+
`with`,
|
|
1210
|
+
`you`
|
|
1211
|
+
]);
|
|
1212
|
+
const TITLE_IGNORED_WORDS = new Set([
|
|
1213
|
+
`dist`,
|
|
1214
|
+
`js`,
|
|
1215
|
+
`json`,
|
|
1216
|
+
`jsx`,
|
|
1217
|
+
`md`,
|
|
1218
|
+
`package`,
|
|
1219
|
+
`packages`,
|
|
1220
|
+
`src`,
|
|
1221
|
+
`ts`,
|
|
1222
|
+
`tsx`,
|
|
1223
|
+
`yaml`,
|
|
1224
|
+
`yml`
|
|
1225
|
+
]);
|
|
1226
|
+
function toTitleWord(word) {
|
|
1227
|
+
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
1228
|
+
}
|
|
1229
|
+
function buildFallbackTitle(userMessage) {
|
|
1230
|
+
const cleaned = userMessage.replace(/`[^`]*`/g, ` `).replace(/[./\\_-]+/g, ` `).replace(/[^a-zA-Z0-9\s]/g, ` `).replace(/\s+/g, ` `).trim();
|
|
1231
|
+
const rawWords = cleaned.split(/\s+/).filter(Boolean);
|
|
1232
|
+
const seen = new Set();
|
|
1233
|
+
const informativeWords = [];
|
|
1234
|
+
const backupWords = [];
|
|
1235
|
+
for (const rawWord of rawWords) {
|
|
1236
|
+
const word = rawWord.toLowerCase();
|
|
1237
|
+
if (seen.has(word)) continue;
|
|
1238
|
+
seen.add(word);
|
|
1239
|
+
if (!/^[a-z0-9]+$/i.test(word)) continue;
|
|
1240
|
+
if (word.length < 2 || /^\d+$/.test(word)) continue;
|
|
1241
|
+
if (TITLE_IGNORED_WORDS.has(word)) continue;
|
|
1242
|
+
const titled = toTitleWord(word);
|
|
1243
|
+
if (backupWords.length < 5) backupWords.push(titled);
|
|
1244
|
+
if (TITLE_STOP_WORDS.has(word)) continue;
|
|
1245
|
+
informativeWords.push(titled);
|
|
1246
|
+
}
|
|
1247
|
+
const selected = informativeWords.length >= 2 ? informativeWords.slice(0, 5) : backupWords;
|
|
1248
|
+
return selected.join(` `).slice(0, 80).trim() || `Untitled Chat`;
|
|
1249
|
+
}
|
|
1250
|
+
async function generateTitle(userMessage, llmCall = defaultHaikuCall) {
|
|
1251
|
+
try {
|
|
1252
|
+
const raw = await llmCall(TITLE_PROMPT(userMessage));
|
|
1253
|
+
const title = raw.trim();
|
|
1254
|
+
return title.length > 0 ? title : buildFallbackTitle(userMessage);
|
|
1255
|
+
} catch {
|
|
1256
|
+
return buildFallbackTitle(userMessage);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
function buildHortonSystemPrompt(workingDirectory, opts = {}) {
|
|
1260
|
+
const docsTools = opts.hasDocsSupport ? `\n- search_durable_agents_docs: hybrid search over the built-in Durable Agents docs index` : ``;
|
|
1261
|
+
const docsGuidance = opts.hasDocsSupport ? `\n- You have built-in Durable Agents docs context plus a docs search tool. Use that before broad web search when the question is about this repo, Electric Agents, or Durable Agents.\n- The docs TOC and docs search results include concrete file paths under the docs tree. Use the normal read tool with those returned paths.\n- Use repo read/bash tools for non-doc files or when you need to inspect exact implementation code in the workspace.` : ``;
|
|
1262
|
+
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.
|
|
1263
|
+
|
|
1264
|
+
# Tools
|
|
1265
|
+
- bash: run shell commands
|
|
1266
|
+
- read: read a file
|
|
1267
|
+
- write: create or overwrite a file
|
|
1268
|
+
- edit: targeted string replacement in an existing file (you must read the file first)
|
|
1269
|
+
- brave_search: search the web
|
|
1270
|
+
- fetch_url: fetch and convert a URL to markdown
|
|
1271
|
+
- spawn_worker: dispatch a subagent for an isolated task
|
|
1272
|
+
${docsTools}
|
|
1273
|
+
|
|
1274
|
+
# Working with files
|
|
1275
|
+
- Prefer edit over write when modifying existing files.
|
|
1276
|
+
- You must read a file before you can edit it.
|
|
1277
|
+
- Use absolute paths or paths relative to the current working directory.
|
|
1278
|
+
${docsGuidance}
|
|
1279
|
+
|
|
1280
|
+
# Risky actions
|
|
1281
|
+
Pause and confirm with the user before:
|
|
1282
|
+
- Destructive operations (deleting files, rm -rf, dropping data, force-pushing)
|
|
1283
|
+
- Hard-to-reverse operations (git reset --hard, removing dependencies)
|
|
1284
|
+
- Actions visible to others (pushing code, opening PRs, sending messages)
|
|
1285
|
+
|
|
1286
|
+
# Parallelism
|
|
1287
|
+
Run independent tool calls in parallel. Only run sequentially when one call depends on the result of another.
|
|
1288
|
+
|
|
1289
|
+
# When to spawn a worker
|
|
1290
|
+
Dispatch a worker when:
|
|
1291
|
+
- The subtask involves long research that would clutter our conversation.
|
|
1292
|
+
- The subtask is independent and can run in parallel with other work.
|
|
1293
|
+
- You need an isolated context (e.g., focused coding on one file without pulling its full content into our chat).
|
|
1294
|
+
|
|
1295
|
+
When you spawn a worker, write its system prompt the way you'd brief a colleague who just walked in: include file paths, line numbers, what specifically to do, and what form of answer you want back. The system prompt sets the worker's persona and constraints; the required initialMessage is the concrete task you're handing off — that's what kicks the worker off, so without it the worker sits idle.
|
|
1296
|
+
|
|
1297
|
+
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.
|
|
1298
|
+
|
|
1299
|
+
# Reporting
|
|
1300
|
+
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.
|
|
1301
|
+
|
|
1302
|
+
Working directory: ${workingDirectory}
|
|
1303
|
+
The current year is ${new Date().getFullYear()}.`;
|
|
1304
|
+
}
|
|
1305
|
+
function createHortonTools(workingDirectory, ctx, readSet, opts = {}) {
|
|
1306
|
+
return [
|
|
1307
|
+
createBashTool(workingDirectory),
|
|
1308
|
+
createReadFileTool(workingDirectory, readSet),
|
|
1309
|
+
createWriteTool(workingDirectory, readSet),
|
|
1310
|
+
createEditTool(workingDirectory, readSet),
|
|
1311
|
+
braveSearchTool,
|
|
1312
|
+
fetchUrlTool,
|
|
1313
|
+
createSpawnWorkerTool(ctx),
|
|
1314
|
+
...opts.docsSearchTool ? [opts.docsSearchTool] : []
|
|
1315
|
+
];
|
|
1316
|
+
}
|
|
1317
|
+
function extractFirstUserMessage(events) {
|
|
1318
|
+
for (const event of events) {
|
|
1319
|
+
if (event.type !== `message_received`) continue;
|
|
1320
|
+
const value = event.value;
|
|
1321
|
+
if (!value || value.from === `system`) continue;
|
|
1322
|
+
const payload = value.payload;
|
|
1323
|
+
if (typeof payload === `string`) return payload;
|
|
1324
|
+
if (payload != null) return JSON.stringify(payload);
|
|
1325
|
+
}
|
|
1326
|
+
return null;
|
|
1327
|
+
}
|
|
1328
|
+
function createAssistantHandler(options) {
|
|
1329
|
+
const { workingDirectory, streamFn, docsSupport, docsSearchTool } = options;
|
|
1330
|
+
return async function assistantHandler(ctx, wake) {
|
|
1331
|
+
const readSet = new Set();
|
|
1332
|
+
const tools = [...ctx.electricTools, ...createHortonTools(workingDirectory, ctx, readSet, { docsSearchTool })];
|
|
1333
|
+
if (docsSupport) ctx.useContext({
|
|
1334
|
+
sourceBudget: 1e5,
|
|
1335
|
+
sources: {
|
|
1336
|
+
docs_toc: {
|
|
1337
|
+
content: () => docsSupport.renderCompressedToc(),
|
|
1338
|
+
max: 3e3,
|
|
1339
|
+
cache: `stable`
|
|
1340
|
+
},
|
|
1341
|
+
retrieved_docs: {
|
|
1342
|
+
content: () => docsSupport.renderRetrievedDocsSource(wake, ctx.events, ctx.db.collections.inbox.toArray),
|
|
1343
|
+
max: 6e3,
|
|
1344
|
+
cache: `volatile`
|
|
1345
|
+
},
|
|
1346
|
+
conversation: {
|
|
1347
|
+
content: () => ctx.timelineMessages(),
|
|
1348
|
+
cache: `volatile`
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
});
|
|
1352
|
+
ctx.useAgent({
|
|
1353
|
+
systemPrompt: buildHortonSystemPrompt(workingDirectory, { hasDocsSupport: Boolean(docsSupport) }),
|
|
1354
|
+
model: HORTON_MODEL,
|
|
1355
|
+
tools,
|
|
1356
|
+
...streamFn && { streamFn }
|
|
1357
|
+
});
|
|
1358
|
+
await ctx.agent.run();
|
|
1359
|
+
if (ctx.firstWake && !ctx.tags.title) {
|
|
1360
|
+
const firstUserMessage = extractFirstUserMessage(ctx.events);
|
|
1361
|
+
if (firstUserMessage) {
|
|
1362
|
+
let title = null;
|
|
1363
|
+
try {
|
|
1364
|
+
const result = await generateTitle(firstUserMessage);
|
|
1365
|
+
if (result.length > 0) title = result;
|
|
1366
|
+
} catch (err) {
|
|
1367
|
+
serverLog.warn(`[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1368
|
+
}
|
|
1369
|
+
if (title !== null) try {
|
|
1370
|
+
await ctx.setTag(`title`, title);
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
serverLog.warn(`[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
function registerHorton(registry, options) {
|
|
1379
|
+
const { workingDirectory, streamFn } = options;
|
|
1380
|
+
const docsSupport = createHortonDocsSupport(workingDirectory);
|
|
1381
|
+
const docsSearchTool = docsSupport?.createSearchTool();
|
|
1382
|
+
docsSupport?.ensureReady().catch((error) => {
|
|
1383
|
+
serverLog.warn(`[horton-docs] warmup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1384
|
+
});
|
|
1385
|
+
const assistantHandler = createAssistantHandler({
|
|
1386
|
+
workingDirectory,
|
|
1387
|
+
streamFn,
|
|
1388
|
+
docsSupport,
|
|
1389
|
+
docsSearchTool
|
|
1390
|
+
});
|
|
1391
|
+
registry.define(`horton`, {
|
|
1392
|
+
description: `Friendly capable assistant — chat, code, research, dispatch`,
|
|
1393
|
+
handler: assistantHandler
|
|
1394
|
+
});
|
|
1395
|
+
const typeNames = [`horton`];
|
|
1396
|
+
if (streamFn) {
|
|
1397
|
+
registry.define(`chat`, {
|
|
1398
|
+
description: `Compatibility alias for the built-in assistant type.`,
|
|
1399
|
+
handler: assistantHandler
|
|
1400
|
+
});
|
|
1401
|
+
typeNames.push(`chat`);
|
|
1402
|
+
}
|
|
1403
|
+
return typeNames;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
//#endregion
|
|
1407
|
+
//#region src/agents/worker.ts
|
|
1408
|
+
function isWorkerToolName(value) {
|
|
1409
|
+
return typeof value === `string` && WORKER_TOOL_NAMES.includes(value);
|
|
1410
|
+
}
|
|
1411
|
+
function parseWorkerArgs(value) {
|
|
1412
|
+
if (typeof value.systemPrompt !== `string` || value.systemPrompt.length === 0) throw new Error(`[worker] systemPrompt is required`);
|
|
1413
|
+
if (!Array.isArray(value.tools) || value.tools.length === 0) throw new Error(`[worker] tools must be a non-empty array`);
|
|
1414
|
+
const tools = [];
|
|
1415
|
+
for (const t of value.tools) {
|
|
1416
|
+
if (!isWorkerToolName(t)) throw new Error(`[worker] unknown tool name: ${JSON.stringify(t)}. Valid tools: ${WORKER_TOOL_NAMES.join(`, `)}`);
|
|
1417
|
+
if (!tools.includes(t)) tools.push(t);
|
|
1418
|
+
}
|
|
1419
|
+
return {
|
|
1420
|
+
systemPrompt: value.systemPrompt,
|
|
1421
|
+
tools
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
function buildToolsForWorker(tools, workingDirectory, ctx, readSet) {
|
|
1425
|
+
const out = [];
|
|
1426
|
+
for (const name of tools) switch (name) {
|
|
1427
|
+
case `bash`:
|
|
1428
|
+
out.push(createBashTool(workingDirectory));
|
|
1429
|
+
break;
|
|
1430
|
+
case `read`:
|
|
1431
|
+
out.push(createReadFileTool(workingDirectory, readSet));
|
|
1432
|
+
break;
|
|
1433
|
+
case `write`:
|
|
1434
|
+
out.push(createWriteTool(workingDirectory, readSet));
|
|
1435
|
+
break;
|
|
1436
|
+
case `edit`:
|
|
1437
|
+
out.push(createEditTool(workingDirectory, readSet));
|
|
1438
|
+
break;
|
|
1439
|
+
case `brave_search`:
|
|
1440
|
+
out.push(braveSearchTool);
|
|
1441
|
+
break;
|
|
1442
|
+
case `fetch_url`:
|
|
1443
|
+
out.push(fetchUrlTool);
|
|
1444
|
+
break;
|
|
1445
|
+
case `spawn_worker`:
|
|
1446
|
+
out.push(createSpawnWorkerTool(ctx));
|
|
1447
|
+
break;
|
|
1448
|
+
}
|
|
1449
|
+
return out;
|
|
1450
|
+
}
|
|
1451
|
+
const WORKER_PROMPT_FOOTER = `
|
|
1452
|
+
|
|
1453
|
+
# Reporting back
|
|
1454
|
+
When you finish, respond with a concise report covering what was done and any key findings. The caller will relay this to the user, so it only needs the essentials.`;
|
|
1455
|
+
function registerWorker(registry, options) {
|
|
1456
|
+
const { workingDirectory, streamFn } = options;
|
|
1457
|
+
registry.define(`worker`, {
|
|
1458
|
+
description: `Internal — generic worker spawned by other agents. Configure via spawn args (systemPrompt + tools).`,
|
|
1459
|
+
async handler(ctx) {
|
|
1460
|
+
const args = parseWorkerArgs(ctx.args);
|
|
1461
|
+
const readSet = new Set();
|
|
1462
|
+
const tools = buildToolsForWorker(args.tools, workingDirectory, ctx, readSet);
|
|
1463
|
+
ctx.useAgent({
|
|
1464
|
+
systemPrompt: `${args.systemPrompt}${WORKER_PROMPT_FOOTER}`,
|
|
1465
|
+
model: HORTON_MODEL,
|
|
1466
|
+
tools,
|
|
1467
|
+
...streamFn && { streamFn }
|
|
1468
|
+
});
|
|
1469
|
+
await ctx.agent.run();
|
|
1470
|
+
}
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
//#endregion
|
|
1475
|
+
//#region src/bootstrap.ts
|
|
1476
|
+
const DEFAULT_BUILTIN_AGENT_HANDLER_PATH = `/_electric/builtin-agent-handler`;
|
|
1477
|
+
function createBuiltinAgentHandler(options) {
|
|
1478
|
+
const { agentServerUrl, serveEndpoint = `${agentServerUrl}${DEFAULT_BUILTIN_AGENT_HANDLER_PATH}`, workingDirectory, streamFn, createElectricTools } = options;
|
|
1479
|
+
if (!streamFn && !process.env.ANTHROPIC_API_KEY) {
|
|
1480
|
+
serverLog.warn(`[builtin-agents] ANTHROPIC_API_KEY not set — skipping built-in agent registration`);
|
|
1481
|
+
return null;
|
|
1482
|
+
}
|
|
1483
|
+
const cwd = workingDirectory ?? process.cwd();
|
|
1484
|
+
const registry = createEntityRegistry();
|
|
1485
|
+
const typeNames = registerHorton(registry, {
|
|
1486
|
+
workingDirectory: cwd,
|
|
1487
|
+
streamFn
|
|
1488
|
+
});
|
|
1489
|
+
registerWorker(registry, {
|
|
1490
|
+
workingDirectory: cwd,
|
|
1491
|
+
streamFn
|
|
1492
|
+
});
|
|
1493
|
+
typeNames.push(`worker`);
|
|
1494
|
+
const runtime = createRuntimeHandler({
|
|
1495
|
+
baseUrl: agentServerUrl,
|
|
1496
|
+
serveEndpoint,
|
|
1497
|
+
registry,
|
|
1498
|
+
subscriptionPathForType: (name) => `/${name}/*/main`,
|
|
1499
|
+
idleTimeout: 5e3,
|
|
1500
|
+
createElectricTools
|
|
1501
|
+
});
|
|
1502
|
+
return {
|
|
1503
|
+
handler: runtime.onEnter,
|
|
1504
|
+
runtime,
|
|
1505
|
+
registry,
|
|
1506
|
+
typeNames
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
async function registerBuiltinAgentTypes(bootstrap) {
|
|
1510
|
+
await bootstrap.runtime.registerTypes();
|
|
1511
|
+
serverLog.info(`[builtin-agents] ${bootstrap.typeNames.length} built-in agent types ready: ${bootstrap.typeNames.join(`, `)}`);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
//#endregion
|
|
1515
|
+
//#region src/server.ts
|
|
1516
|
+
var BuiltinAgentsServer = class {
|
|
1517
|
+
server = null;
|
|
1518
|
+
bootstrap = null;
|
|
1519
|
+
_url = null;
|
|
1520
|
+
publicBaseUrl = null;
|
|
1521
|
+
options;
|
|
1522
|
+
constructor(options) {
|
|
1523
|
+
this.options = options;
|
|
1524
|
+
}
|
|
1525
|
+
get url() {
|
|
1526
|
+
if (!this._url) throw new Error(`Builtin agents server not started`);
|
|
1527
|
+
return this._url;
|
|
1528
|
+
}
|
|
1529
|
+
get registeredBaseUrl() {
|
|
1530
|
+
if (!this.publicBaseUrl) throw new Error(`Builtin agents server not started`);
|
|
1531
|
+
return this.publicBaseUrl;
|
|
1532
|
+
}
|
|
1533
|
+
async start() {
|
|
1534
|
+
if (this.server) throw new Error(`Builtin agents server already started`);
|
|
1535
|
+
return new Promise((resolve$1, reject) => {
|
|
1536
|
+
this.server = createServer((req, res) => {
|
|
1537
|
+
this.handleRequest(req, res).catch((error) => {
|
|
1538
|
+
serverLog.error(`[builtin-agents] unhandled request error`, error);
|
|
1539
|
+
if (!res.headersSent) {
|
|
1540
|
+
res.writeHead(500, { "content-type": `application/json` });
|
|
1541
|
+
res.end(JSON.stringify({ error: `Internal server error` }));
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
});
|
|
1545
|
+
this.server.on(`error`, reject);
|
|
1546
|
+
const host = this.options.host ?? `127.0.0.1`;
|
|
1547
|
+
this.server.listen(this.options.port, host, async () => {
|
|
1548
|
+
try {
|
|
1549
|
+
const addr = this.server.address();
|
|
1550
|
+
if (typeof addr === `string`) this._url = addr;
|
|
1551
|
+
else if (addr) {
|
|
1552
|
+
const resolvedHost = host === `0.0.0.0` ? `127.0.0.1` : host;
|
|
1553
|
+
this._url = `http://${resolvedHost}:${addr.port}`;
|
|
1554
|
+
} else throw new Error(`Could not determine builtin agents server address`);
|
|
1555
|
+
this.publicBaseUrl = this.options.baseUrl ?? this._url;
|
|
1556
|
+
const webhookPath = this.options.webhookPath ?? DEFAULT_BUILTIN_AGENT_HANDLER_PATH;
|
|
1557
|
+
const serveEndpoint = new URL(webhookPath, this.publicBaseUrl.endsWith(`/`) ? this.publicBaseUrl : `${this.publicBaseUrl}/`).toString();
|
|
1558
|
+
this.bootstrap = createBuiltinAgentHandler({
|
|
1559
|
+
agentServerUrl: this.options.agentServerUrl,
|
|
1560
|
+
serveEndpoint,
|
|
1561
|
+
workingDirectory: this.options.workingDirectory,
|
|
1562
|
+
streamFn: this.options.mockStreamFn,
|
|
1563
|
+
createElectricTools: this.options.createElectricTools
|
|
1564
|
+
});
|
|
1565
|
+
if (!this.bootstrap) throw new Error(`ANTHROPIC_API_KEY must be set before starting builtin agents`);
|
|
1566
|
+
await registerBuiltinAgentTypes(this.bootstrap);
|
|
1567
|
+
serverLog.info(`[builtin-agents] webhook handler listening at ${serveEndpoint}`);
|
|
1568
|
+
resolve$1(this._url);
|
|
1569
|
+
} catch (error) {
|
|
1570
|
+
await this.stop().catch(() => {});
|
|
1571
|
+
reject(error);
|
|
1572
|
+
}
|
|
1573
|
+
});
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
async stop() {
|
|
1577
|
+
if (this.bootstrap) {
|
|
1578
|
+
this.bootstrap.runtime.abortWakes();
|
|
1579
|
+
await Promise.race([this.bootstrap.runtime.drainWakes().catch(() => {}), new Promise((resolve$1) => setTimeout(resolve$1, 5e3))]);
|
|
1580
|
+
this.bootstrap = null;
|
|
1581
|
+
}
|
|
1582
|
+
if (this.server) {
|
|
1583
|
+
const server = this.server;
|
|
1584
|
+
await new Promise((resolve$1) => {
|
|
1585
|
+
server.close(() => resolve$1());
|
|
1586
|
+
});
|
|
1587
|
+
this.server = null;
|
|
1588
|
+
}
|
|
1589
|
+
this._url = null;
|
|
1590
|
+
this.publicBaseUrl = null;
|
|
1591
|
+
}
|
|
1592
|
+
async handleRequest(req, res) {
|
|
1593
|
+
const method = req.method?.toUpperCase();
|
|
1594
|
+
const path$1 = new URL(req.url ?? `/`, `http://localhost`).pathname;
|
|
1595
|
+
const webhookPath = this.options.webhookPath ?? DEFAULT_BUILTIN_AGENT_HANDLER_PATH;
|
|
1596
|
+
if (path$1 === `/_electric/health` && method === `GET`) {
|
|
1597
|
+
res.writeHead(200, { "content-type": `application/json` });
|
|
1598
|
+
res.end(JSON.stringify({ status: `ok` }));
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
if (path$1 === webhookPath && method === `POST` && this.bootstrap) {
|
|
1602
|
+
await this.bootstrap.handler(req, res);
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
res.writeHead(404, { "content-type": `application/json` });
|
|
1606
|
+
res.end(JSON.stringify({ error: `Not found` }));
|
|
1607
|
+
}
|
|
1608
|
+
};
|
|
1609
|
+
|
|
1610
|
+
//#endregion
|
|
1611
|
+
//#region src/entrypoint-lib.ts
|
|
1612
|
+
const DEFAULT_HOST = `127.0.0.1`;
|
|
1613
|
+
const DEFAULT_PORT = 4448;
|
|
1614
|
+
function readEnv(env, names) {
|
|
1615
|
+
for (const name of names) {
|
|
1616
|
+
const value = env[name]?.trim();
|
|
1617
|
+
if (value) return value;
|
|
1618
|
+
}
|
|
1619
|
+
return void 0;
|
|
1620
|
+
}
|
|
1621
|
+
function readRequiredEnv(env, names, description) {
|
|
1622
|
+
const value = readEnv(env, names);
|
|
1623
|
+
if (value) return value;
|
|
1624
|
+
throw new Error(`Missing ${description}. Set one of: ${names.map((name) => `"${name}"`).join(`, `)}`);
|
|
1625
|
+
}
|
|
1626
|
+
function readPort(env) {
|
|
1627
|
+
const raw = readEnv(env, [`ELECTRIC_AGENTS_BUILTIN_PORT`, `PORT`]);
|
|
1628
|
+
if (!raw) return DEFAULT_PORT;
|
|
1629
|
+
const port = Number(raw);
|
|
1630
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid builtin agents port "${raw}". Expected an integer between 1 and 65535.`);
|
|
1631
|
+
return port;
|
|
1632
|
+
}
|
|
1633
|
+
function validateUrl(name, value) {
|
|
1634
|
+
try {
|
|
1635
|
+
new URL(value);
|
|
1636
|
+
return value;
|
|
1637
|
+
} catch {
|
|
1638
|
+
throw new Error(`Invalid ${name}: "${value}"`);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
function resolveBuiltinAgentsEntrypointOptions(env = process.env, cwd = process.cwd()) {
|
|
1642
|
+
const agentServerUrl = validateUrl(`agent server URL`, readRequiredEnv(env, [`ELECTRIC_AGENTS_SERVER_URL`, `ELECTRIC_AGENTS_BASE_URL`], `agent server base URL`));
|
|
1643
|
+
const baseUrl = readEnv(env, [`ELECTRIC_AGENTS_BUILTIN_BASE_URL`, `BUILTIN_AGENTS_BASE_URL`]);
|
|
1644
|
+
return {
|
|
1645
|
+
agentServerUrl,
|
|
1646
|
+
baseUrl: baseUrl ? validateUrl(`builtin agents base URL`, baseUrl) : void 0,
|
|
1647
|
+
host: readEnv(env, [`ELECTRIC_AGENTS_BUILTIN_HOST`, `HOST`]) ?? DEFAULT_HOST,
|
|
1648
|
+
port: readPort(env),
|
|
1649
|
+
workingDirectory: readEnv(env, [`ELECTRIC_AGENTS_WORKING_DIRECTORY`, `WORKING_DIRECTORY`]) ?? cwd
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
async function runBuiltinAgentsEntrypoint({ env = process.env, cwd = process.cwd(), createServer: createServer$1 = (options$1) => new BuiltinAgentsServer(options$1) } = {}) {
|
|
1653
|
+
const options = resolveBuiltinAgentsEntrypointOptions(env, cwd);
|
|
1654
|
+
const server = createServer$1(options);
|
|
1655
|
+
const url = await server.start();
|
|
1656
|
+
return {
|
|
1657
|
+
options,
|
|
1658
|
+
server,
|
|
1659
|
+
url
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
async function main() {
|
|
1663
|
+
try {
|
|
1664
|
+
process.loadEnvFile();
|
|
1665
|
+
} catch {}
|
|
1666
|
+
let server = null;
|
|
1667
|
+
let stopping = null;
|
|
1668
|
+
const stop = async (exitCode) => {
|
|
1669
|
+
if (!stopping) stopping = server?.stop().catch((error) => {
|
|
1670
|
+
console.error(`[builtin-agents] failed to stop cleanly`, error);
|
|
1671
|
+
}) ?? Promise.resolve();
|
|
1672
|
+
await stopping;
|
|
1673
|
+
process.exit(exitCode);
|
|
1674
|
+
};
|
|
1675
|
+
try {
|
|
1676
|
+
const started = await runBuiltinAgentsEntrypoint();
|
|
1677
|
+
server = started.server;
|
|
1678
|
+
console.log(`Builtin agents server running at ${started.url}`);
|
|
1679
|
+
console.log(`Registering against: ${started.options.agentServerUrl}`);
|
|
1680
|
+
console.log(`Working directory: ${started.options.workingDirectory}`);
|
|
1681
|
+
if (started.options.baseUrl) console.log(`Public webhook base URL: ${started.options.baseUrl}`);
|
|
1682
|
+
process.on(`SIGINT`, () => {
|
|
1683
|
+
stop(0);
|
|
1684
|
+
});
|
|
1685
|
+
process.on(`SIGTERM`, () => {
|
|
1686
|
+
stop(0);
|
|
1687
|
+
});
|
|
1688
|
+
process.on(`uncaughtException`, (error) => {
|
|
1689
|
+
console.error(error);
|
|
1690
|
+
stop(1);
|
|
1691
|
+
});
|
|
1692
|
+
process.on(`unhandledRejection`, (error) => {
|
|
1693
|
+
console.error(error);
|
|
1694
|
+
stop(1);
|
|
1695
|
+
});
|
|
1696
|
+
} catch (error) {
|
|
1697
|
+
console.error(error);
|
|
1698
|
+
if (server) await server.stop().catch(() => {});
|
|
1699
|
+
process.exit(1);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
//#endregion
|
|
1704
|
+
//#region src/entrypoint.ts
|
|
1705
|
+
main();
|
|
1706
|
+
|
|
1707
|
+
//#endregion
|