@robzilla1738/agentswarm 0.3.0 → 0.6.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.
Files changed (45) hide show
  1. package/README.md +51 -11
  2. package/dist/agent.js +18 -2
  3. package/dist/cli.js +39 -8
  4. package/dist/config.js +62 -6
  5. package/dist/crawltools.js +247 -0
  6. package/dist/deepseek.js +125 -10
  7. package/dist/executor.js +993 -144
  8. package/dist/hub.js +85 -6
  9. package/dist/journal.js +61 -11
  10. package/dist/memory.js +84 -0
  11. package/dist/pdftext.js +211 -0
  12. package/dist/prompts.js +124 -23
  13. package/dist/report.js +289 -0
  14. package/dist/run.js +15 -2
  15. package/dist/sandbox.js +11 -0
  16. package/dist/searchcore.js +244 -0
  17. package/dist/state.js +85 -3
  18. package/dist/tools.js +392 -25
  19. package/dist/util.js +85 -0
  20. package/dist/webtools.js +327 -66
  21. package/package.json +3 -2
  22. package/ui/out/404/index.html +1 -1
  23. package/ui/out/404.html +1 -1
  24. package/ui/out/_next/static/chunks/532-35122e93f37719b9.js +1 -0
  25. package/ui/out/_next/static/chunks/677-721ce1c8b7a6a317.js +1 -0
  26. package/ui/out/_next/static/chunks/app/page-dc9f6744d203e76c.js +1 -0
  27. package/ui/out/_next/static/chunks/app/run/page-3674e103981703a2.js +1 -0
  28. package/ui/out/_next/static/chunks/app/settings/page-41a5d8ba43ecfd4a.js +1 -0
  29. package/ui/out/_next/static/css/d95c2ba395730031.css +3 -0
  30. package/ui/out/fonts/PlanetKosmos.ttf +0 -0
  31. package/ui/out/index.html +1 -1
  32. package/ui/out/index.txt +3 -3
  33. package/ui/out/run/index.html +1 -1
  34. package/ui/out/run/index.txt +3 -3
  35. package/ui/out/settings/index.html +1 -1
  36. package/ui/out/settings/index.txt +3 -3
  37. package/ui/out/_next/static/chunks/383-289a866b246b41cc.js +0 -1
  38. package/ui/out/_next/static/chunks/619-ba102abea3e3d0e4.js +0 -1
  39. package/ui/out/_next/static/chunks/677-7ab85a6f38c3a235.js +0 -1
  40. package/ui/out/_next/static/chunks/app/page-0fda5b8e77d90b84.js +0 -1
  41. package/ui/out/_next/static/chunks/app/run/page-07aab6b1224c3c8c.js +0 -1
  42. package/ui/out/_next/static/chunks/app/settings/page-528482d468d84cfa.js +0 -1
  43. package/ui/out/_next/static/css/e2c82b53bf4519e8.css +0 -3
  44. /package/ui/out/_next/static/{Rm5Fhkds2-wIOnVlME55J → 7_pihFubDGD40BCy2ynlr}/_buildManifest.js +0 -0
  45. /package/ui/out/_next/static/{Rm5Fhkds2-wIOnVlME55J → 7_pihFubDGD40BCy2ynlr}/_ssgManifest.js +0 -0
package/dist/tools.js CHANGED
@@ -33,12 +33,14 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.FINISH_TOOL = exports.WAIT_TOOL = exports.SPAWN_TASKS_TOOL = exports.SUBMIT_FINAL_TOOL = exports.VERDICT_TOOL = exports.REPORT_TOOL = void 0;
36
+ exports.FINISH_TOOL = exports.WAIT_TOOL = exports.SET_PHASE_TOOL = exports.UPDATE_PLAN_TOOL = exports.CONDUCTOR_READ_REPORT_TOOL = exports.SPAWN_TASKS_TOOL = exports.SUBMIT_FINAL_TOOL = exports.VERDICT_TOOL = exports.REPORT_TOOL = void 0;
37
37
  exports.workerToolset = workerToolset;
38
38
  exports.verifierToolset = verifierToolset;
39
39
  exports.synthToolset = synthToolset;
40
40
  const fs = __importStar(require("fs"));
41
41
  const path = __importStar(require("path"));
42
+ const crawltools_1 = require("./crawltools");
43
+ const searchcore_1 = require("./searchcore");
42
44
  const util_1 = require("./util");
43
45
  const webtools_1 = require("./webtools");
44
46
  // ---------- safety ----------
