@electric-ax/agents 0.1.0 → 0.1.3

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.
@@ -3,11 +3,11 @@ import { createServer } from "node:http";
3
3
  import path, { dirname, relative, resolve } from "node:path";
4
4
  import fs from "node:fs";
5
5
  import pino from "pino";
6
- import { createEntityRegistry, createRuntimeHandler } from "@electric-ax/agents-runtime";
6
+ import { fileURLToPath } from "node:url";
7
+ import { createEntityRegistry, createRuntimeHandler, db } from "@electric-ax/agents-runtime";
7
8
  import Anthropic from "@anthropic-ai/sdk";
8
9
  import { createHash } from "node:crypto";
9
10
  import fs$1, { mkdir, readFile, stat, writeFile } from "node:fs/promises";
10
- import { fileURLToPath } from "node:url";
11
11
  import Database from "better-sqlite3";
12
12
  import { Type } from "@sinclair/typebox";
13
13
  import { load } from "sqlite-vec";
@@ -238,12 +238,12 @@ function createFingerprint(entries) {
238
238
  }
239
239
  return hash.digest(`hex`);
240
240
  }
241
- function getMeta(db, key) {
242
- const row = db.prepare(`select value from index_meta where key = ?`).get(key);
241
+ function getMeta(db$1, key) {
242
+ const row = db$1.prepare(`select value from index_meta where key = ?`).get(key);
243
243
  return row?.value ?? null;
244
244
  }
245
- function setMeta(db, key, value) {
246
- db.prepare(`insert into index_meta(key, value) values(?, ?)
245
+ function setMeta(db$1, key, value) {
246
+ db$1.prepare(`insert into index_meta(key, value) values(?, ?)
247
247
  on conflict(key) do update set value = excluded.value`).run(key, value);
248
248
  }
249
249
  function reciprocalRank(rank, k = 60) {
@@ -311,11 +311,11 @@ var DocsKnowledgeBase = class {
311
311
  openDatabase() {
312
312
  fs.mkdirSync(path.dirname(this.dbPath), { recursive: true });
313
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;
314
+ const db$1 = new Database(this.dbPath);
315
+ load(db$1);
316
+ db$1.pragma(`journal_mode = WAL`);
317
+ db$1.pragma(`synchronous = NORMAL`);
318
+ return db$1;
319
319
  } catch (error) {
320
320
  const message = error instanceof Error ? error.message : String(error);
321
321
  console.warn(`${this.logPrefix} falling back to in-memory docs index: ${message}`);
@@ -408,17 +408,17 @@ var DocsKnowledgeBase = class {
408
408
  console.log(`${this.logPrefix} indexed ${stats$1.docCount} docs into ${stats$1.chunkCount} chunks (${stats$1.fingerprint.slice(0, 12)}...)`);
409
409
  return stats$1;
410
410
  }
411
- const db = this.db;
412
- const currentFingerprint = getMeta(db, DOCS_FINGERPRINT_KEY);
413
- const currentVersion = getMeta(db, INDEX_VERSION_KEY);
411
+ const db$1 = this.db;
412
+ const currentFingerprint = getMeta(db$1, DOCS_FINGERPRINT_KEY);
413
+ const currentVersion = getMeta(db$1, INDEX_VERSION_KEY);
414
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)
415
+ const insertDoc = db$1.prepare(`insert into docs(path, title, content) values(?, ?, ?)`);
416
+ const insertChunk = db$1.prepare(`insert into chunks(doc_path, title, heading, chunk_index, content, embedding)
417
417
  values(?, ?, ?, ?, ?, vec_f32(?))`);
418
- const insertFts = db.prepare(`insert into chunks_fts(rowid, doc_path, title, heading, content)
418
+ const insertFts = db$1.prepare(`insert into chunks_fts(rowid, doc_path, title, heading, content)
419
419
  values(?, ?, ?, ?, ?)`);
420
- const reset = db.transaction(() => {
421
- db.exec(`
420
+ const reset = db$1.transaction(() => {
421
+ db$1.exec(`
422
422
  delete from chunks_fts;
423
423
  delete from chunks;
424
424
  delete from docs;
@@ -437,8 +437,8 @@ var DocsKnowledgeBase = class {
437
437
  insertFts.run(Number(result.lastInsertRowid), chunk.docPath, chunk.title, chunk.heading, chunk.content);
438
438
  }
439
439
  }
440
- setMeta(db, DOCS_FINGERPRINT_KEY, fingerprint);
441
- setMeta(db, INDEX_VERSION_KEY, INDEX_VERSION);
440
+ setMeta(db$1, DOCS_FINGERPRINT_KEY, fingerprint);
441
+ setMeta(db$1, INDEX_VERSION_KEY, INDEX_VERSION);
442
442
  });
443
443
  reset();
444
444
  const stats = this.stats();
@@ -661,6 +661,184 @@ function createHortonDocsSupport(workingDirectory, opts = {}) {
661
661
  };
662
662
  }
663
663
 
664
+ //#endregion
665
+ //#region src/skills/tools.ts
666
+ function skillContextId(name) {
667
+ return `skill:${name}`;
668
+ }
669
+ function createSkillTools(registry, ctx) {
670
+ const useSkill = {
671
+ name: `use_skill`,
672
+ label: `Use Skill`,
673
+ description: `Load a skill into your context. Call with a skill name to load it. Pass args if the skill accepts arguments.`,
674
+ parameters: Type.Object({
675
+ name: Type.String({ description: `Name of the skill to load` }),
676
+ args: Type.Optional(Type.String({ description: `Arguments to pass to the skill (space-separated, or quoted for multi-word values)` }))
677
+ }),
678
+ execute: async (_toolCallId, params) => {
679
+ const { name, args } = params;
680
+ const meta = registry.catalog.get(name);
681
+ if (!meta) {
682
+ const available = Array.from(registry.catalog.keys()).join(`, `);
683
+ return {
684
+ content: [{
685
+ type: `text`,
686
+ text: `Skill "${name}" not found. Available skills: ${available || `none`}`
687
+ }],
688
+ details: { loaded: false }
689
+ };
690
+ }
691
+ const contextId = skillContextId(name);
692
+ if (ctx.getContext(contextId)) return {
693
+ content: [{
694
+ type: `text`,
695
+ text: `Skill "${name}" is already loaded.`
696
+ }],
697
+ details: {
698
+ loaded: false,
699
+ alreadyLoaded: true
700
+ }
701
+ };
702
+ let content = await registry.readContent(name);
703
+ if (content === null) return {
704
+ content: [{
705
+ type: `text`,
706
+ text: `Error: could not read skill file for "${name}".`
707
+ }],
708
+ details: { loaded: false }
709
+ };
710
+ let truncated = false;
711
+ if (content.length > meta.max) {
712
+ truncated = true;
713
+ content = content.slice(0, meta.max);
714
+ }
715
+ if (args) content = substituteArgs(content, args, meta.arguments);
716
+ ctx.insertContext(contextId, {
717
+ name: `skill_instructions`,
718
+ attrs: {
719
+ skill: name,
720
+ type: `directive`
721
+ },
722
+ content
723
+ });
724
+ const skillDir = path.join(path.dirname(meta.source), name);
725
+ const truncNote = truncated ? `\n\nWARNING: Content was truncated from ${meta.charCount.toLocaleString()} to ${meta.max.toLocaleString()} chars. Inform the user.` : ``;
726
+ const allRefFiles = listRefFiles(skillDir);
727
+ const mdFiles = allRefFiles.filter((f) => f.endsWith(`.md`));
728
+ const refContents = [];
729
+ for (const f of mdFiles) try {
730
+ const refContent = await fs$1.readFile(path.join(skillDir, f), `utf-8`);
731
+ const refId = `${skillContextId(name)}:${f}`;
732
+ ctx.insertContext(refId, {
733
+ name: `skill_reference`,
734
+ attrs: {
735
+ skill: name,
736
+ file: f
737
+ },
738
+ content: refContent
739
+ });
740
+ refContents.push(`--- ${f} ---\n${refContent}`);
741
+ } catch {}
742
+ const hasRefDir = allRefFiles.length > 0;
743
+ const dirNote = hasRefDir ? `\nSkill directory: ${skillDir}` : ``;
744
+ const refSection = refContents.length > 0 ? `\n\n${refContents.join(`\n\n`)}` : ``;
745
+ const toolResult = `SKILL ACTIVATED: "${name}". The instructions below override your default behavior. Follow them exactly. Do not read any files to find this content — it is all here.\n${dirNote}${truncNote}\n\n${content}${refSection}`;
746
+ return {
747
+ content: [{
748
+ type: `text`,
749
+ text: toolResult
750
+ }],
751
+ details: {
752
+ loaded: true,
753
+ truncated,
754
+ chars: content.length
755
+ }
756
+ };
757
+ }
758
+ };
759
+ const removeSkill = {
760
+ name: `remove_skill`,
761
+ label: `Remove Skill`,
762
+ description: `Unload a previously loaded skill from your context.`,
763
+ parameters: Type.Object({ name: Type.String({ description: `Name of the skill to remove` }) }),
764
+ execute: async (_toolCallId, params) => {
765
+ const { name } = params;
766
+ ctx.removeContext(skillContextId(name));
767
+ const meta = registry.catalog.get(name);
768
+ if (meta) {
769
+ const skillDir = path.join(path.dirname(meta.source), name);
770
+ for (const f of listRefFiles(skillDir)) ctx.removeContext(`${skillContextId(name)}:${f}`);
771
+ }
772
+ return {
773
+ content: [{
774
+ type: `text`,
775
+ text: `Skill "${name}" removed from context.`
776
+ }],
777
+ details: { removed: true }
778
+ };
779
+ }
780
+ };
781
+ return [useSkill, removeSkill];
782
+ }
783
+ function parseArgs(raw) {
784
+ const args = [];
785
+ let current = ``;
786
+ let inQuote = false;
787
+ let quoteChar = ``;
788
+ for (const ch of raw) if (inQuote) if (ch === quoteChar) inQuote = false;
789
+ else current += ch;
790
+ else if (ch === `"` || ch === `'`) {
791
+ inQuote = true;
792
+ quoteChar = ch;
793
+ } else if (ch === ` ` || ch === `\t`) {
794
+ if (current.length > 0) {
795
+ args.push(current);
796
+ current = ``;
797
+ }
798
+ } else current += ch;
799
+ if (current.length > 0) args.push(current);
800
+ return args;
801
+ }
802
+ function substituteArgs(content, rawArgs, argNames) {
803
+ const parsed = parseArgs(rawArgs);
804
+ let result = content;
805
+ let matched = false;
806
+ if (argNames) for (let i = 0; i < argNames.length && i < parsed.length; i++) {
807
+ const pattern = new RegExp(`\\$${argNames[i]}\\b`, `g`);
808
+ if (pattern.test(result)) {
809
+ result = result.replace(pattern, parsed[i]);
810
+ matched = true;
811
+ }
812
+ }
813
+ for (let i = 0; i < parsed.length; i++) {
814
+ const pattern = new RegExp(`\\$${i}\\b`, `g`);
815
+ if (pattern.test(result)) {
816
+ result = result.replace(pattern, parsed[i]);
817
+ matched = true;
818
+ }
819
+ }
820
+ if (result.includes(`$ARGUMENTS`)) {
821
+ result = result.replace(/\$ARGUMENTS/g, rawArgs);
822
+ matched = true;
823
+ }
824
+ if (!matched) result += `\n\nArguments: ${rawArgs}`;
825
+ return result;
826
+ }
827
+ function listRefFiles(dir, prefix = ``) {
828
+ try {
829
+ const results = [];
830
+ for (const entry of fs.readdirSync(dir)) {
831
+ const full = path.join(dir, entry);
832
+ const rel = prefix ? `${prefix}/${entry}` : entry;
833
+ if (fs.statSync(full).isDirectory()) results.push(...listRefFiles(full, rel));
834
+ else results.push(rel);
835
+ }
836
+ return results;
837
+ } catch {
838
+ return [];
839
+ }
840
+ }
841
+
664
842
  //#endregion
665
843
  //#region src/tools/bash.ts
666
844
  const TIMEOUT_MS = 3e4;
@@ -1258,7 +1436,22 @@ async function generateTitle(userMessage, llmCall = defaultHaikuCall) {
1258
1436
  }
1259
1437
  function buildHortonSystemPrompt(workingDirectory, opts = {}) {
1260
1438
  const docsTools = opts.hasDocsSupport ? `\n- search_durable_agents_docs: hybrid search over the built-in Durable Agents docs index` : ``;
1439
+ const skillsTools = opts.hasSkills ? `\n- use_skill: load a skill (knowledge, instructions, or a tutorial) into your context to help with the user's request\n- remove_skill: unload a skill from context when you're done with it` : ``;
1261
1440
  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.` : ``;
1441
+ const skillsGuidance = opts.hasSkills ? `\n# Skills\nYou have access to skills — specialized knowledge and guided workflows you can load on demand. Your context includes a skills catalog listing what's available. When the user's request matches a skill's description or keywords, load it with use_skill.
1442
+
1443
+ Some skills are user-invocable — the user can trigger them with a slash command like \`/tutorial\`. When you see a message starting with \`/\` followed by a skill name, load that skill immediately with use_skill. Pass any text after the skill name as args.
1444
+
1445
+ ## IMPORTANT: How to use a loaded skill
1446
+
1447
+ When you load a skill, it becomes your primary directive for that interaction. Follow the skill's instructions exactly:
1448
+
1449
+ 1. **Read all reference files first.** The use_skill tool response lists reference files with absolute paths. Read ALL of them with your read tool before responding to the user. These files contain the actual content the skill needs — without them you're guessing.
1450
+ 2. **Follow the skill's conversation flow.** If the skill defines steps, follow them in order. Do not improvise your own approach.
1451
+ 3. **Adopt the skill's persona and teaching style.** The skill defines how to interact — follow it.
1452
+ 4. **Unload when done.** Use remove_skill to free context space when the skill's workflow is complete.
1453
+
1454
+ Do NOT load a skill and then ignore its instructions. The skill is there because it contains a tested, specific workflow. Your job is to execute it faithfully.` : ``;
1262
1455
  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
1456
 
1264
1457
  # Tools
@@ -1269,13 +1462,13 @@ function buildHortonSystemPrompt(workingDirectory, opts = {}) {
1269
1462
  - brave_search: search the web
1270
1463
  - fetch_url: fetch and convert a URL to markdown
1271
1464
  - spawn_worker: dispatch a subagent for an isolated task
1272
- ${docsTools}
1465
+ ${docsTools}${skillsTools}
1273
1466
 
1274
1467
  # Working with files
1275
1468
  - Prefer edit over write when modifying existing files.
1276
1469
  - You must read a file before you can edit it.
1277
1470
  - Use absolute paths or paths relative to the current working directory.
1278
- ${docsGuidance}
1471
+ ${docsGuidance}${skillsGuidance}
1279
1472
 
1280
1473
  # Risky actions
1281
1474
  Pause and confirm with the user before:
@@ -1326,10 +1519,15 @@ function extractFirstUserMessage(events) {
1326
1519
  return null;
1327
1520
  }
1328
1521
  function createAssistantHandler(options) {
1329
- const { workingDirectory, streamFn, docsSupport, docsSearchTool } = options;
1522
+ const { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry } = options;
1523
+ const hasSkills = Boolean(skillsRegistry && skillsRegistry.catalog.size > 0);
1330
1524
  return async function assistantHandler(ctx, wake) {
1331
1525
  const readSet = new Set();
1332
- const tools = [...ctx.electricTools, ...createHortonTools(workingDirectory, ctx, readSet, { docsSearchTool })];
1526
+ const tools = [
1527
+ ...ctx.electricTools,
1528
+ ...createHortonTools(workingDirectory, ctx, readSet, { docsSearchTool }),
1529
+ ...skillsRegistry && skillsRegistry.catalog.size > 0 ? createSkillTools(skillsRegistry, ctx) : []
1530
+ ];
1333
1531
  if (docsSupport) ctx.useContext({
1334
1532
  sourceBudget: 1e5,
1335
1533
  sources: {
@@ -1343,6 +1541,25 @@ function createAssistantHandler(options) {
1343
1541
  max: 6e3,
1344
1542
  cache: `volatile`
1345
1543
  },
1544
+ conversation: {
1545
+ content: () => ctx.timelineMessages(),
1546
+ cache: `volatile`
1547
+ },
1548
+ ...skillsRegistry && skillsRegistry.catalog.size > 0 ? { skills_catalog: {
1549
+ content: () => skillsRegistry.renderCatalog(2e3),
1550
+ max: 2e3,
1551
+ cache: `stable`
1552
+ } } : {}
1553
+ }
1554
+ });
1555
+ else if (skillsRegistry && skillsRegistry.catalog.size > 0) ctx.useContext({
1556
+ sourceBudget: 1e5,
1557
+ sources: {
1558
+ skills_catalog: {
1559
+ content: () => skillsRegistry.renderCatalog(2e3),
1560
+ max: 2e3,
1561
+ cache: `stable`
1562
+ },
1346
1563
  conversation: {
1347
1564
  content: () => ctx.timelineMessages(),
1348
1565
  cache: `volatile`
@@ -1350,7 +1567,10 @@ function createAssistantHandler(options) {
1350
1567
  }
1351
1568
  });
1352
1569
  ctx.useAgent({
1353
- systemPrompt: buildHortonSystemPrompt(workingDirectory, { hasDocsSupport: Boolean(docsSupport) }),
1570
+ systemPrompt: buildHortonSystemPrompt(workingDirectory, {
1571
+ hasDocsSupport: Boolean(docsSupport),
1572
+ hasSkills
1573
+ }),
1354
1574
  model: HORTON_MODEL,
1355
1575
  tools,
1356
1576
  ...streamFn && { streamFn }
@@ -1376,7 +1596,7 @@ function createAssistantHandler(options) {
1376
1596
  };
1377
1597
  }
1378
1598
  function registerHorton(registry, options) {
1379
- const { workingDirectory, streamFn } = options;
1599
+ const { workingDirectory, streamFn, skillsRegistry = null } = options;
1380
1600
  const docsSupport = createHortonDocsSupport(workingDirectory);
1381
1601
  const docsSearchTool = docsSupport?.createSearchTool();
1382
1602
  docsSupport?.ensureReady().catch((error) => {
@@ -1386,7 +1606,8 @@ function registerHorton(registry, options) {
1386
1606
  workingDirectory,
1387
1607
  streamFn,
1388
1608
  docsSupport,
1389
- docsSearchTool
1609
+ docsSearchTool,
1610
+ skillsRegistry
1390
1611
  });
1391
1612
  registry.define(`horton`, {
1392
1613
  description: `Friendly capable assistant — chat, code, research, dispatch`,
@@ -1408,18 +1629,33 @@ function registerHorton(registry, options) {
1408
1629
  function isWorkerToolName(value) {
1409
1630
  return typeof value === `string` && WORKER_TOOL_NAMES.includes(value);
1410
1631
  }
1632
+ function isRecord(value) {
1633
+ return value !== null && typeof value === `object`;
1634
+ }
1411
1635
  function parseWorkerArgs(value) {
1412
1636
  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
1637
  const tools = [];
1415
- for (const t of value.tools) {
1638
+ if (Array.isArray(value.tools)) for (const t of value.tools) {
1416
1639
  if (!isWorkerToolName(t)) throw new Error(`[worker] unknown tool name: ${JSON.stringify(t)}. Valid tools: ${WORKER_TOOL_NAMES.join(`, `)}`);
1417
1640
  if (!tools.includes(t)) tools.push(t);
1418
1641
  }
1419
- return {
1642
+ const args = {
1420
1643
  systemPrompt: value.systemPrompt,
1421
1644
  tools
1422
1645
  };
1646
+ if (value.sharedDbToolMode === `full` || value.sharedDbToolMode === `write-only`) args.sharedDbToolMode = value.sharedDbToolMode;
1647
+ if (value.sharedDb !== void 0) {
1648
+ if (!isRecord(value.sharedDb)) throw new Error(`[worker] sharedDb must be an object`);
1649
+ const { id, schema } = value.sharedDb;
1650
+ if (typeof id !== `string` || id.length === 0) throw new Error(`[worker] sharedDb.id must be a non-empty string`);
1651
+ if (!isRecord(schema)) throw new Error(`[worker] sharedDb.schema must be an object`);
1652
+ args.sharedDb = {
1653
+ id,
1654
+ schema
1655
+ };
1656
+ }
1657
+ if (tools.length === 0 && !args.sharedDb) throw new Error(`[worker] must provide tools and/or sharedDb`);
1658
+ return args;
1423
1659
  }
1424
1660
  function buildToolsForWorker(tools, workingDirectory, ctx, readSet) {
1425
1661
  const out = [];
@@ -1452,18 +1688,115 @@ const WORKER_PROMPT_FOOTER = `
1452
1688
 
1453
1689
  # Reporting back
1454
1690
  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.`;
1691
+ function buildSharedStateTools(shared, schema, mode) {
1692
+ const tools = [];
1693
+ for (const [collectionName] of Object.entries(schema)) {
1694
+ if (collectionName === `id`) continue;
1695
+ const handle = shared[collectionName];
1696
+ if (!handle) continue;
1697
+ tools.push({
1698
+ name: `write_${collectionName}`,
1699
+ label: `Write ${collectionName}`,
1700
+ description: `Write an entry to the shared ${collectionName} collection. The data must include a unique 'key' field.`,
1701
+ parameters: Type.Object({ data: Type.Record(Type.String(), Type.Unknown(), { description: `The data object to write` }) }),
1702
+ execute: async (_id, params) => {
1703
+ const { data } = params;
1704
+ handle.insert(data);
1705
+ return {
1706
+ content: [{
1707
+ type: `text`,
1708
+ text: `Written to ${collectionName}: ${JSON.stringify(data)}`
1709
+ }],
1710
+ details: {}
1711
+ };
1712
+ }
1713
+ });
1714
+ if (mode === `write-only`) continue;
1715
+ tools.push({
1716
+ name: `read_${collectionName}`,
1717
+ label: `Read ${collectionName}`,
1718
+ description: `Read all entries from the shared ${collectionName} collection.`,
1719
+ parameters: Type.Object({}),
1720
+ execute: async () => {
1721
+ const entries = handle.toArray;
1722
+ return {
1723
+ content: [{
1724
+ type: `text`,
1725
+ text: JSON.stringify(entries, null, 2)
1726
+ }],
1727
+ details: {}
1728
+ };
1729
+ }
1730
+ });
1731
+ tools.push({
1732
+ name: `update_${collectionName}`,
1733
+ label: `Update ${collectionName}`,
1734
+ description: `Update an existing entry in the shared ${collectionName} collection by key.`,
1735
+ parameters: Type.Object({
1736
+ key: Type.String({ description: `The key of the entry to update` }),
1737
+ data: Type.Record(Type.String(), Type.Unknown(), { description: `The fields to update` })
1738
+ }),
1739
+ execute: async (_id, params) => {
1740
+ const { key, data } = params;
1741
+ try {
1742
+ handle.update(key, (draft) => {
1743
+ Object.assign(draft, data);
1744
+ });
1745
+ } catch (err) {
1746
+ return {
1747
+ content: [{
1748
+ type: `text`,
1749
+ text: `Failed to update ${collectionName} entry "${key}": ${err instanceof Error ? err.message : String(err)}`
1750
+ }],
1751
+ details: {}
1752
+ };
1753
+ }
1754
+ return {
1755
+ content: [{
1756
+ type: `text`,
1757
+ text: `Updated ${collectionName} entry "${key}"`
1758
+ }],
1759
+ details: {}
1760
+ };
1761
+ }
1762
+ });
1763
+ tools.push({
1764
+ name: `delete_${collectionName}`,
1765
+ label: `Delete ${collectionName}`,
1766
+ description: `Delete an entry from the shared ${collectionName} collection by key.`,
1767
+ parameters: Type.Object({ key: Type.String({ description: `The key of the entry to delete` }) }),
1768
+ execute: async (_id, params) => {
1769
+ const { key } = params;
1770
+ handle.delete(key);
1771
+ return {
1772
+ content: [{
1773
+ type: `text`,
1774
+ text: `Deleted ${collectionName} entry "${key}"`
1775
+ }],
1776
+ details: {}
1777
+ };
1778
+ }
1779
+ });
1780
+ }
1781
+ return tools;
1782
+ }
1455
1783
  function registerWorker(registry, options) {
1456
1784
  const { workingDirectory, streamFn } = options;
1457
1785
  registry.define(`worker`, {
1458
- description: `Internal — generic worker spawned by other agents. Configure via spawn args (systemPrompt + tools).`,
1786
+ description: `Internal — generic worker spawned by other agents. Configure via spawn args (systemPrompt + tools + optional sharedDb).`,
1459
1787
  async handler(ctx) {
1460
1788
  const args = parseWorkerArgs(ctx.args);
1461
1789
  const readSet = new Set();
1462
- const tools = buildToolsForWorker(args.tools, workingDirectory, ctx, readSet);
1790
+ const builtinTools = buildToolsForWorker(args.tools, workingDirectory, ctx, readSet);
1791
+ const sharedStateTools = [];
1792
+ if (args.sharedDb) {
1793
+ const shared = await ctx.observe(db(args.sharedDb.id, args.sharedDb.schema));
1794
+ sharedStateTools.push(...buildSharedStateTools(shared, args.sharedDb.schema, args.sharedDbToolMode ?? `full`));
1795
+ }
1463
1796
  ctx.useAgent({
1464
1797
  systemPrompt: `${args.systemPrompt}${WORKER_PROMPT_FOOTER}`,
1465
1798
  model: HORTON_MODEL,
1466
- tools,
1799
+ tools: [...builtinTools, ...sharedStateTools],
1467
1800
  ...streamFn && { streamFn }
1468
1801
  });
1469
1802
  await ctx.agent.run();
@@ -1471,20 +1804,289 @@ function registerWorker(registry, options) {
1471
1804
  });
1472
1805
  }
1473
1806
 
1807
+ //#endregion
1808
+ //#region src/skills/preamble.ts
1809
+ function parsePreamble(content) {
1810
+ const lines = content.split(`\n`);
1811
+ if (lines[0]?.trim() !== `---`) return {};
1812
+ let closingIndex = -1;
1813
+ for (let i = 1; i < Math.min(lines.length, 25); i++) if (lines[i]?.trim() === `---`) {
1814
+ closingIndex = i;
1815
+ break;
1816
+ }
1817
+ if (closingIndex === -1) return {};
1818
+ const result = {};
1819
+ for (let i = 1; i < closingIndex; i++) {
1820
+ const line = lines[i];
1821
+ const colonIndex = line.indexOf(`:`);
1822
+ if (colonIndex === -1) continue;
1823
+ const key = line.slice(0, colonIndex).trim();
1824
+ const rawValue = line.slice(colonIndex + 1).trim();
1825
+ switch (key) {
1826
+ case `description`:
1827
+ result.description = stripQuotes(rawValue);
1828
+ break;
1829
+ case `whenToUse`:
1830
+ result.whenToUse = stripQuotes(rawValue);
1831
+ break;
1832
+ case `keywords`: {
1833
+ if (rawValue.length === 0) {
1834
+ const items = [];
1835
+ for (let j = i + 1; j < closingIndex; j++) {
1836
+ const next = lines[j];
1837
+ const match = next.match(/^\s+-\s+(.+)$/);
1838
+ if (match) {
1839
+ items.push(match[1].trim());
1840
+ i = j;
1841
+ } else break;
1842
+ }
1843
+ result.keywords = items;
1844
+ } else result.keywords = parseKeywords(rawValue);
1845
+ break;
1846
+ }
1847
+ case `arguments`: {
1848
+ if (rawValue.length === 0) {
1849
+ const items = [];
1850
+ for (let j = i + 1; j < closingIndex; j++) {
1851
+ const next = lines[j];
1852
+ const match = next.match(/^\s+-\s+(.+)$/);
1853
+ if (match) {
1854
+ items.push(match[1].trim());
1855
+ i = j;
1856
+ } else break;
1857
+ }
1858
+ result.arguments = items;
1859
+ } else result.arguments = parseKeywords(rawValue);
1860
+ break;
1861
+ }
1862
+ case `argument-hint`:
1863
+ result.argumentHint = stripQuotes(rawValue);
1864
+ break;
1865
+ case `user-invocable`:
1866
+ result.userInvocable = rawValue === `true`;
1867
+ break;
1868
+ case `max`: {
1869
+ const num = parseInt(rawValue, 10);
1870
+ if (!Number.isNaN(num) && num > 0) result.max = num;
1871
+ break;
1872
+ }
1873
+ }
1874
+ }
1875
+ return result;
1876
+ }
1877
+ function parseKeywords(raw) {
1878
+ const stripped = raw.replace(/^\[/, ``).replace(/\]$/, ``);
1879
+ return stripped.split(`,`).map((s) => s.trim()).filter((s) => s.length > 0);
1880
+ }
1881
+ function stripQuotes(value) {
1882
+ if (value.length >= 2 && value.startsWith(`"`) && value.endsWith(`"`)) return value.slice(1, -1);
1883
+ return value;
1884
+ }
1885
+
1886
+ //#endregion
1887
+ //#region src/skills/extract-meta.ts
1888
+ const EXTRACT_MODEL = `claude-haiku-4-5-20251001`;
1889
+ const DEFAULT_MAX = 1e4;
1890
+ async function extractSkillMeta(name, content) {
1891
+ const preamble = parsePreamble(content);
1892
+ if (preamble.description && preamble.whenToUse && preamble.keywords) return {
1893
+ description: preamble.description,
1894
+ whenToUse: preamble.whenToUse,
1895
+ keywords: preamble.keywords,
1896
+ ...preamble.arguments && { arguments: preamble.arguments },
1897
+ ...preamble.argumentHint && { argumentHint: preamble.argumentHint },
1898
+ ...preamble.userInvocable && { userInvocable: true },
1899
+ max: preamble.max ?? DEFAULT_MAX
1900
+ };
1901
+ if (process.env.ANTHROPIC_API_KEY) try {
1902
+ return await llmExtract(name, content, preamble);
1903
+ } catch (err) {
1904
+ serverLog.warn(`[skills] LLM metadata extraction failed for "${name}": ${err instanceof Error ? err.message : String(err)}`);
1905
+ }
1906
+ return {
1907
+ description: preamble.description ?? humanize(name),
1908
+ whenToUse: preamble.whenToUse ?? `User asks about ${humanize(name).toLowerCase()}`,
1909
+ keywords: preamble.keywords ?? [name],
1910
+ max: preamble.max ?? DEFAULT_MAX
1911
+ };
1912
+ }
1913
+ async function llmExtract(name, content, partial) {
1914
+ const client = new Anthropic();
1915
+ const truncated = content.slice(0, 8e3);
1916
+ const prompt = `Analyze this skill document and extract metadata. The skill is named "${name}".
1917
+
1918
+ <skill>
1919
+ ${truncated}
1920
+ </skill>
1921
+
1922
+ Return ONLY a JSON object with these fields:
1923
+ - "description": one-line summary of what this skill provides (max 100 chars)
1924
+ - "whenToUse": when should an AI agent load this skill (max 200 chars)
1925
+ - "keywords": array of 3-8 relevant keywords
1926
+
1927
+ Return raw JSON, no markdown fences.`;
1928
+ const res = await client.messages.create({
1929
+ model: EXTRACT_MODEL,
1930
+ max_tokens: 256,
1931
+ messages: [{
1932
+ role: `user`,
1933
+ content: prompt
1934
+ }]
1935
+ });
1936
+ const text = res.content[0]?.type === `text` ? res.content[0].text : ``;
1937
+ const parsed = JSON.parse(text);
1938
+ return {
1939
+ description: partial.description ?? parsed.description ?? humanize(name),
1940
+ whenToUse: partial.whenToUse ?? parsed.whenToUse ?? `User asks about ${name}`,
1941
+ keywords: partial.keywords ?? parsed.keywords ?? [name],
1942
+ max: partial.max ?? DEFAULT_MAX
1943
+ };
1944
+ }
1945
+ function humanize(name) {
1946
+ return name.replace(/[-_]/g, ` `).replace(/\b\w/g, (c) => c.toUpperCase());
1947
+ }
1948
+
1949
+ //#endregion
1950
+ //#region src/skills/registry.ts
1951
+ const CACHE_FILENAME = `skills-cache.json`;
1952
+ async function createSkillsRegistry(opts) {
1953
+ const { baseSkillsDir, appSkillsDir, cacheDir } = opts;
1954
+ const cachePath = path.join(cacheDir, CACHE_FILENAME);
1955
+ const existingCache = await loadCache(cachePath);
1956
+ const files = new Map();
1957
+ await scanDir(baseSkillsDir, files);
1958
+ if (appSkillsDir) await scanDir(appSkillsDir, files);
1959
+ const catalog = new Map();
1960
+ for (const [name, filePath] of files) {
1961
+ const content = await fs$1.readFile(filePath, `utf-8`);
1962
+ const hash = sha256(content);
1963
+ const cached = existingCache[name];
1964
+ if (cached && cached.contentHash === hash && cached.source === filePath) {
1965
+ catalog.set(name, cached);
1966
+ continue;
1967
+ }
1968
+ serverLog.info(`[skills] extracting metadata for "${name}"`);
1969
+ const meta = await extractSkillMeta(name, content);
1970
+ const entry = {
1971
+ name,
1972
+ ...meta,
1973
+ charCount: content.length,
1974
+ contentHash: hash,
1975
+ source: filePath
1976
+ };
1977
+ catalog.set(name, entry);
1978
+ }
1979
+ await saveCache(cachePath, catalog, cacheDir);
1980
+ return {
1981
+ catalog,
1982
+ renderCatalog(budget) {
1983
+ if (catalog.size === 0) return ``;
1984
+ const skills = Array.from(catalog.values());
1985
+ const full = renderSkillList(skills, `full`);
1986
+ if (!budget || full.length <= budget) return full;
1987
+ const compact = renderSkillList(skills, `compact`);
1988
+ if (compact.length <= budget) return compact;
1989
+ return renderSkillList(skills, `names`);
1990
+ },
1991
+ async readContent(name) {
1992
+ const meta = catalog.get(name);
1993
+ if (!meta) return null;
1994
+ try {
1995
+ return await fs$1.readFile(meta.source, `utf-8`);
1996
+ } catch {
1997
+ return null;
1998
+ }
1999
+ }
2000
+ };
2001
+ }
2002
+ async function scanDir(dir, out) {
2003
+ let entries;
2004
+ try {
2005
+ entries = await fs$1.readdir(dir, { withFileTypes: true });
2006
+ } catch {
2007
+ return;
2008
+ }
2009
+ for (const entry of entries) {
2010
+ if (!entry.isFile() || !entry.name.endsWith(`.md`)) continue;
2011
+ const name = entry.name.slice(0, -3);
2012
+ out.set(name, path.resolve(dir, entry.name));
2013
+ }
2014
+ }
2015
+ async function loadCache(cachePath) {
2016
+ try {
2017
+ const raw = await fs$1.readFile(cachePath, `utf-8`);
2018
+ return JSON.parse(raw);
2019
+ } catch {
2020
+ return {};
2021
+ }
2022
+ }
2023
+ async function saveCache(cachePath, catalog, cacheDir) {
2024
+ const obj = {};
2025
+ for (const [name, meta] of catalog) obj[name] = meta;
2026
+ fs.mkdirSync(cacheDir, { recursive: true });
2027
+ await fs$1.writeFile(cachePath, JSON.stringify(obj, null, 2), `utf-8`);
2028
+ }
2029
+ function sha256(content) {
2030
+ return createHash(`sha256`).update(content).digest(`hex`);
2031
+ }
2032
+ function renderSkillList(skills, mode) {
2033
+ const invocable = skills.filter((s) => s.userInvocable);
2034
+ const others = skills.filter((s) => !s.userInvocable);
2035
+ const lines = [`Available skills:`];
2036
+ if (invocable.length > 0 && mode !== `names`) {
2037
+ lines.push(`\nUser-invocable (the user can trigger these directly):`);
2038
+ for (const meta of invocable) {
2039
+ const hint = meta.argumentHint ? ` ${meta.argumentHint}` : ``;
2040
+ lines.push(`- /${meta.name}${hint} — ${mode === `compact` ? truncate(meta.description, 100) : meta.description}`);
2041
+ }
2042
+ if (others.length > 0) lines.push(``);
2043
+ }
2044
+ const all = mode === `names` ? skills : others.length > 0 ? others : invocable.length === 0 ? skills : [];
2045
+ for (const meta of all) {
2046
+ if (mode === `names`) {
2047
+ const prefix = meta.userInvocable ? `/${meta.name}` : meta.name;
2048
+ lines.push(`- ${prefix}: ${truncate(meta.description, 60)}`);
2049
+ continue;
2050
+ }
2051
+ lines.push(`- ${meta.name} (${meta.charCount.toLocaleString()} chars): ${mode === `compact` ? truncate(meta.description, 100) : meta.description}`);
2052
+ lines.push(` Use when: ${meta.whenToUse}`);
2053
+ if (mode === `full`) lines.push(` Keywords: ${meta.keywords.join(`, `)}`);
2054
+ if (meta.argumentHint) lines.push(` Usage: use_skill("${meta.name}", "${meta.argumentHint}")`);
2055
+ }
2056
+ return lines.join(`\n`);
2057
+ }
2058
+ function truncate(str, max) {
2059
+ return str.length <= max ? str : str.slice(0, max - 3) + `...`;
2060
+ }
2061
+
1474
2062
  //#endregion
1475
2063
  //#region src/bootstrap.ts
1476
2064
  const DEFAULT_BUILTIN_AGENT_HANDLER_PATH = `/_electric/builtin-agent-handler`;
1477
- function createBuiltinAgentHandler(options) {
2065
+ async function createBuiltinAgentHandler(options) {
1478
2066
  const { agentServerUrl, serveEndpoint = `${agentServerUrl}${DEFAULT_BUILTIN_AGENT_HANDLER_PATH}`, workingDirectory, streamFn, createElectricTools } = options;
1479
2067
  if (!streamFn && !process.env.ANTHROPIC_API_KEY) {
1480
2068
  serverLog.warn(`[builtin-agents] ANTHROPIC_API_KEY not set — skipping built-in agent registration`);
1481
2069
  return null;
1482
2070
  }
1483
2071
  const cwd = workingDirectory ?? process.cwd();
2072
+ const here = path.dirname(fileURLToPath(import.meta.url));
2073
+ const baseSkillsDir = path.resolve(here, `../skills`);
2074
+ let skillsRegistry = null;
2075
+ try {
2076
+ skillsRegistry = await createSkillsRegistry({
2077
+ baseSkillsDir,
2078
+ appSkillsDir: path.resolve(cwd, `skills`),
2079
+ cacheDir: path.resolve(cwd, `.electric-agents`)
2080
+ });
2081
+ if (skillsRegistry.catalog.size > 0) serverLog.info(`[electric-agents] ${skillsRegistry.catalog.size} skill(s) loaded: ${Array.from(skillsRegistry.catalog.keys()).join(`, `)}`);
2082
+ } catch (err) {
2083
+ serverLog.warn(`[electric-agents] skills registry failed to initialize: ${err instanceof Error ? err.message : String(err)}`);
2084
+ }
1484
2085
  const registry = createEntityRegistry();
1485
2086
  const typeNames = registerHorton(registry, {
1486
2087
  workingDirectory: cwd,
1487
- streamFn
2088
+ streamFn,
2089
+ skillsRegistry
1488
2090
  });
1489
2091
  registerWorker(registry, {
1490
2092
  workingDirectory: cwd,
@@ -1503,7 +2105,8 @@ function createBuiltinAgentHandler(options) {
1503
2105
  handler: runtime.onEnter,
1504
2106
  runtime,
1505
2107
  registry,
1506
- typeNames
2108
+ typeNames,
2109
+ skillsRegistry
1507
2110
  };
1508
2111
  }
1509
2112
  async function registerBuiltinAgentTypes(bootstrap) {
@@ -1555,7 +2158,7 @@ var BuiltinAgentsServer = class {
1555
2158
  this.publicBaseUrl = this.options.baseUrl ?? this._url;
1556
2159
  const webhookPath = this.options.webhookPath ?? DEFAULT_BUILTIN_AGENT_HANDLER_PATH;
1557
2160
  const serveEndpoint = new URL(webhookPath, this.publicBaseUrl.endsWith(`/`) ? this.publicBaseUrl : `${this.publicBaseUrl}/`).toString();
1558
- this.bootstrap = createBuiltinAgentHandler({
2161
+ this.bootstrap = await createBuiltinAgentHandler({
1559
2162
  agentServerUrl: this.options.agentServerUrl,
1560
2163
  serveEndpoint,
1561
2164
  workingDirectory: this.options.workingDirectory,