@@ -61,9 +63,48 @@ function checkCommand(cmd, cfg) {
61
63
  function resolveRead(p, ctx) {
62
64
  return path.resolve(ctx.workdir, p);
63
65
  }
66
+ /** Single-quote a string for sh. */
67
+ function shq(s) {
68
+ return `'${s.replace(/'/g, `'\\''`)}'`;
69
+ }
70
+ /**
71
+ * Where a write actually lands: realpath of the deepest existing ancestor plus
72
+ * the not-yet-created remainder. Confinement checks must use this, or a
73
+ * symlink inside the workdir smuggles writes anywhere on the host.
74
+ */
75
+ function realDestination(abs) {
76
+ let dir = abs;
77
+ const tail = [];
78
+ while (!fs.existsSync(dir)) {
79
+ tail.unshift(path.basename(dir));
80
+ const parent = path.dirname(dir);
81
+ if (parent === dir)
82
+ break;
83
+ dir = parent;
84
+ }
85
+ try {
86
+ dir = fs.realpathSync(dir);
87
+ }
88
+ catch {
89
+ /* races/permissions: keep the lexical path */
90
+ }
91
+ return path.join(dir, ...tail);
92
+ }
93
+ function realBase(base) {
94
+ try {
95
+ return fs.realpathSync(base);
96
+ }
97
+ catch {
98
+ return base;
99
+ }
100
+ }
64
101
  function resolveWrite(p, ctx) {
65
102
  const abs = path.resolve(ctx.workdir, p);
66
- const ok = (0, util_1.pathInside)(ctx.workdir, abs) || (0, util_1.pathInside)(ctx.runDirPath, abs) || !ctx.cfg.safeMode;
103
+ // Remote sandboxes own their filesystem host-side realpath is meaningless there.
104
+ const real = ctx.sandbox.localFs ? realDestination(abs) : abs;
105
+ const ok = (0, util_1.pathInside)(realBase(ctx.workdir), real) ||
106
+ (0, util_1.pathInside)(realBase(ctx.runDirPath), real) ||
107
+ !ctx.cfg.safeMode;
67
108
  if (!ok) {
68
109
  throw new Error(`safeMode: writes are restricted to the working directory (${ctx.workdir}). ` +
69
110
  `Use a relative path, or save deliverables with save_artifact.`);
@@ -84,7 +125,7 @@ async function writeFileVia(ctx, abs, content) {
84
125
  }
85
126
  }
86
127
  // ---------- tool definitions ----------
87
- function workerToolset() {
128
+ function workerToolset(cfg) {
88
129
  const tools = {};
89
130
  tools.shell = {
90
131
  schema: {
@@ -163,13 +204,14 @@ function workerToolset() {
163
204
  if (content.length > 5_000_000)
164
205
  throw new Error("content too large (>5MB)");
165
206
  await writeFileVia(ctx, abs, content);
166
- return `wrote ${abs} (${content.length} chars)`;
207
+ const warn = ctx.checkClaim?.(String(args.path));
208
+ return `wrote ${abs} (${content.length} chars)${warn ? `\n${warn}` : ""}`;
167
209
  },
168
210
  };
169
211
  tools.replace_in_file = {
170
212
  schema: {
171
213
  name: "replace_in_file",
172
- description: "Exact string replacement in a file. `find` must match exactly (including whitespace). Fails if not found, or if ambiguous when all=false.",
214
+ description: "Exact string replacement in a file. `find` must match exactly (including whitespace). Fails if not found, or if ambiguous when all=false. For several edits to the same file, pass `edits` — they apply in order, all-or-nothing, in one call.",
173
215
  parameters: {
174
216
  type: "object",
175
217
  properties: {
@@ -177,24 +219,96 @@ function workerToolset() {
177
219
  find: { type: "string" },
178
220
  replace: { type: "string" },
179
221
  all: { type: "boolean", description: "Replace every occurrence (default false)" },
222
+ edits: {
223
+ type: "array",
224
+ description: "Batch mode: multiple find/replace pairs applied in order, atomically (replaces top-level find/replace)",
225
+ items: {
226
+ type: "object",
227
+ properties: {
228
+ find: { type: "string" },
229
+ replace: { type: "string" },
230
+ all: { type: "boolean" },
231
+ },
232
+ required: ["find", "replace"],
233
+ },
234
+ },
180
235
  },
181
- required: ["path", "find", "replace"],
236
+ required: ["path"],
182
237
  },
183
238
  },
184
239
  run: async (args, ctx) => {
185
240
  const abs = resolveWrite(String(args.path), ctx);
186
241
  const raw = await readFileVia(ctx, abs);
187
- const find = String(args.find);
188
- const replace = String(args.replace);
189
- const count = raw.split(find).length - 1;
190
- if (count === 0)
191
- throw new Error("find string not found in file");
192
- if (count > 1 && !args.all) {
193
- throw new Error(`find string matches ${count} times; provide more context or set all=true`);
242
+ const edits = Array.isArray(args.edits) && args.edits.length
243
+ ? args.edits.map((e) => ({
244
+ find: String(e.find ?? ""),
245
+ replace: String(e.replace ?? ""),
246
+ all: Boolean(e.all),
247
+ }))
248
+ : args.find !== undefined && args.replace !== undefined
249
+ ? [{ find: String(args.find), replace: String(args.replace), all: Boolean(args.all) }]
250
+ : null;
251
+ if (!edits)
252
+ throw new Error("provide find+replace, or an edits array");
253
+ // Validate-then-apply against the progressively edited content:
254
+ // any failing edit aborts the whole batch with nothing written.
255
+ let next = raw;
256
+ let total = 0;
257
+ const at = (i) => (edits.length > 1 ? `edit ${i + 1}: ` : "");
258
+ for (let i = 0; i < edits.length; i++) {
259
+ const { find, replace, all } = edits[i];
260
+ if (!find)
261
+ throw new Error(`${at(i)}find must not be empty`);
262
+ const count = next.split(find).length - 1;
263
+ if (count === 0) {
264
+ throw new Error(`${at(i)}find string not found in file${edits.length > 1 ? " — no edits were applied" : ""}`);
265
+ }
266
+ if (count > 1 && !all) {
267
+ throw new Error(`${at(i)}find string matches ${count} times; provide more context or set all=true${edits.length > 1 ? " — no edits were applied" : ""}`);
268
+ }
269
+ next = all ? next.split(find).join(replace) : next.replace(find, replace);
270
+ total += all ? count : 1;
194
271
  }
195
- const next = args.all ? raw.split(find).join(replace) : raw.replace(find, replace);
196
272
  await writeFileVia(ctx, abs, next);
197
- return `replaced ${args.all ? count : 1} occurrence(s) in ${abs}`;
273
+ const warn = ctx.checkClaim?.(String(args.path));
274
+ return `replaced ${total} occurrence(s) via ${edits.length} edit(s) in ${abs}${warn ? `\n${warn}` : ""}`;
275
+ },
276
+ };
277
+ tools.grep_files = {
278
+ schema: {
279
+ name: "grep_files",
280
+ description: "Search file contents with a regex (grep -E syntax). Returns matching lines as path:line:text. Use this to locate code or text instead of shell grep pipelines — one round-trip, works identically in remote sandboxes, skips node_modules/.git/build output.",
281
+ parameters: {
282
+ type: "object",
283
+ properties: {
284
+ pattern: { type: "string", description: "Extended regex (grep -E)" },
285
+ path: { type: "string", description: "Directory or file to search (default: working directory)" },
286
+ glob: { type: "string", description: "Filename filter, e.g. *.ts" },
287
+ ignore_case: { type: "boolean" },
288
+ max_results: { type: "number", description: "Default 50, max 200" },
289
+ },
290
+ required: ["pattern"],
291
+ },
292
+ },
293
+ run: async (args, ctx) => {
294
+ const pattern = String(args.pattern ?? "");
295
+ if (!pattern.trim())
296
+ throw new Error("pattern is required");
297
+ const root = args.path ? resolveRead(String(args.path), ctx) : ctx.workdir;
298
+ const max = Math.min(Math.max(Number(args.max_results) || 50, 1), 200);
299
+ const flags = `-rnE${args.ignore_case ? "i" : ""}`;
300
+ const include = args.glob ? ` --include=${shq(String(args.glob))}` : "";
301
+ const excludes = ["node_modules", ".git", "dist", ".next", "out", "build", "target", "__pycache__", ".venv"]
302
+ .map((d) => ` --exclude-dir=${d}`)
303
+ .join("");
304
+ const cmd = `grep ${flags}${include}${excludes} -e ${shq(pattern)} ${shq(root)} | head -n ${max + 1}`;
305
+ const r = await ctx.sandbox.exec(cmd, { cwd: ctx.workdir, timeoutSec: 60, signal: ctx.signal });
306
+ const lines = r.out.split("\n").filter(Boolean);
307
+ if (!lines.length)
308
+ return "no matches";
309
+ const shown = lines.slice(0, max);
310
+ const more = lines.length > max ? `\n…more matches truncated (raise max_results or narrow the pattern)` : "";
311
+ return shown.join("\n") + more;
198
312
  },
199
313
  };
200
314
  tools.list_dir = {
@@ -266,20 +380,20 @@ function workerToolset() {
266
380
  tools.web_search = {
267
381
  schema: {
268
382
  name: "web_search",
269
- description: "Search the web. Returns ranked results with title, URL and snippet. " +
270
- "Set deep=true to also fetch top pages and return quotable passages (slower; use for claims that need grounding).",
383
+ description: "Search the web. Fans out across multiple engines (DuckDuckGo, Bing, +TinyFish if configured), merges and quality-ranks results, and dedupes by canonical URL. Returns ranked results with title, URL and snippet. " +
384
+ "Set deep=true to widen the query into complementary phrasings, fetch the top pages, and return quotable passages with publication dates — use for thorough research and any claim that needs grounding. Raise count (up to 25) to pull more sources per call.",
271
385
  parameters: {
272
386
  type: "object",
273
387
  properties: {
274
388
  query: { type: "string" },
275
- count: { type: "number", description: "Max results, default 6, max 10" },
276
- deep: { type: "boolean", description: "Fetch page content for quotable passages" },
389
+ count: { type: "number", description: "Max results, default 8, max 25" },
390
+ deep: { type: "boolean", description: "Multi-phrasing sweep + fetch pages for quotable passages" },
277
391
  },
278
392
  required: ["query"],
279
393
  },
280
394
  },
281
395
  run: async (args, ctx) => {
282
- const count = Math.min(Math.max(Number(args.count) || 6, 1), 10);
396
+ const count = Math.min(Math.max(Number(args.count) || 8, 1), 25);
283
397
  const hits = await (0, webtools_1.webSearch)(ctx.cfg, String(args.query), count, ctx.signal, Boolean(args.deep), (msg) => ctx.log?.("warn", msg));
284
398
  if (!hits.length)
285
399
  return "no results";
@@ -292,6 +406,39 @@ function workerToolset() {
292
406
  .join("\n");
293
407
  },
294
408
  };
409
+ tools.academic_search = {
410
+ schema: {
411
+ name: "academic_search",
412
+ description: "Search scholarly sources: arXiv preprints and Crossref journal/conference metadata (keyless APIs). Returns papers with title, link (arXiv/DOI), abstract snippet, and date. Use for scientific or technical questions where peer-reviewed and preprint sources beat the open web.",
413
+ parameters: {
414
+ type: "object",
415
+ properties: {
416
+ query: { type: "string" },
417
+ count: { type: "number", description: "Max results, default 8, max 20" },
418
+ },
419
+ required: ["query"],
420
+ },
421
+ },
422
+ run: async (args, ctx) => {
423
+ const count = Math.min(Math.max(Number(args.count) || 8, 1), 20);
424
+ const q = String(args.query);
425
+ const settled = await Promise.allSettled([
426
+ (0, webtools_1.arxivSearch)(q, count, ctx.signal),
427
+ (0, webtools_1.crossrefSearch)(q, count, ctx.signal),
428
+ ]);
429
+ const candidates = settled.flatMap((s) => (s.status === "fulfilled" ? s.value : []));
430
+ if (!candidates.length) {
431
+ const err = settled.find((s) => s.status === "rejected");
432
+ if (err)
433
+ throw err.reason;
434
+ return "no results";
435
+ }
436
+ const merged = (0, searchcore_1.mergeCandidates)(candidates, count);
437
+ return merged
438
+ .map((h, i) => `${i + 1}. ${h.title}${h.date ? ` (${h.date})` : ""} [${h.engine}]\n ${h.url}\n ${h.snippet}`)
439
+ .join("\n");
440
+ },
441
+ };
295
442
  tools.fetch_url = {
296
443
  schema: {
297
444
  name: "fetch_url",
@@ -315,25 +462,94 @@ function workerToolset() {
315
462
  tools.note = {
316
463
  schema: {
317
464
  name: "note",
318
- description: "Post a durable fact/discovery to the swarm's shared blackboard so the conductor and other agents can see it. Use sparingly — facts other tasks need, not progress chatter.",
465
+ description: "Post a durable fact/discovery to the swarm's shared blackboard so the conductor and other agents can see it. Use sparingly — facts other tasks need, not progress chatter. Mark kind='decision' for choices the rest of the mission must respect, and kind='conflict' when independent sources disagree on a material fact (both are never trimmed from digests).",
319
466
  parameters: {
320
467
  type: "object",
321
468
  properties: {
322
469
  text: { type: "string" },
323
470
  key: { type: "string", description: "Optional short label" },
471
+ kind: {
472
+ type: "string",
473
+ enum: ["finding", "decision", "conflict", "open-question", "handoff", "claim"],
474
+ description: "Category (default finding). kind='conflict' flags sources that disagree — name both. kind='claim' with key=<file path> advertises you are editing that file",
475
+ },
476
+ url: { type: "string", description: "Source URL backing this note, when it came from the web" },
324
477
  },
325
478
  required: ["text"],
326
479
  },
327
480
  },
328
481
  run: async (args, ctx) => {
329
- ctx.addNote(String(args.text), args.key ? String(args.key) : undefined);
482
+ const kind = ["finding", "decision", "conflict", "open-question", "handoff", "claim"].includes(String(args.kind))
483
+ ? String(args.kind)
484
+ : undefined;
485
+ const url = /^https?:\/\//.test(String(args.url ?? "")) ? String(args.url) : undefined;
486
+ ctx.addNote(String(args.text), args.key ? String(args.key) : undefined, kind, url);
330
487
  return "noted on the blackboard";
331
488
  },
332
489
  };
490
+ tools.search_notes = {
491
+ schema: {
492
+ name: "search_notes",
493
+ description: "Keyword-search the ENTIRE blackboard history (the digest in your prompt only shows the recent tail). Use when you need a fact another agent may have posted earlier in the run.",
494
+ parameters: {
495
+ type: "object",
496
+ properties: {
497
+ query: { type: "string", description: "Keywords to match against note text/labels" },
498
+ },
499
+ required: ["query"],
500
+ },
501
+ },
502
+ run: async (args, ctx) => {
503
+ if (!ctx.searchNotes)
504
+ return "note search is unavailable in this context";
505
+ return ctx.searchNotes(String(args.query ?? ""));
506
+ },
507
+ };
508
+ tools.read_report = {
509
+ schema: {
510
+ name: "read_report",
511
+ description: "Read the FULL report of a settled task (dependency reports in your prompt are excerpts). Use when an excerpt cuts off details you need.",
512
+ parameters: {
513
+ type: "object",
514
+ properties: {
515
+ task_id: { type: "string", description: "e.g. T3" },
516
+ },
517
+ required: ["task_id"],
518
+ },
519
+ },
520
+ run: async (args, ctx) => {
521
+ if (!ctx.readReport)
522
+ return "report lookup is unavailable in this context";
523
+ return ctx.readReport(String(args.task_id ?? ""));
524
+ },
525
+ };
526
+ tools.checkpoint = {
527
+ schema: {
528
+ name: "checkpoint",
529
+ description: "Journal a durable progress checkpoint: a dense summary of what you've completed, key findings, and what remains. If the run is interrupted, the next attempt resumes from your latest checkpoint instead of starting over. Use after completing each major chunk of a long task.",
530
+ parameters: {
531
+ type: "object",
532
+ properties: {
533
+ summary: {
534
+ type: "string",
535
+ description: "Completed work (exact paths/commands), key findings, and remaining steps",
536
+ },
537
+ },
538
+ required: ["summary"],
539
+ },
540
+ },
541
+ run: async (args, ctx) => {
542
+ const summary = String(args.summary ?? "").trim();
543
+ if (!summary)
544
+ throw new Error("summary is required");
545
+ ctx.addCheckpoint?.(summary);
546
+ return "checkpoint saved";
547
+ },
548
+ };
333
549
  tools.save_artifact = {
334
550
  schema: {
335
551
  name: "save_artifact",
336
- description: "Save a deliverable into the run's artifacts folder (shown prominently to the operator). Provide content, or from_path to copy an existing file.",
552
+ description: "Save a deliverable into the run's artifacts folder (shown prominently to the operator). Provide content, or from_path to copy an existing file. Any file type works — save deliverables in the format that fits them (.csv/.json for data, .html for documents, runnable code files), not just markdown.",
337
553
  parameters: {
338
554
  type: "object",
339
555
  properties: {
@@ -346,8 +562,12 @@ function workerToolset() {
346
562
  },
347
563
  run: async (args, ctx) => {
348
564
  const name = String(args.name).replace(/^\/+/, "");
349
- const dest = path.join(ctx.runDirPath, "artifacts", name);
350
- if (!(0, util_1.pathInside)(path.join(ctx.runDirPath, "artifacts"), dest)) {
565
+ const artifactsRoot = path.join(ctx.runDirPath, "artifacts");
566
+ (0, util_1.ensureDir)(artifactsRoot);
567
+ const dest = path.join(artifactsRoot, name);
568
+ // Realpath-based: neither ../ traversal nor a planted symlink may move
569
+ // the artifact outside the run's artifacts folder.
570
+ if (!(0, util_1.pathInside)(realBase(artifactsRoot), realDestination(dest))) {
351
571
  throw new Error("artifact name must stay inside the artifacts folder");
352
572
  }
353
573
  (0, util_1.ensureDir)(path.dirname(dest));
@@ -366,6 +586,64 @@ function workerToolset() {
366
586
  return `saved artifacts/${name}`;
367
587
  },
368
588
  };
589
+ // Only offered when a crawl backend (Firecrawl / context.dev / deepcrawl)
590
+ // is configured — there is no free fallback for whole-site crawls.
591
+ if (cfg && (0, crawltools_1.resolveCrawlBackend)(cfg)) {
592
+ tools.crawl_site = {
593
+ schema: {
594
+ name: "crawl_site",
595
+ description: "Crawl a website (JS-rendered, clean markdown) and save every discovered page as a markdown file under crawl/<host>/ in the working directory. Returns an index of the saved files — read individual pages afterwards with read_file. Use for ingesting documentation sites or multi-page content; use fetch_url for single pages.",
596
+ parameters: {
597
+ type: "object",
598
+ properties: {
599
+ url: { type: "string", description: "Starting URL to crawl" },
600
+ max_pages: { type: "number", description: "Page limit (default 15, max 50)" },
601
+ include_paths: {
602
+ type: "array",
603
+ items: { type: "string" },
604
+ description: "Limit the crawl to URL path prefixes/globs, e.g. /docs/*",
605
+ },
606
+ },
607
+ required: ["url"],
608
+ },
609
+ },
610
+ run: async (args, ctx) => {
611
+ const url = String(args.url ?? "");
612
+ if (!/^https?:\/\//.test(url))
613
+ throw new Error("only http(s) URLs are supported");
614
+ const maxPages = Math.min(Math.max(Number(args.max_pages) || 15, 1), 50);
615
+ const includePaths = Array.isArray(args.include_paths)
616
+ ? args.include_paths.map(String).filter(Boolean)
617
+ : undefined;
618
+ const out = await (0, crawltools_1.crawlSite)(ctx.cfg, { url, maxPages, includePaths, signal: ctx.signal });
619
+ if (!out.pages.length) {
620
+ return `crawled ${url} via ${out.backend}: no pages with content${out.warnings.length ? `\nwarnings: ${out.warnings.join("; ")}` : ""}`;
621
+ }
622
+ const used = new Set();
623
+ const lines = [];
624
+ for (const page of out.pages) {
625
+ const { host, slug } = (0, crawltools_1.slugForUrl)(page.url || url);
626
+ let rel = `crawl/${host}/${slug}.md`;
627
+ for (let n = 2; used.has(rel); n++)
628
+ rel = `crawl/${host}/${slug}-${n}.md`;
629
+ used.add(rel);
630
+ const abs = resolveWrite(rel, ctx);
631
+ const header = `# ${page.title || page.url || "untitled"}\n\nSource: ${page.url || url}\n\n`;
632
+ await writeFileVia(ctx, abs, header + page.markdown);
633
+ if (lines.length < 50) {
634
+ lines.push(` ${rel} — "${page.title || "untitled"}" (${page.markdown.length.toLocaleString()} chars)`);
635
+ }
636
+ }
637
+ const hidden = out.pages.length - lines.length;
638
+ return [
639
+ `crawled ${url} via ${out.backend}: ${out.pages.length} page${out.pages.length > 1 ? "s" : ""} saved`,
640
+ ...lines,
641
+ ...(hidden > 0 ? [` …and ${hidden} more (list crawl/ to see all)`] : []),
642
+ ...(out.warnings.length ? [`warnings: ${out.warnings.join("; ")}`] : []),
643
+ ].join("\n");
644
+ },
645
+ };
646
+ }
369
647
  return tools;
370
648
  }
371
649
  function verifierToolset() {
@@ -383,6 +661,7 @@ function synthToolset() {
383
661
  return {
384
662
  read_file: all.read_file,
385
663
  list_dir: all.list_dir,
664
+ save_artifact: all.save_artifact,
386
665
  };
387
666
  }
388
667
  // ---------- terminal tool schemas (handled by the agent loop, not executed) ----------
@@ -402,6 +681,35 @@ exports.REPORT_TOOL = {
402
681
  items: { type: "string" },
403
682
  description: "Paths of files you created/changed that matter",
404
683
  },
684
+ key_facts: {
685
+ type: "array",
686
+ items: { type: "string" },
687
+ description: "3-8 standalone facts downstream tasks need (figures, paths, URLs, decisions)",
688
+ },
689
+ open_questions: {
690
+ type: "array",
691
+ items: { type: "string" },
692
+ description: "Unresolved questions or risks the conductor should know about",
693
+ },
694
+ files_touched: {
695
+ type: "array",
696
+ items: { type: "string" },
697
+ description: "Every file you created or modified (exact paths)",
698
+ },
699
+ sources: {
700
+ type: "array",
701
+ description: "Web sources your findings rely on — REQUIRED whenever your work drew on the web. They flow into the final report's bibliography; a web-sourced claim without an entry here cannot be cited.",
702
+ items: {
703
+ type: "object",
704
+ properties: {
705
+ url: { type: "string" },
706
+ title: { type: "string" },
707
+ date: { type: "string", description: "Publication date if known (ISO or year)" },
708
+ note: { type: "string", description: "What this source supports" },
709
+ },
710
+ required: ["url"],
711
+ },
712
+ },
405
713
  },
406
714
  required: ["status", "report"],
407
715
  },
@@ -417,6 +725,19 @@ exports.VERDICT_TOOL = {
417
725
  type: "string",
418
726
  description: "If fail: exactly what is wrong and where. If pass: one-line confirmation of the evidence.",
419
727
  },
728
+ issues: {
729
+ type: "array",
730
+ description: "On fail: one entry per concrete problem. The worker's retry sees these verbatim — make each actionable.",
731
+ items: {
732
+ type: "object",
733
+ properties: {
734
+ problem: { type: "string", description: "What is wrong" },
735
+ evidence: { type: "string", description: "What you observed that proves it (command output, file content, URL)" },
736
+ fix: { type: "string", description: "The exact change that would resolve it" },
737
+ },
738
+ required: ["problem"],
739
+ },
740
+ },
420
741
  },
421
742
  required: ["pass", "feedback"],
422
743
  },
@@ -460,6 +781,17 @@ exports.SPAWN_TASKS_TOOL = {
460
781
  },
461
782
  verify: { type: "boolean", description: "Adversarially verify this task's result before accepting it" },
462
783
  context: { type: "string", description: "Facts, paths, URLs, constraints the worker needs inlined" },
784
+ model: {
785
+ type: "string",
786
+ enum: ["cheap", "default", "strong"],
787
+ description: "Model tier: cheap for scouts/bulk extraction, strong for leads, integration, and verified deliverables",
788
+ },
789
+ team: {
790
+ type: "boolean",
791
+ description: "Run as a sub-swarm: this task gets its own conductor that decomposes it into parallel sub-tasks and reports one consolidated result. Use for coherent multi-task subsystems (e.g. 'build the backend'). Teams cannot spawn teams.",
792
+ },
793
+ team_max_workers: { type: "number", description: "Parallelism inside the team (default: half the run's)" },
794
+ team_budget_tokens: { type: "number", description: "Token slice for the team (default: a quarter of what remains)" },
463
795
  },
464
796
  required: ["title", "objective"],
465
797
  },
@@ -468,6 +800,41 @@ exports.SPAWN_TASKS_TOOL = {
468
800
  required: ["tasks"],
469
801
  },
470
802
  };
803
+ exports.CONDUCTOR_READ_REPORT_TOOL = {
804
+ name: "read_report",
805
+ description: "Read the full report of any settled task. Updates show one-line summaries once many tasks settle — use this when a summary isn't enough to plan from.",
806
+ parameters: {
807
+ type: "object",
808
+ properties: {
809
+ task_id: { type: "string", description: "e.g. T17" },
810
+ },
811
+ required: ["task_id"],
812
+ },
813
+ };
814
+ exports.UPDATE_PLAN_TOOL = {
815
+ name: "update_plan",
816
+ description: "Maintain the mission's living plan document (artifacts/mission-plan.md, full overwrite). On missions beyond ~20 tasks, keep it current: approach, phases, what's done, what's next, open risks. Its head is pinned into every update you receive, surviving history trimming and restarts.",
817
+ parameters: {
818
+ type: "object",
819
+ properties: {
820
+ markdown: { type: "string", description: "The complete plan document (markdown)" },
821
+ },
822
+ required: ["markdown"],
823
+ },
824
+ };
825
+ exports.SET_PHASE_TOOL = {
826
+ name: "set_phase",
827
+ description: "Declare the mission's current phase/milestone. Use on long missions to structure the work (e.g. 'discovery' → 'build' → 'integrate' → 'polish'). The phase and its exit criteria are pinned into every update you receive, surviving history trimming.",
828
+ parameters: {
829
+ type: "object",
830
+ properties: {
831
+ name: { type: "string", description: "Short phase name" },
832
+ goal: { type: "string", description: "What this phase accomplishes" },
833
+ exit_criteria: { type: "string", description: "Concrete conditions that end this phase" },
834
+ },
835
+ required: ["name"],
836
+ },
837
+ };
471
838
  exports.WAIT_TOOL = {
472
839
  name: "wait",
473
840
  description: "Do nothing for now; wake again when running tasks report.",
package/dist/util.js CHANGED
@@ -48,6 +48,7 @@ exports.ensureDir = ensureDir;
48
48
  exports.readJson = readJson;
49
49
  exports.writeJson = writeJson;
50
50
  exports.pathInside = pathInside;
51
+ exports.validateArtifactFormat = validateArtifactFormat;
51
52
  exports.decodeEntities = decodeEntities;
52
53
  exports.htmlToText = htmlToText;
53
54
  const fs = __importStar(require("fs"));
@@ -147,6 +148,90 @@ function pathInside(parent, child) {
147
148
  const rel = path.relative(path.resolve(parent), path.resolve(child));
148
149
  return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
149
150
  }
151
+ // ---------- artifact validation ----------
152
+ /**
153
+ * Cheap structural checks for common deliverable formats — catches a worker
154
+ * shipping malformed JSON/CSV/stub HTML before an LLM verifier spends tokens
155
+ * on it. Returns a problem description, or null when the file looks sound
156
+ * (or is a format we don't check).
157
+ */
158
+ function validateArtifactFormat(absPath) {
159
+ const ext = path.extname(absPath).toLowerCase();
160
+ if (![".json", ".csv", ".html", ".htm"].includes(ext))
161
+ return null;
162
+ let raw;
163
+ try {
164
+ raw = fs.readFileSync(absPath, "utf8");
165
+ }
166
+ catch {
167
+ return null; // existence/size is the caller's check
168
+ }
169
+ if (ext === ".json") {
170
+ try {
171
+ JSON.parse(raw);
172
+ return null;
173
+ }
174
+ catch (e) {
175
+ return `not valid JSON (${errMsg(e)})`;
176
+ }
177
+ }
178
+ if (ext === ".csv") {
179
+ const counts = csvFieldCounts(raw, 50);
180
+ if (!counts.length)
181
+ return "CSV has no records";
182
+ const expect = counts[0];
183
+ const bad = counts.findIndex((c) => c !== expect);
184
+ if (bad > 0)
185
+ return `inconsistent CSV: record 1 has ${expect} field(s), record ${bad + 1} has ${counts[bad]}`;
186
+ return null;
187
+ }
188
+ // .html / .htm — catch empty shells and plain text passed off as HTML.
189
+ if (raw.length < 200 || !/<[a-z!][^>]*>/i.test(raw) || !/<\/[a-z][a-z0-9]*>/i.test(raw)) {
190
+ return "HTML looks like a stub (too short or no real markup)";
191
+ }
192
+ return null;
193
+ }
194
+ /** Field count per CSV record (quote-aware, handles newlines inside quotes). */
195
+ function csvFieldCounts(raw, maxRecords) {
196
+ const counts = [];
197
+ let fields = 1;
198
+ let chars = 0; // non-separator chars seen in the current record
199
+ let inQ = false;
200
+ for (let i = 0; i < raw.length && counts.length < maxRecords; i++) {
201
+ const ch = raw[i];
202
+ if (inQ) {
203
+ if (ch === '"') {
204
+ if (raw[i + 1] === '"')
205
+ i++;
206
+ else
207
+ inQ = false;
208
+ }
209
+ chars++;
210
+ }
211
+ else if (ch === '"') {
212
+ inQ = true;
213
+ chars++;
214
+ }
215
+ else if (ch === ",") {
216
+ fields++;
217
+ chars++;
218
+ }
219
+ else if (ch === "\n" || ch === "\r") {
220
+ if (ch === "\r" && raw[i + 1] === "\n")
221
+ i++;
222
+ if (chars > 0)
223
+ counts.push(fields); // skip blank lines
224
+ fields = 1;
225
+ chars = 0;
226
+ }
227
+ else {
228
+ chars++;
229
+ }
230
+ }
231
+ if (chars > 0 && counts.length < maxRecords)
232
+ counts.push(fields);
233
+ return counts;
234
+ }
150
235
  // ---------- html ----------
151
236
  const ENTITIES = {
152
237
  amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ",