@inetafrica/open-claudia 2.6.48 → 2.6.50

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/CHANGELOG.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.6.49
4
+ - **Reusable tools + a directed tool-usage graph.** Two linked features. First, **reusable tools** (`core/tools.js`, `bin/tool.js`): the executable sibling of context packs — when the agent works out an operational procedure (hit an API, drive an interface, transform a file) it crystallises the script into a re-runnable command instead of a throwaway heredoc. Tools are one executable file with a parseable comment header, run pre-authed (the operational keyring is merged into their env so they reference `$NAME` and never hardcode a secret), can link to an owning skill pack, and are surfaced every turn as an always-on index (full docs/source on demand via `open-claudia tool show <name>`). Full CLI: `open-claudia tool list|show|add|run|remove`; `/learn` now crystallises the executable part too. Second, a **directed tool-graph** (`core/tool-graph.js`): a "tool B follows tool A" graph kept deliberately separate from the recall graph — for tools the useful signal is the *order* a chain runs in (auth → list → download), so edges are stored and traversed directionally. Each run-sequence reinforces consecutive pairs (Hebbian, decayed nightly, no structural floor so unused chains prune away). `tool show` and the system-prompt index surface "usually followed by …"; `pack show` gains the reverse view (a pack's linked tools); the nightly dream tends the graph (decay + orphan prune) and flags tools whose `--pack` link dangles. Adds `test-tools.js` + `test-tool-graph.js`.
5
+
3
6
  ## v2.6.43
4
7
  - **Fix the `🧠 Recall this turn` banner rendering raw HTML.** The recall debug banner (from `/recall on`) is built with `<b>` tags and HTML-escaped names, but its two `send()` calls in `runner.js` omitted the Telegram `parseMode: "HTML"` opts that every normal reply already passes via `telegramHtmlOpts()` — so the tags and `&amp;` entities showed up literally in chat instead of rendering. Both recall sends now pass `telegramHtmlOpts()`, matching the rest of the reply path. Display-only; no change to recall behaviour itself.
5
8
 
package/bin/cli.js CHANGED
@@ -284,6 +284,11 @@ switch (command) {
284
284
  break;
285
285
  }
286
286
 
287
+ case "tool": {
288
+ require("./tool").run(args.slice(1));
289
+ break;
290
+ }
291
+
287
292
  case "entity": {
288
293
  require("./entity").run(args.slice(1));
289
294
  break;
@@ -365,6 +370,7 @@ Memory tools:
365
370
  open-claudia transcript-window <pattern> Search project transcript, show hits with context
366
371
  (alias: tw; --help for options)
367
372
  open-claudia pack list|show|match|archive|restore|archived Context packs: living topic docs (skills + memory)
373
+ open-claudia tool list|show|add|run|remove Reusable tools: executable scripts the agent saves & re-runs (keyring preauth)
368
374
  open-claudia entity list|show|match|note Entity notes: people/places/projects memory
369
375
  open-claudia lessons list|add|remove|show Always-loaded learned rules (cross-cutting, promoted after a miss)
370
376
  open-claudia ideas list|add|remove|show Self-improvement backlog (captured by the nightly dream)
package/bin/pack.js CHANGED
@@ -102,6 +102,16 @@ function run(args) {
102
102
  const prov = packs.readProvenance(p.dir);
103
103
  const authored = packs.SECTIONS.map((s) => `${s}=${prov.sections[s] || "user"}`).join(" ");
104
104
  console.log(`\n# provenance: ${authored}${prov.lastWriter ? ` (last: ${prov.lastWriter} ${prov.ts})` : ""}`);
105
+ // Reverse view: the runnable siblings of this pack's prose. A pack's
106
+ // Procedure documents *how*; a linked tool *is* the how. Surfacing them
107
+ // here keeps the doc and its crystallised commands discoverable together.
108
+ try {
109
+ const linked = require("../core/tools").listTools().filter((t) => t.pack === p.dir);
110
+ if (linked.length) {
111
+ console.log(`\n# tools (${linked.length}) — run with 'open-claudia tool run <name>':`);
112
+ for (const t of linked) console.log(`# ${t.name} — ${t.description || "(no description)"}`);
113
+ }
114
+ } catch (e) { /* tools optional */ }
105
115
  break;
106
116
  }
107
117
 
package/bin/tool.js ADDED
@@ -0,0 +1,148 @@
1
+ // CLI: manage and run reusable tools — executable scripts the agent saves so a
2
+ // procedure it worked out once becomes a re-runnable command, not a re-typed
3
+ // heredoc. The executable sibling of context packs (`open-claudia pack`).
4
+ //
5
+ // open-claudia tool list — index (name — description [skill])
6
+ // open-claudia tool show <name> — full docs, path, keyring status
7
+ // open-claudia tool add <path> [--name n] — register a script as a tool
8
+ // [--pack <dir>] [--desc "..."] [--requires "k1,k2"] [--usage "..."]
9
+ // open-claudia tool run <name> [args...] — run it with keyring pre-loaded
10
+ // open-claudia tool remove <name> — delete one
11
+ // open-claudia tool path — print the tools directory
12
+ //
13
+ // Credentials: a tool runs with the operational keyring merged into its env, so
14
+ // reference creds as $NAME inside the script. `open-claudia keyring list` shows
15
+ // what's available; never hardcode secrets in a tool.
16
+
17
+ const { spawnSync } = require("child_process");
18
+ const tools = require("../core/tools");
19
+
20
+ // Minimal flag parser: pulls --key value (and --key=value) out of argv, returns
21
+ // { positional, flags }.
22
+ function parseFlags(argv) {
23
+ const positional = [];
24
+ const flags = {};
25
+ for (let i = 0; i < argv.length; i++) {
26
+ const a = argv[i];
27
+ if (a.startsWith("--")) {
28
+ const eq = a.indexOf("=");
29
+ if (eq >= 0) { flags[a.slice(2, eq)] = a.slice(eq + 1); }
30
+ else { flags[a.slice(2)] = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : true; }
31
+ } else positional.push(a);
32
+ }
33
+ return { positional, flags };
34
+ }
35
+
36
+ function run(args) {
37
+ const cmd = (args[0] || "list").toLowerCase();
38
+ const rest = args.slice(1);
39
+
40
+ switch (cmd) {
41
+ case "list":
42
+ case "ls": {
43
+ const all = tools.listTools();
44
+ if (all.length === 0) {
45
+ return console.log(`No tools yet (${tools.TOOLS_DIR}).\nSave one with: open-claudia tool add <script-path> --pack <skill>`);
46
+ }
47
+ console.log(`${all.length} reusable tool(s) — run via 'open-claudia tool run <name>':\n`);
48
+ for (const t of all) {
49
+ const skill = t.pack ? ` [skill: ${t.pack}]` : "";
50
+ console.log(`• ${t.name} — ${t.description || "(no description)"}${skill}`);
51
+ }
52
+ console.log(`\nDocs: open-claudia tool show <name> · Dir: ${tools.TOOLS_DIR}`);
53
+ break;
54
+ }
55
+
56
+ case "show":
57
+ case "cat": {
58
+ const name = rest[0];
59
+ if (!name) { console.error("Usage: tool show <name>"); process.exitCode = 1; return; }
60
+ const t = tools.findTool(name);
61
+ if (!t) { console.error(`No tool named "${name}". Run: open-claudia tool list`); process.exitCode = 1; return; }
62
+ console.log(`Tool: ${t.name}`);
63
+ if (t.description) console.log(`Description: ${t.description}`);
64
+ if (t.pack) console.log(`Skill pack: ${t.pack} (open-claudia pack show ${t.pack})`);
65
+ console.log(`Usage: open-claudia tool run ${t.name}${t.usage ? " (" + t.usage + ")" : ""}`);
66
+ if (t.requires.length) {
67
+ const missing = tools.missingRequires(t);
68
+ const status = missing.length ? `MISSING: ${missing.join(", ")} — set with 'open-claudia keyring set'` : "all present in keyring";
69
+ console.log(`Requires keyring keys: ${t.requires.join(", ")} (${status})`);
70
+ }
71
+ console.log(`File: ${t.file}${t.executable ? "" : " (not executable!)"}`);
72
+ // Directed tool-graph: what tends to run right after / before this one.
73
+ try {
74
+ const graph = require("../core/tool-graph");
75
+ const fmt = (rows) => rows.map((r) => `${r.name} (${r.weight.toFixed(1)})`).join(", ");
76
+ const after = graph.followers(t.name);
77
+ const before = graph.predecessors(t.name);
78
+ if (after.length) console.log(`Often followed by: ${fmt(after)}`);
79
+ if (before.length) console.log(`Often preceded by: ${fmt(before)}`);
80
+ } catch (e) { /* graph optional (old node) */ }
81
+ console.log(`\n----- source -----\n${t.content}`);
82
+ break;
83
+ }
84
+
85
+ case "add":
86
+ case "save": {
87
+ const { positional, flags } = parseFlags(rest);
88
+ const srcPath = positional[0];
89
+ if (!srcPath) {
90
+ console.error('Usage: tool add <path> [--name n] [--pack dir] [--desc "..."] [--requires "k1,k2"] [--usage "..."]');
91
+ process.exitCode = 1; return;
92
+ }
93
+ try {
94
+ const t = tools.addTool(srcPath, {
95
+ name: typeof flags.name === "string" ? flags.name : undefined,
96
+ pack: typeof flags.pack === "string" ? flags.pack : undefined,
97
+ description: typeof flags.desc === "string" ? flags.desc : (typeof flags.description === "string" ? flags.description : undefined),
98
+ requires: typeof flags.requires === "string" ? flags.requires.split(/[,\s]+/).filter(Boolean) : undefined,
99
+ usage: typeof flags.usage === "string" ? flags.usage : undefined,
100
+ });
101
+ console.log(`Saved tool "${t.name}"${t.pack ? ` (skill: ${t.pack})` : ""}.`);
102
+ console.log(`Run it: open-claudia tool run ${t.name}`);
103
+ if (t.requires.length) {
104
+ const missing = tools.missingRequires(t);
105
+ if (missing.length) console.log(`Note: missing keyring keys it needs: ${missing.join(", ")}`);
106
+ }
107
+ } catch (e) {
108
+ console.error(`Could not add tool: ${e.message}`); process.exitCode = 1;
109
+ }
110
+ break;
111
+ }
112
+
113
+ case "run":
114
+ case "exec": {
115
+ const name = rest[0];
116
+ if (!name) { console.error("Usage: tool run <name> [args...]"); process.exitCode = 1; return; }
117
+ const t = tools.findTool(name);
118
+ if (!t) { console.error(`No tool named "${name}". Run: open-claudia tool list`); process.exitCode = 1; return; }
119
+ const missing = tools.missingRequires(t);
120
+ if (missing.length) {
121
+ console.error(`Cannot run ${t.name}: missing keyring keys ${missing.join(", ")}. Set them with 'open-claudia keyring set <name> <value>'.`);
122
+ process.exitCode = 1; return;
123
+ }
124
+ const r = spawnSync(t.file, rest.slice(1), { stdio: "inherit", env: tools.runEnv() });
125
+ if (r.error) { console.error(`Failed to run ${t.name}: ${r.error.message}`); process.exitCode = 1; return; }
126
+ process.exitCode = typeof r.status === "number" ? r.status : 1;
127
+ break;
128
+ }
129
+
130
+ case "remove":
131
+ case "rm": {
132
+ const name = rest[0];
133
+ if (!name) { console.error("Usage: tool remove <name>"); process.exitCode = 1; return; }
134
+ const removed = tools.removeTool(name);
135
+ console.log(removed ? `Removed tool "${removed.name}".` : `No tool named "${name}".`);
136
+ break;
137
+ }
138
+
139
+ case "path":
140
+ console.log(tools.TOOLS_DIR);
141
+ break;
142
+
143
+ default:
144
+ console.log('Usage: open-claudia tool [list | show <name> | add <path> [flags] | run <name> [args] | remove <name> | path]');
145
+ }
146
+ }
147
+
148
+ module.exports = { run };
package/core/dream.js CHANGED
@@ -681,6 +681,7 @@ function writeDreamReport(data) {
681
681
  sec.push("", "### Proposed (not applied — DREAM_SELF_APPLY=off)", "```json", JSON.stringify(data.introspection.proposed, null, 2), "```");
682
682
  }
683
683
  if (data.graphNote) sec.push("", data.graphNote);
684
+ if (data.toolNote) sec.push("", data.toolNote);
684
685
  if (data.staleNote) sec.push("", data.staleNote);
685
686
  if (data.backupRoot) sec.push("", `Backups: ${data.backupRoot}`);
686
687
  const block = sec.join("\n") + "\n\n---\n\n";
@@ -825,6 +826,25 @@ async function runDream({ trigger = "manual" } = {}) {
825
826
  }
826
827
  } catch (e) { /* graph is best-effort */ }
827
828
 
829
+ // Tool hygiene: tend the directed tool-graph (decay reinforced follows-edges,
830
+ // prune faded/orphaned ones) and flag tools whose --pack link now dangles.
831
+ // Read-only toward the tools themselves — a tool is never auto-deleted (that's
832
+ // destructive); we just surface the breakage for the user to re-link or remove.
833
+ let toolNote = "";
834
+ try {
835
+ const toolsLib = require("./tools");
836
+ const tg = require("./tool-graph").tend(toolsLib);
837
+ const bits = [];
838
+ if (tg.edges || tg.decayed || tg.pruned) {
839
+ bits.push(`🔧 Tool graph: ${tg.edges} edges / ${tg.nodes} nodes (decayed ${tg.decayed}, pruned ${tg.pruned}).`);
840
+ }
841
+ const dangling = toolsLib.listTools().filter((t) => t.pack && !packs.readPack(t.pack));
842
+ if (dangling.length) {
843
+ bits.push(`🔗 ${dangling.length} tool(s) link a missing skill pack: ${dangling.map((t) => `${t.name}→${t.pack}`).join(", ")} — re-link with --pack or remove.`);
844
+ }
845
+ toolNote = bits.join("\n");
846
+ } catch (e) { /* tool graph is best-effort (old node) */ }
847
+
828
848
  const staleNote = staleTaskReport();
829
849
  const dreamLines = consolidationLines.concat(introApplied);
830
850
 
@@ -832,7 +852,7 @@ async function runDream({ trigger = "manual" } = {}) {
832
852
  model: DREAM_MODEL, effort: DREAM_EFFORT, trigger,
833
853
  consolidation: { report: decision.report, lines: consolidationLines },
834
854
  introspection: { report: introReport, lines: introApplied, proposed: introProposed },
835
- graphNote, staleNote, backupRoot,
855
+ graphNote, toolNote, staleNote, backupRoot,
836
856
  });
837
857
 
838
858
  // Richer chat summary: consolidation + introspection narrative + what
@@ -846,12 +866,13 @@ async function runDream({ trigger = "manual" } = {}) {
846
866
  if (n) parts.push(`📋 ${n} self-improvement change(s) staged for your OK (DREAM_SELF_APPLY=off) — see the log below.`);
847
867
  }
848
868
  if (graphNote) parts.push(graphNote);
869
+ if (toolNote) parts.push(toolNote);
849
870
  if (staleNote) parts.push(staleNote);
850
871
  if (dreamLines.length) parts.push(`🗄 Anything changed or merged away is backed up under ${backupRoot}`);
851
872
  if (reportPath) parts.push(`📔 Full dream log: ${reportPath}`);
852
873
  const message = parts.length ? parts.join("\n\n") : "";
853
874
 
854
- return { applied: dreamLines, report, introReport, message, staleNote, graphNote, reportPath, trigger };
875
+ return { applied: dreamLines, report, introReport, message, staleNote, graphNote, toolNote, reportPath, trigger };
855
876
  } finally {
856
877
  _dreaming = false;
857
878
  }
package/core/handlers.js CHANGED
@@ -1217,7 +1217,8 @@ register({
1217
1217
  `First check the already-injected packs and 'open-claudia pack list' for a pack this work belongs to; if one fits, fold the reusable how-to into that pack's Procedure section (exact commands in order, prerequisites, pitfalls) and append a dated Journal line — edit the PACK.md directly. ` +
1218
1218
  `Only if no pack fits, create a new one: 'open-claudia pack' has no create subcommand, so write ~/.open-claudia/packs/<kebab-name>/PACK.md following the four-section format (Stance, Procedure, State, Journal) with name + a when-to-use description in the frontmatter. Give it an ACTIVITY-oriented name and description (what you DO — the verbs, tools, and artifacts) so it is findable from other projects by the work being done, not by a project name; and if this how-to came from a specific project, add a 'learned_on: <project-pack-dir>' frontmatter line so its cross-project reuse can be tracked. ` +
1219
1219
  `Then mark that pack as a reusable skill so it is always surfaced in future conversations: run 'open-claudia pack skill <dir> on'. This flags it as a reusable ability and adds it to the always-on skill index (name + description); the Procedure still loads on demand. ` +
1220
- `No secrets or tokens. When done, reply with one short line naming the pack you created or updated and what it covers. ` +
1220
+ `CRUCIAL also crystallise the EXECUTABLE part: if the work involved a script, API call, or any repeatable command sequence you wrote inline (a heredoc, a one-off .py/.sh, a curl chain), turn it into a reusable tool instead of leaving it to be re-typed. Write the script to a file (parameterise the bits that vary; read any credential from the env as $NAME rather than hardcoding), then register it with 'open-claudia tool add <path> --pack <dir> --desc "..." [--requires "key1,key2"] [--usage "..."]', linking it to the pack you just touched. Reference creds by their keyring names (see 'open-claudia keyring list'); never write a secret into the tool. The tool is the runnable form of the pack's Procedure — save both. ` +
1221
+ `No secrets or tokens in packs OR tools. When done, reply with one short line naming the pack you created or updated, any tool you saved, and what it covers. ` +
1221
1222
  `If the recent work is genuinely not reusable, say so instead of forcing a skill.`;
1222
1223
  await runClaude(prompt, currentState().currentSession.dir, env.messageId);
1223
1224
  },
package/core/runner.js CHANGED
@@ -27,6 +27,7 @@ const loopback = require("./loopback");
27
27
  const skillsLib = require("./skills");
28
28
  const packsLib = require("./packs");
29
29
  const entitiesLib = require("./entities");
30
+ const toolsLib = require("./tools");
30
31
  const packReview = require("./pack-review");
31
32
  const { recallNodeFromPath } = require("./recall/read-signal");
32
33
  const {
@@ -314,6 +315,15 @@ async function buildClaudeArgs(prompt, opts = {}) {
314
315
  if (settings.permissionMode) args.push("--permission-mode", settings.permissionMode);
315
316
  else args.push("--dangerously-skip-permissions");
316
317
  if (settings.worktree) args.push("--worktree");
318
+ // Voice turns stream partial text so the spoken reply can start mid-generation
319
+ // (see the streaming-out handler in the runner). Strictly gated to the voice
320
+ // channel — zero behaviour change for Telegram/Kazee.
321
+ if (state.lastInputWasVoice) {
322
+ try {
323
+ const { currentTransport } = require("./context");
324
+ if (currentTransport() === "voice") args.push("--include-partial-messages");
325
+ } catch { /* context not ready — skip streaming flag */ }
326
+ }
317
327
  // Dynamic state rides in the user prompt so the appended system prompt
318
328
  // stays byte-stable across turns and the prompt-cache prefix survives.
319
329
  args.push(await promptWithDynamicContext(prompt));
@@ -813,6 +823,50 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
813
823
  state.statusMessageId = null;
814
824
  state.streamBuffer = "";
815
825
  let assistantText = "";
826
+
827
+ // Voice streaming-out: on voice turns we speak each finished sentence as it is
828
+ // generated (off the partial text_delta events) so the first audio plays while
829
+ // the rest of the reply is still being written — far lower time-to-first-sound
830
+ // than synthesizing one pass over the whole reply at the end. Reads the delta
831
+ // stream only; the text/transcript channel still reads whole-message events, so
832
+ // chat transports are completely unaffected.
833
+ let voiceStreaming = false;
834
+ try {
835
+ const { currentTransport } = require("./context");
836
+ voiceStreaming = !!state.lastInputWasVoice && currentTransport() === "voice";
837
+ } catch { voiceStreaming = false; }
838
+ let spokenBuf = ""; // text_delta accumulator awaiting a sentence boundary
839
+ let ttsChain = Promise.resolve(); // ordered send queue so clips play in order
840
+ let spokeAnyStreamed = false;
841
+ const SPOKEN_MIN_CHARS = 40; // don't fire TTS on tiny fragments ("Hi.")
842
+ function dispatchSpoken(text) {
843
+ const clean = redactSensitive(text);
844
+ if (!clean.trim()) return;
845
+ spokeAnyStreamed = true;
846
+ const synthP = synthSentenceMp3(clean); // start synth now (parallel)
847
+ ttsChain = ttsChain.then(async () => { // but send strictly in order
848
+ try { const clip = await synthP; if (clip) await sendVoice(clip); }
849
+ catch (e) { console.error("voice stream clip failed:", e.message); }
850
+ });
851
+ }
852
+ function pumpSpoken(flush) {
853
+ // Cut the smallest prefix that ends in a sentence terminator and is at least
854
+ // SPOKEN_MIN_CHARS long, dispatch it, repeat. On flush, send whatever remains.
855
+ while (true) {
856
+ const re = /[.!?]+(?=\s|$)/g;
857
+ let idx = -1, m;
858
+ while ((m = re.exec(spokenBuf)) !== null) {
859
+ const end = m.index + m[0].length;
860
+ if (end >= SPOKEN_MIN_CHARS) { idx = end; break; }
861
+ }
862
+ if (idx === -1) break;
863
+ const sentence = spokenBuf.slice(0, idx).trim();
864
+ spokenBuf = spokenBuf.slice(idx).replace(/^\s+/, "");
865
+ if (sentence) dispatchSpoken(sentence);
866
+ }
867
+ if (flush) { const tail = spokenBuf.trim(); spokenBuf = ""; if (tail) dispatchSpoken(tail); }
868
+ }
869
+
816
870
  let toolUses = [];
817
871
  let currentTool = null;
818
872
  let currentToolDetail = "";
@@ -857,6 +911,12 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
857
911
  else notifySkill(`entity:${entSlug}`, `👤 New entity noted: ${entSlug} — open-claudia entity show ${entSlug} to peek.`);
858
912
  return;
859
913
  }
914
+ const toolName2 = toolsLib.toolNameFromPath(filePath);
915
+ if (toolName2) {
916
+ if (toolsLib.findTool(toolName2)) notifySkill(`tool:${toolName2}`, `🔧 Updating tool: ${toolName2} — open-claudia tool show ${toolName2} to inspect.`);
917
+ else notifySkill(`tool:${toolName2}`, `🔧 New tool: ${toolName2} — open-claudia tool run ${toolName2} to use it.`);
918
+ return;
919
+ }
860
920
  const dir = skillsLib.skillNameFromPath(filePath);
861
921
  if (!dir) return;
862
922
  // The tool_use event precedes the actual write, so existence now
@@ -874,6 +934,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
874
934
  // Nodes the agent actually OPENED this turn (📖). This is the co-use signal
875
935
  // the recall graph reinforces on — actually-read, not merely surfaced.
876
936
  const openedThisTurn = new Set();
937
+ // Reusable tools the agent RAN this turn, in order. Feeds the directed
938
+ // tool-graph (core/tool-graph.js): consecutive runs become follows-edges so
939
+ // "you ran X — you usually run Y next" can be surfaced. Order matters here
940
+ // (unlike openedThisTurn, a Set), because a tool chain is a pipeline.
941
+ const toolRunsThisTurn = [];
877
942
  const noteRecallFromShell = (command) => {
878
943
  try {
879
944
  const cmd = String(command || "");
@@ -895,6 +960,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
895
960
  openedThisTurn.add(`entity:${slug}`);
896
961
  notifySkill(`recall:entity:${slug}`, `📖 Recalled my notes on: ${name}`);
897
962
  }
963
+ // `open-claudia tool run|exec <name>` — record the run for the directed
964
+ // tool-graph. Only the name is captured (args are noise for "what runs
965
+ // next"); the end-of-turn block reinforces consecutive pairs.
966
+ const toolRe = /\btool\s+(?:run|exec)\s+["']?([a-z0-9][\w.-]*)/gi;
967
+ while ((m = toolRe.exec(cmd))) toolRunsThisTurn.push(m[1]);
898
968
  } catch (e) { /* announcements are best-effort */ }
899
969
  };
900
970
 
@@ -1003,6 +1073,15 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1003
1073
  const lastNewline = state.streamBuffer.lastIndexOf("\n");
1004
1074
  state.streamBuffer = lastNewline >= 0 ? state.streamBuffer.slice(lastNewline + 1) : state.streamBuffer;
1005
1075
  for (const evt of events) {
1076
+ // Voice streaming-out: speak finished sentences as the model writes them.
1077
+ // Only text_delta is spoken; thinking_delta and tool events are ignored.
1078
+ if (voiceStreaming && evt.type === "stream_event"
1079
+ && evt.event?.type === "content_block_delta"
1080
+ && evt.event.delta?.type === "text_delta"
1081
+ && typeof evt.event.delta.text === "string") {
1082
+ spokenBuf += evt.event.delta.text;
1083
+ pumpSpoken(false);
1084
+ }
1006
1085
  if (evt.type === "assistant" && evt.message?.usage) {
1007
1086
  const callPrefix = usageParts(evt.message.usage, settings.backend || "claude").context;
1008
1087
  if (callPrefix > peakContextTokens) peakContextTokens = callPrefix;
@@ -1195,25 +1274,37 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1195
1274
 
1196
1275
  if (state.lastInputWasVoice) {
1197
1276
  state.lastInputWasVoice = false;
1198
- // Spoken replies belong to the hands-free voice channel. On chat
1199
- // transports (Telegram/Kazee) an auto voice note on every voice
1200
- // input is unwanted noise, so gate it to the voice channel.
1201
- const { currentTransport } = require("./context");
1202
- if (currentTransport() === "voice") {
1203
- // Stream the spoken reply sentence-by-sentence so the first audio
1204
- // plays while the rest still synthesizes — far lower time-to-first-
1205
- // sound than waiting for one TTS pass over the whole reply.
1206
- const sentences = splitSentences(finalText);
1207
- let spokeAny = false;
1208
- for (const sentence of sentences) {
1209
- const clip = await synthSentenceMp3(sentence);
1210
- if (clip) { spokeAny = true; await sendVoice(clip); }
1211
- }
1212
- if (!spokeAny) {
1277
+ if (voiceStreaming) {
1278
+ // Sentences were already being spoken as the model wrote them. Flush
1279
+ // the trailing partial sentence, wait for the ordered send queue to
1280
+ // drain, then close the turn so the client re-arms the mic.
1281
+ pumpSpoken(true);
1282
+ await ttsChain;
1283
+ if (!spokeAnyStreamed) {
1284
+ // Tool-only / empty turn produced no spoken text — say the final
1285
+ // text once so the user still hears a reply.
1213
1286
  const voicePath = await textToVoice(finalText);
1214
1287
  if (voicePath) await sendVoice(voicePath);
1215
1288
  }
1216
1289
  await sendVoiceEnd();
1290
+ } else {
1291
+ // Non-streamed fallback. Spoken replies belong to the hands-free voice
1292
+ // channel; on chat transports (Telegram/Kazee) an auto voice note on
1293
+ // every voice input is unwanted noise, so gate it to the voice channel.
1294
+ const { currentTransport } = require("./context");
1295
+ if (currentTransport() === "voice") {
1296
+ const sentences = splitSentences(finalText);
1297
+ let spokeAny = false;
1298
+ for (const sentence of sentences) {
1299
+ const clip = await synthSentenceMp3(sentence);
1300
+ if (clip) { spokeAny = true; await sendVoice(clip); }
1301
+ }
1302
+ if (!spokeAny) {
1303
+ const voicePath = await textToVoice(finalText);
1304
+ if (voicePath) await sendVoice(voicePath);
1305
+ }
1306
+ await sendVoiceEnd();
1307
+ }
1217
1308
  }
1218
1309
  }
1219
1310
  } catch (e) {
@@ -1240,6 +1331,16 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1240
1331
  } catch (e) { /* best-effort */ }
1241
1332
  }
1242
1333
 
1334
+ // Directed tool-graph: a chain of reusable tools run in succession this turn
1335
+ // (auth → list → download) becomes follows-edges, so next time the first
1336
+ // runs we can surface "usually followed by …". Kept in a tool-specific graph
1337
+ // so it never bleeds into recall's spreading activation. Reinforce only on a
1338
+ // successful turn, and only when ≥2 ran (a single run has no succession).
1339
+ if (turnSucceeded && toolRunsThisTurn.length > 1) {
1340
+ try { require("./tool-graph").reinforceSequence(toolRunsThisTurn); }
1341
+ catch (e) { /* best-effort */ }
1342
+ }
1343
+
1243
1344
  // Close the learning loop: when an ABILITY pack is opened in the same turn
1244
1345
  // as a project (context) pack, the ability was demonstrably applied while
1245
1346
  // working on that project. recordCoUse grows the ability's applied_on, which
@@ -66,6 +66,53 @@ function buildSkillIndexBlock() {
66
66
  }
67
67
  }
68
68
 
69
+ // Always-on tool index: the executable sibling of the skill index. Each entry
70
+ // is a real script the agent crystallised from a working procedure, surfaced by
71
+ // name+description every turn (progressive disclosure — full docs/source load on
72
+ // demand via `open-claudia tool show <name>`). Tools run pre-authed: the
73
+ // operational keyring is merged into their env, so a tool references a credential
74
+ // as $NAME and never hardcodes a secret.
75
+ function buildToolIndexBlock() {
76
+ let listing = "";
77
+ try {
78
+ const all = require("./tools").listTools();
79
+ if (all.length) {
80
+ // Directed tool-graph: surface the strongest "runs next" follower per tool
81
+ // so a known chain (auth → list → download) is visible inline. Optional —
82
+ // absent on old node where node:sqlite is missing.
83
+ let graph = null;
84
+ try { graph = require("./tool-graph"); } catch (e) {}
85
+ listing = "\nTools you already have (run with `open-claudia tool run <name> [args]`; read docs/source with `open-claudia tool show <name>`):\n\n" +
86
+ all.slice(0, 40)
87
+ .map((t) => {
88
+ const skill = t.pack ? ` [skill: ${t.pack}]` : "";
89
+ const needs = t.requires && t.requires.length ? ` (keyring: ${t.requires.join(", ")})` : "";
90
+ let next = "";
91
+ if (graph) {
92
+ try {
93
+ const after = graph.followers(t.name, 2).map((r) => r.name);
94
+ if (after.length) next = ` → usually followed by: ${after.join(", ")}`;
95
+ } catch (e) {}
96
+ }
97
+ return `- ${t.name} — ${t.description || "(no description)"}${needs}${skill}${next}`;
98
+ })
99
+ .join("\n") + "\n";
100
+ } else {
101
+ listing = "\nNo reusable tools saved yet — the first time you work out how to hit an API or drive an interface, save it as one.\n";
102
+ }
103
+ } catch (e) {
104
+ return "";
105
+ }
106
+ return `\n## Reusable tools (build skills as you work)
107
+ When you work out how to do something operational — hit an API, drive an interface, transform a file, run a multi-step flow — do NOT leave it as a throwaway heredoc. Crystallise it into a reusable tool so next time it's a single command, not a re-derivation. This is a core behaviour, not an optional extra: prefer saving a tool over re-typing a script you've written before.
108
+
109
+ - A tool is one executable file (any language; shebang picks the interpreter) with a parseable comment header. Save the script you just wrote with \`open-claudia tool add <path> --pack <skill-dir> --desc "..." [--requires "key1,key2"] [--usage "..."]\`. The \`--pack\` link ties it to the matching skill pack so the prose how-to and the runnable how-to stay together.
110
+ - Preauth: tools run with the operational keyring merged into their environment — and so does your OWN Bash, right now. Every operational credential is already present as an environment variable in your shell and in any tool you run; reference it as \`$inet_central_user\` etc. instead of asking for it or hardcoding it. Run \`open-claudia keyring list\` to see the exact names available. In a saved tool, declare what it needs via \`--requires\` so a missing key is reported before the run, and never write a secret into the file.
111
+ - Read a tool's docs and source on demand with \`open-claudia tool show <name>\` (don't guess its args). Full CLI: \`open-claudia tool list|show|add|run|remove\`.
112
+ - When a tool's behaviour changes or you improve it, update the saved tool rather than forking a new heredoc. Announce in one line when you create or change a tool, same as packs.
113
+ ${listing}`;
114
+ }
115
+
69
116
  function buildSystemPrompt() {
70
117
  const state = currentState();
71
118
  const soul = loadSoul();
@@ -160,6 +207,7 @@ Open Claudia learned skills are stored as context packs under ${path.join(CONFIG
160
207
 
161
208
  If the user asks for a skill by name, do not rely only on the backend harness's native "Available skills" list. First use any Active context pack injected into the current request as the requested Open Claudia skill. If no matching pack was injected, inspect with \`open-claudia pack list\` / \`open-claudia pack show <dir>\` and legacy \`/skills\` paths before saying the skill does not exist.
162
209
  ${buildSkillIndexBlock()}
210
+ ${buildToolIndexBlock()}
163
211
 
164
212
  ## Stable Local Paths
165
213
  - Bot code: ${path.join(BOT_DIR, "bot.js")}
@@ -232,7 +280,7 @@ Your durable knowledge lives in context packs: living per-topic documents (one p
232
280
 
233
281
  Alongside packs you keep entity notes: one short file per named person, place, project, org, or system (Notes = current truth, Log = dated observations). Entities matching the incoming message are auto-injected like packs, and the same background reviewer maintains them. Inspect with \`open-claudia entity list\` / \`entity show <slug>\`; edit the files directly (announce in one line) when you learn something durable about someone or something. Same boundaries as packs.
234
282
 
235
- "/learn" asks you to explicitly capture the most recent piece of work: fold it into the matching pack's Procedure section (create a pack only if none fits). Legacy ~/.claude/skills still load if present, but new captures go to packs.
283
+ "/learn" asks you to explicitly capture the most recent piece of work: fold the prose how-to into the matching pack's Procedure section (create a pack only if none fits), AND crystallise any repeatable script into a reusable tool with \`open-claudia tool add ... --pack <dir>\` so the runnable form is saved alongside the prose. Legacy ~/.claude/skills still load if present, but new captures go to packs + tools.
236
284
 
237
285
  A nightly "dream" pass consolidates memory on a stronger model: it merges duplicate packs, builds umbrella/parent pack trees, tightens descriptions and tags, dedupes entities, and may gently evolve your persona file. Anything merged away is backed up first, and every dream that changes something reports in chat. Trigger it manually with \`open-claudia dream\` (or \`--dry-run\` to preview the decision without applying).
238
286
 
@@ -0,0 +1,217 @@
1
+ // Directed tool-usage graph: "tool B often follows tool A". The executable
2
+ // counterpart to the recall graph (core/recall/graph.js), but deliberately
3
+ // SEPARATE and DIRECTED.
4
+ //
5
+ // Why separate: the recall graph spreads activation over packs+entities to pick
6
+ // which MEMORY to surface. Tools are a different corpus with different physics —
7
+ // the useful signal is the ORDER a chain runs in (auth → list → download), not
8
+ // symmetric "these are related". Mixing tool nodes into the recall graph would
9
+ // let a tool fire a pack headline (and vice-versa), which is noise. So tools get
10
+ // their own tiny graph.
11
+ //
12
+ // Why directed: A→B ("B follows A") carries real information that B→A does not.
13
+ // auth-then-fetch is a pipeline; fetch-then-auth is nonsense. Edges are stored
14
+ // and traversed directionally. Each edge carries a Hebbian `weight` bumped on
15
+ // co-use and decayed over time; unlike pack edges there is no structural floor,
16
+ // so a chain that stops being used eventually decays away and is pruned.
17
+
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+
21
+ let DatabaseSync = null;
22
+ try { ({ DatabaseSync } = require("node:sqlite")); } catch (e) { /* old node — graph disabled */ }
23
+
24
+ const CONFIG_DIR = require("../config-dir");
25
+ const GRAPH_DB = process.env.TOOL_GRAPH_DB
26
+ ? path.resolve(process.env.TOOL_GRAPH_DB)
27
+ : path.join(CONFIG_DIR, "tool-graph.db");
28
+
29
+ // Tunables (overridable via dream knobs later).
30
+ const DEFAULTS = {
31
+ halfLifeDays: 45, // Hebbian weight half-life for decay()
32
+ pruneBelow: 0.15, // edges decayed under this are dropped (no structural floor)
33
+ };
34
+
35
+ let _db = null;
36
+
37
+ function available() { return !!DatabaseSync; }
38
+
39
+ function openDb() {
40
+ if (!DatabaseSync) return null;
41
+ if (_db) return _db;
42
+ try {
43
+ fs.mkdirSync(path.dirname(GRAPH_DB), { recursive: true, mode: 0o700 });
44
+ const db = new DatabaseSync(GRAPH_DB);
45
+ try { fs.chmodSync(GRAPH_DB, 0o600); } catch (e) {}
46
+ db.exec("PRAGMA journal_mode=WAL");
47
+ db.exec("PRAGMA busy_timeout=3000");
48
+ db.exec(`CREATE TABLE IF NOT EXISTS tool_edges (
49
+ src TEXT NOT NULL,
50
+ dst TEXT NOT NULL,
51
+ weight REAL NOT NULL DEFAULT 1,
52
+ last_reinforced TEXT,
53
+ created TEXT,
54
+ PRIMARY KEY (src, dst)
55
+ )`);
56
+ db.exec("CREATE INDEX IF NOT EXISTS tool_edges_src ON tool_edges (src)");
57
+ db.exec("CREATE INDEX IF NOT EXISTS tool_edges_dst ON tool_edges (dst)");
58
+ _db = db;
59
+ return db;
60
+ } catch (e) {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function now() { return new Date().toISOString(); }
66
+
67
+ // Directed upsert of a follows-edge src→dst. `bump` adds to the current weight
68
+ // (Hebbian) and stamps last_reinforced; with no bump it just ensures the edge
69
+ // exists at `weight` (default 1).
70
+ function addFollow(src, dst, opts = {}) {
71
+ const db = openDb();
72
+ if (!db || !src || !dst || src === dst) return false;
73
+ const bump = Number.isFinite(opts.bump) ? opts.bump : 0;
74
+ const weight = Number.isFinite(opts.weight) ? opts.weight : 1;
75
+ try {
76
+ const existing = db.prepare("SELECT weight FROM tool_edges WHERE src=? AND dst=?").get(src, dst);
77
+ if (existing) {
78
+ const next = bump ? existing.weight + bump : Math.max(existing.weight, weight);
79
+ if (bump) {
80
+ db.prepare("UPDATE tool_edges SET weight=?, last_reinforced=? WHERE src=? AND dst=?")
81
+ .run(next, now(), src, dst);
82
+ } else {
83
+ db.prepare("UPDATE tool_edges SET weight=? WHERE src=? AND dst=?").run(next, src, dst);
84
+ }
85
+ } else {
86
+ // No structural floor for tools: a brand-new edge is worth exactly its
87
+ // first co-occurrence (bump), or the explicit `weight` for a pure ensure.
88
+ const initial = bump ? bump : weight;
89
+ db.prepare("INSERT INTO tool_edges (src, dst, weight, last_reinforced, created) VALUES (?,?,?,?,?)")
90
+ .run(src, dst, initial, bump ? now() : null, now());
91
+ }
92
+ return true;
93
+ } catch (e) {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ // Reinforce an ordered run of tools: for [a, b, c] strengthen a→b and b→c (only
99
+ // immediate succession — that's the cleanest "what runs next" signal). Repeated
100
+ // names in a row are collapsed so a tool re-run in a loop doesn't self-link.
101
+ function reinforceSequence(names, amount = 1) {
102
+ const seq = (names || []).filter(Boolean);
103
+ let last = null;
104
+ for (const name of seq) {
105
+ if (last && last !== name) addFollow(last, name, { bump: amount });
106
+ last = name;
107
+ }
108
+ }
109
+
110
+ function removeEdge(src, dst) {
111
+ const db = openDb();
112
+ if (!db) return false;
113
+ try { db.prepare("DELETE FROM tool_edges WHERE src=? AND dst=?").run(src, dst); return true; }
114
+ catch (e) { return false; }
115
+ }
116
+
117
+ function allEdges() {
118
+ const db = openDb();
119
+ if (!db) return [];
120
+ try { return db.prepare("SELECT src, dst, weight, last_reinforced FROM tool_edges").all(); }
121
+ catch (e) { return []; }
122
+ }
123
+
124
+ // Tools that tend to follow `name`, strongest first. This is the surfaced
125
+ // signal: "you ran X — you usually run these next".
126
+ function followers(name, limit = 5) {
127
+ const db = openDb();
128
+ if (!db || !name) return [];
129
+ try {
130
+ return db.prepare("SELECT dst AS name, weight FROM tool_edges WHERE src=? ORDER BY weight DESC LIMIT ?")
131
+ .all(name, limit);
132
+ } catch (e) { return []; }
133
+ }
134
+
135
+ // Tools that tend to precede `name` (the inverse view).
136
+ function predecessors(name, limit = 5) {
137
+ const db = openDb();
138
+ if (!db || !name) return [];
139
+ try {
140
+ return db.prepare("SELECT src AS name, weight FROM tool_edges WHERE dst=? ORDER BY weight DESC LIMIT ?")
141
+ .all(name, limit);
142
+ } catch (e) { return []; }
143
+ }
144
+
145
+ // Exponential time decay. Tools have no structural floor, so edges decay toward
146
+ // zero and are pruned once under `pruneBelow` — a chain that fell out of use
147
+ // disappears instead of lingering as stale advice.
148
+ function decay({ halfLifeDays = DEFAULTS.halfLifeDays, pruneBelow = DEFAULTS.pruneBelow } = {}) {
149
+ const db = openDb();
150
+ if (!db) return { decayed: 0, pruned: 0 };
151
+ const rows = allEdges();
152
+ const nowMs = Date.now();
153
+ const ln2 = Math.log(2);
154
+ let decayed = 0, pruned = 0;
155
+ for (const e of rows) {
156
+ if (!e.last_reinforced) continue;
157
+ const ageDays = (nowMs - Date.parse(e.last_reinforced)) / 86400000;
158
+ if (!(ageDays > 0)) continue;
159
+ const factor = Math.exp(-ln2 * ageDays / Math.max(1, halfLifeDays));
160
+ const next = e.weight * factor;
161
+ if (next < pruneBelow) {
162
+ if (removeEdge(e.src, e.dst)) pruned++;
163
+ continue;
164
+ }
165
+ if (Math.abs(next - e.weight) > 1e-6) {
166
+ try {
167
+ db.prepare("UPDATE tool_edges SET weight=? WHERE src=? AND dst=?").run(next, e.src, e.dst);
168
+ decayed++;
169
+ } catch (e2) {}
170
+ }
171
+ }
172
+ return { decayed, pruned };
173
+ }
174
+
175
+ // Drop edges whose endpoints are no longer registered tools (a tool was removed
176
+ // or renamed). Keeps the graph from pointing at things that can't be run.
177
+ function pruneOrphans(toolsLib) {
178
+ const db = openDb();
179
+ if (!db) return 0;
180
+ let live;
181
+ try { live = new Set(toolsLib.listTools().map((t) => t.name)); }
182
+ catch (e) { return 0; }
183
+ if (!live.size) return 0;
184
+ let removed = 0;
185
+ for (const e of allEdges()) {
186
+ if (!live.has(e.src) || !live.has(e.dst)) {
187
+ if (removeEdge(e.src, e.dst)) removed++;
188
+ }
189
+ }
190
+ return removed;
191
+ }
192
+
193
+ // Nightly maintenance (called by the dream): decay reinforced weights and drop
194
+ // orphaned/faded edges. Deterministic + safe — no model needed.
195
+ function tend(toolsLib, opts = {}) {
196
+ const { decayed, pruned } = decay(opts);
197
+ const orphaned = toolsLib ? pruneOrphans(toolsLib) : 0;
198
+ return { decayed, pruned: pruned + orphaned, ...stats() };
199
+ }
200
+
201
+ function stats() {
202
+ const rows = allEdges();
203
+ const nodes = new Set();
204
+ for (const e of rows) { nodes.add(e.src); nodes.add(e.dst); }
205
+ return { edges: rows.length, nodes: nodes.size };
206
+ }
207
+
208
+ // Test seam.
209
+ function _resetForTest() { if (_db) { try { _db.close(); } catch (e) {} } _db = null; }
210
+
211
+ module.exports = {
212
+ DEFAULTS, GRAPH_DB,
213
+ available, openDb,
214
+ addFollow, reinforceSequence, removeEdge, allEdges,
215
+ followers, predecessors, decay, pruneOrphans, tend, stats,
216
+ _resetForTest,
217
+ };
package/core/tools.js ADDED
@@ -0,0 +1,227 @@
1
+ // Reusable tools: executable scripts the agent crystallises as it works, so a
2
+ // procedure it figured out once (hit an API, drive an interface, transform a
3
+ // file) becomes a re-runnable command next time instead of a throwaway heredoc.
4
+ //
5
+ // This is the executable sibling of context packs. A pack's Procedure section
6
+ // documents *how* in prose; a tool *is* the how — a real file you run. Tools
7
+ // are surfaced in the system prompt as an always-on index (like skill packs)
8
+ // and read on demand via `open-claudia tool show <name>` (progressive
9
+ // disclosure). Each tool can name an owning skill pack so the two stay linked.
10
+ //
11
+ // Preauth: tools run with the operational keyring merged into their env (see
12
+ // runEnv()), exactly like the agent's own subprocess. So a tool references a
13
+ // credential as $inet_central_user and never hardcodes a secret. The keyring is
14
+ // already a managed .env (plaintext + chmod 600 + log redaction); tools inherit
15
+ // that model rather than inventing a second one.
16
+
17
+ const fs = require("fs");
18
+ const path = require("path");
19
+ const CONFIG_DIR = require("../config-dir");
20
+
21
+ const TOOLS_DIR = process.env.TOOLS_DIR ? path.resolve(process.env.TOOLS_DIR) : path.join(CONFIG_DIR, "tools");
22
+
23
+ // Marker line that makes a script a registered tool, plus the header fields we
24
+ // parse from the leading comment block. Language-agnostic: we accept "#" (sh,
25
+ // python, ruby) and "//" (js, ts, go) comment prefixes.
26
+ const MARKER = "open-claudia-tool";
27
+ const HEADER_KEYS = ["description", "pack", "requires", "usage"];
28
+
29
+ function ensureDir() {
30
+ fs.mkdirSync(TOOLS_DIR, { recursive: true, mode: 0o700 });
31
+ }
32
+
33
+ function sanitizeName(name) {
34
+ return String(name || "").toLowerCase().trim()
35
+ .replace(/[^a-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 60);
36
+ }
37
+
38
+ // Strip a leading comment prefix ("# " / "// ") from a header line. Returns the
39
+ // remainder, or null if the line is not a comment (header block has ended).
40
+ function uncomment(line) {
41
+ const m = String(line).match(/^\s*(#|\/\/)\s?(.*)$/);
42
+ return m ? m[2] : null;
43
+ }
44
+
45
+ // Parse the leading comment header of a tool script. The block runs from the
46
+ // first comment line and ends at the first non-comment, non-shebang line. A
47
+ // script is only a tool if the block contains an "open-claudia-tool: <name>"
48
+ // line.
49
+ function parseHeader(content) {
50
+ const out = { name: "", description: "", pack: "", requires: [], usage: "" };
51
+ const lines = String(content || "").split("\n");
52
+ let started = false;
53
+ for (let i = 0; i < lines.length; i++) {
54
+ const raw = lines[i];
55
+ if (i === 0 && raw.startsWith("#!")) continue; // shebang
56
+ const body = uncomment(raw);
57
+ if (body === null) {
58
+ if (started) break; // header block ended
59
+ if (raw.trim() === "") continue; // tolerate a blank line before the block
60
+ break; // first real code line, no header → not a tool
61
+ }
62
+ started = true;
63
+ const kv = body.match(/^([a-zA-Z-]+)\s*:\s*(.*)$/);
64
+ if (!kv) continue;
65
+ const key = kv[1].toLowerCase();
66
+ const val = kv[2].trim();
67
+ if (key === MARKER) out.name = sanitizeName(val);
68
+ else if (key === "requires") out.requires = val.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
69
+ else if (HEADER_KEYS.includes(key)) out[key] = val;
70
+ }
71
+ return out.name ? out : null;
72
+ }
73
+
74
+ function toolFile(name) {
75
+ return path.join(TOOLS_DIR, sanitizeName(name));
76
+ }
77
+
78
+ function readTool(name) {
79
+ const file = toolFile(name);
80
+ let content;
81
+ try { content = fs.readFileSync(file, "utf-8"); } catch (e) { return null; }
82
+ const header = parseHeader(content);
83
+ if (!header) return null;
84
+ let stat = null;
85
+ try { stat = fs.statSync(file); } catch (e) {}
86
+ return {
87
+ name: header.name || sanitizeName(name),
88
+ description: header.description || "",
89
+ pack: header.pack || "",
90
+ requires: header.requires || [],
91
+ usage: header.usage || "",
92
+ file,
93
+ executable: stat ? !!(stat.mode & 0o111) : false,
94
+ updatedAt: stat ? stat.mtime.toISOString() : "",
95
+ content,
96
+ };
97
+ }
98
+
99
+ function listTools() {
100
+ let entries;
101
+ try { entries = fs.readdirSync(TOOLS_DIR); } catch (e) { return []; }
102
+ const tools = [];
103
+ for (const name of entries) {
104
+ if (name.startsWith(".")) continue;
105
+ try { if (!fs.statSync(path.join(TOOLS_DIR, name)).isFile()) continue; }
106
+ catch (e) { continue; }
107
+ const t = readTool(name);
108
+ if (t) tools.push(t);
109
+ }
110
+ tools.sort((a, b) => a.name.localeCompare(b.name));
111
+ return tools;
112
+ }
113
+
114
+ function findTool(name) {
115
+ const needle = sanitizeName(name);
116
+ if (!needle) return null;
117
+ return readTool(needle);
118
+ }
119
+
120
+ // Build the header comment block for a script that doesn't already declare one.
121
+ // Uses the comment prefix that matches the script's shebang/extension.
122
+ function buildHeader(prefix, { name, description, pack, requires, usage }) {
123
+ const lines = [`${prefix} ${MARKER}: ${name}`];
124
+ if (description) lines.push(`${prefix} description: ${description}`);
125
+ if (pack) lines.push(`${prefix} pack: ${pack}`);
126
+ if (requires && requires.length) lines.push(`${prefix} requires: ${[].concat(requires).join(", ")}`);
127
+ if (usage) lines.push(`${prefix} usage: ${usage}`);
128
+ return lines.join("\n");
129
+ }
130
+
131
+ function commentPrefixFor(content, srcPath) {
132
+ const first = String(content || "").split("\n")[0] || "";
133
+ if (/\b(node|deno|bun)\b/.test(first) || /\.(jsx?|tsx?|go)$/i.test(srcPath || "")) return "//";
134
+ return "#";
135
+ }
136
+
137
+ // Register a script as a tool. Copies it into TOOLS_DIR under a sanitized name,
138
+ // ensures it carries a header (synthesising one from opts if absent), and makes
139
+ // it executable. Returns the stored tool.
140
+ function addTool(srcPath, opts = {}) {
141
+ ensureDir();
142
+ let content;
143
+ try { content = fs.readFileSync(srcPath, "utf-8"); }
144
+ catch (e) { throw new Error(`cannot read ${srcPath}: ${e.message}`); }
145
+
146
+ let header = parseHeader(content);
147
+ const name = sanitizeName(opts.name || (header && header.name) || path.basename(srcPath).replace(/\.[^.]+$/, ""));
148
+ if (!name) throw new Error("tool needs a name (pass --name or add an 'open-claudia-tool:' header line)");
149
+
150
+ if (!header) {
151
+ // No header in the source — synthesise one so the tool is self-documenting.
152
+ const prefix = commentPrefixFor(content, srcPath);
153
+ const block = buildHeader(prefix, {
154
+ name,
155
+ description: opts.description || "",
156
+ pack: opts.pack || "",
157
+ requires: opts.requires || [],
158
+ usage: opts.usage || "",
159
+ });
160
+ const hasShebang = content.startsWith("#!");
161
+ if (hasShebang) {
162
+ const nl = content.indexOf("\n");
163
+ content = content.slice(0, nl + 1) + block + "\n" + content.slice(nl + 1);
164
+ } else {
165
+ content = block + "\n" + content;
166
+ }
167
+ header = parseHeader(content);
168
+ } else if (opts.pack && !header.pack) {
169
+ // Source had a header but no pack link and the caller supplied one — add it.
170
+ const prefix = commentPrefixFor(content, srcPath);
171
+ content = content.replace(
172
+ new RegExp(`((#|//)\\s*${MARKER}:.*\\n)`),
173
+ `$1${prefix} pack: ${opts.pack}\n`
174
+ );
175
+ }
176
+
177
+ const dest = toolFile(name);
178
+ fs.writeFileSync(dest, content, { mode: 0o700 });
179
+ try { fs.chmodSync(dest, 0o700); } catch (e) {}
180
+ return readTool(name);
181
+ }
182
+
183
+ function removeTool(name) {
184
+ const tool = findTool(name);
185
+ if (!tool) return null;
186
+ try { fs.rmSync(tool.file, { force: true }); } catch (e) { return null; }
187
+ return tool;
188
+ }
189
+
190
+ // Env for running a tool: the bot's standard subprocess env (PATH + keyring
191
+ // creds merged) so tools are pre-authed the same way the agent is. Falls back to
192
+ // a plain keyring merge if config isn't importable (e.g. standalone test).
193
+ function runEnv() {
194
+ try { return require("./config").botSubprocessEnv(); }
195
+ catch (e) {
196
+ try {
197
+ const keyring = require("./keyring");
198
+ return { ...keyring.all(), ...process.env };
199
+ } catch (e2) { return { ...process.env }; }
200
+ }
201
+ }
202
+
203
+ // Which of a tool's required keyring keys are actually present right now — used
204
+ // by `tool show` to warn before a run fails on a missing credential.
205
+ function missingRequires(tool) {
206
+ if (!tool || !tool.requires || !tool.requires.length) return [];
207
+ let present;
208
+ try { present = new Set(require("./keyring").keys()); }
209
+ catch (e) { present = new Set(Object.keys(process.env)); }
210
+ return tool.requires.filter((k) => !present.has(k) && process.env[k] === undefined);
211
+ }
212
+
213
+ // Recognise a Write/Edit aimed at a tool file (for chat announcements).
214
+ function toolNameFromPath(filePath) {
215
+ if (!filePath) return null;
216
+ const resolved = path.resolve(String(filePath));
217
+ const rel = path.relative(TOOLS_DIR, resolved);
218
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
219
+ if (rel.split(path.sep).length !== 1) return null;
220
+ return rel;
221
+ }
222
+
223
+ module.exports = {
224
+ TOOLS_DIR, MARKER,
225
+ parseHeader, readTool, listTools, findTool, addTool, removeTool,
226
+ runEnv, missingRequires, toolNameFromPath, sanitizeName, ensureDir,
227
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.48",
3
+ "version": "2.6.50",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "setup": "node setup.js",
11
11
  "start": "node bot.js",
12
- "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js"
12
+ "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js"
13
13
  },
14
14
  "files": [
15
15
  "bot.js",
@@ -43,7 +43,9 @@
43
43
  "test-ability-merge-guard.js",
44
44
  "test-learning-e2e.js",
45
45
  "test-pack-nesting.js",
46
- "test-read-signal.js"
46
+ "test-read-signal.js",
47
+ "test-tools.js",
48
+ "test-tool-graph.js"
47
49
  ],
48
50
  "keywords": [
49
51
  "claude",
@@ -0,0 +1,97 @@
1
+ // Directed tool-graph: "tool B follows tool A". Unlike the recall graph this is
2
+ // DIRECTED (A→B carries info B→A does not) and has NO structural floor, so an
3
+ // unused chain decays to zero and is pruned. This exercises core/tool-graph.js
4
+ // end to end against an isolated DB so nothing touches the real graph.
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tool-graph-test-"));
11
+ process.env.TOOL_GRAPH_DB = path.join(tmp, "tool-graph.db");
12
+
13
+ const graph = require("./core/tool-graph");
14
+
15
+ // node:sqlite arrived in node 22; on older runtimes the graph is a no-op. Skip
16
+ // cleanly so the suite stays green rather than failing on the environment.
17
+ if (!graph.available()) {
18
+ console.log("tool-graph OK (node:sqlite unavailable — skipped)");
19
+ process.exit(0);
20
+ }
21
+
22
+ // ── reinforceSequence lays directed edges for IMMEDIATE succession only ──
23
+ graph.reinforceSequence(["auth", "list", "download"]);
24
+ let after = graph.followers("auth");
25
+ assert.strictEqual(after.length, 1, "auth has exactly one follower");
26
+ assert.strictEqual(after[0].name, "list", "list follows auth");
27
+ assert.strictEqual(after[0].weight, 1, "a single co-occurrence weighs 1 (no structural floor)");
28
+
29
+ // non-adjacent pairs are NOT linked — auth→download must not exist
30
+ assert.ok(!graph.followers("auth").some((r) => r.name === "download"), "auth→download skipped (not adjacent)");
31
+
32
+ // ── directionality: the edge is auth→list, never list→auth ──
33
+ assert.ok(graph.followers("list").some((r) => r.name === "download"), "download follows list");
34
+ assert.ok(!graph.followers("list").some((r) => r.name === "auth"), "auth does NOT follow list (directed)");
35
+ assert.strictEqual(graph.predecessors("download")[0].name, "list", "list precedes download");
36
+ assert.ok(graph.predecessors("list").some((r) => r.name === "auth"), "auth precedes list");
37
+ assert.ok(!graph.predecessors("list").some((r) => r.name === "download"), "download does NOT precede list (directed)");
38
+
39
+ // ── Hebbian bump: re-running the chain strengthens the same edges ──
40
+ graph.reinforceSequence(["auth", "list", "download"]);
41
+ assert.strictEqual(graph.followers("auth")[0].weight, 2, "second co-use bumps auth→list to 2");
42
+
43
+ // ── repeated names in a row collapse: a tool re-run in a loop must not self-link ──
44
+ graph.reinforceSequence(["loop", "loop", "loop", "end"]);
45
+ assert.ok(!graph.followers("loop").some((r) => r.name === "loop"), "no loop→loop self-edge");
46
+ assert.strictEqual(graph.followers("loop")[0].name, "end", "loop→end is the only edge from a repeated run");
47
+
48
+ // ── addFollow guards: src===dst is rejected ──
49
+ assert.strictEqual(graph.addFollow("x", "x"), false, "self-edge refused");
50
+
51
+ // ── a pure 'ensure' edge (no bump) has no last_reinforced and so survives decay ──
52
+ graph.addFollow("ensure-src", "ensure-dst"); // weight 1, last_reinforced=null
53
+ const ensured = graph.followers("ensure-src");
54
+ assert.strictEqual(ensured[0].weight, 1, "ensure edge weighs 1");
55
+
56
+ // ── decay: backdate a reinforced edge far into the past, then it prunes (no floor) ──
57
+ const db = graph.openDb();
58
+ const longAgo = new Date(Date.now() - 365 * 86400000).toISOString();
59
+ db.prepare("UPDATE tool_edges SET last_reinforced=? WHERE src=? AND dst=?").run(longAgo, "auth", "list");
60
+ let res = graph.decay({ halfLifeDays: 1, pruneBelow: 0.15 });
61
+ assert.ok(res.pruned >= 1, "a year-old edge under the floor is pruned");
62
+ assert.ok(!graph.followers("auth").some((r) => r.name === "list"), "pruned edge is gone");
63
+
64
+ // the ensure edge (last_reinforced=null) was skipped by decay, not pruned
65
+ assert.ok(graph.followers("ensure-src").some((r) => r.name === "ensure-dst"), "un-reinforced edge survives decay");
66
+
67
+ // ── decay that reduces but keeps an edge above the floor ──
68
+ graph.addFollow("warm-a", "warm-b", { bump: 10 });
69
+ const recent = new Date(Date.now() - 30 * 86400000).toISOString();
70
+ db.prepare("UPDATE tool_edges SET last_reinforced=? WHERE src=? AND dst=?").run(recent, "warm-a", "warm-b");
71
+ graph.decay({ halfLifeDays: 30, pruneBelow: 0.15 }); // one half-life → ~5
72
+ const warm = graph.followers("warm-a")[0];
73
+ assert.ok(warm && warm.name === "warm-b", "warm edge survives a single half-life");
74
+ assert.ok(warm.weight < 10 && warm.weight > 0.15, `warm edge decayed but kept (${warm.weight})`);
75
+
76
+ // ── removeEdge ──
77
+ assert.strictEqual(graph.removeEdge("warm-a", "warm-b"), true, "removeEdge succeeds");
78
+ assert.ok(!graph.followers("warm-a").length, "removed edge gone");
79
+
80
+ // ── pruneOrphans drops edges whose endpoints are no longer registered tools ──
81
+ graph.reinforceSequence(["live", "dead"]); // 'dead' will not be in the live set
82
+ const fakeLib = { listTools: () => [{ name: "live" }, { name: "end" }, { name: "loop" }, { name: "ensure-src" }, { name: "ensure-dst" }] };
83
+ const removed = graph.pruneOrphans(fakeLib);
84
+ assert.ok(removed >= 1, "edge pointing at an unregistered tool ('dead') is pruned");
85
+ assert.ok(!graph.followers("live").length, "live→dead removed because dead is not a live tool");
86
+
87
+ // ── tend() is the nightly entry: decays + prunes orphans + returns stats ──
88
+ const t = graph.tend(fakeLib);
89
+ assert.ok(typeof t.edges === "number" && typeof t.nodes === "number", "tend returns stats");
90
+ assert.ok(typeof t.pruned === "number" && typeof t.decayed === "number", "tend returns decay/prune counts");
91
+
92
+ // ── stats / allEdges sanity ──
93
+ const s = graph.stats();
94
+ assert.strictEqual(s.edges, graph.allEdges().length, "stats edge count matches allEdges");
95
+
96
+ graph._resetForTest();
97
+ console.log("tool-graph OK");
package/test-tools.js ADDED
@@ -0,0 +1,92 @@
1
+ // Reusable tools: a script the agent crystallises must register with a parseable
2
+ // header, run with the keyring merged into its env (preauth), and be findable by
3
+ // the always-on tool index. This exercises the core/tools.js library end to end
4
+ // in an isolated TOOLS_DIR so nothing touches the real ~/.open-claudia/tools.
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tools-test-"));
11
+ process.env.TOOLS_DIR = path.join(tmp, "tools");
12
+
13
+ const tools = require("./core/tools");
14
+
15
+ // ── add a bare script (no header): a header is synthesised from opts, shebang
16
+ // is preserved on line 1, and the file is made executable ──
17
+ const srcA = path.join(tmp, "hello.sh");
18
+ fs.writeFileSync(srcA, "#!/usr/bin/env bash\necho \"hi $1\"\n");
19
+ const a = tools.addTool(srcA, {
20
+ name: "hello",
21
+ description: "say hi",
22
+ pack: "greetings",
23
+ requires: ["api_key", "api_secret"],
24
+ usage: "hello <name>",
25
+ });
26
+ assert.strictEqual(a.name, "hello", "synthesised tool keeps requested name");
27
+ assert.strictEqual(a.description, "say hi", "description parsed back from synthesised header");
28
+ assert.strictEqual(a.pack, "greetings", "pack link parsed back");
29
+ assert.deepStrictEqual(a.requires, ["api_key", "api_secret"], "requires parsed back as a list");
30
+ assert.strictEqual(a.usage, "hello <name>", "usage parsed back");
31
+ assert.ok(a.executable, "stored tool is chmod +x");
32
+ assert.ok(a.content.startsWith("#!/usr/bin/env bash\n"), "shebang stays on line 1");
33
+ assert.ok(/# open-claudia-tool: hello/.test(a.content), "marker line injected with '#' prefix for a shell script");
34
+
35
+ // ── findTool / listTools see it ──
36
+ assert.strictEqual(tools.findTool("hello").name, "hello", "findTool resolves by name");
37
+ assert.strictEqual(tools.findTool("HELLO").name, "hello", "findTool is case-insensitive (sanitized)");
38
+ assert.ok(tools.listTools().some((t) => t.name === "hello"), "listTools includes the new tool");
39
+
40
+ // ── a JS source with a shebang gets a '//' comment header, not '#' ──
41
+ const srcB = path.join(tmp, "fetch.js");
42
+ fs.writeFileSync(srcB, "#!/usr/bin/env node\nconsole.log('x');\n");
43
+ const b = tools.addTool(srcB, { name: "fetch", description: "fetch a thing" });
44
+ assert.ok(/\/\/ open-claudia-tool: fetch/.test(b.content), "JS script gets '//' marker prefix");
45
+ assert.strictEqual(b.requires.length, 0, "no requires when none supplied");
46
+
47
+ // ── a source that already declares its own header is respected; a --pack passed
48
+ // in is grafted on without clobbering the existing marker ──
49
+ const srcC = path.join(tmp, "self-doc.sh");
50
+ fs.writeFileSync(
51
+ srcC,
52
+ "#!/usr/bin/env bash\n# open-claudia-tool: selfdoc\n# description: pre-documented\necho hi\n"
53
+ );
54
+ const c = tools.addTool(srcC, { pack: "linked-pack" });
55
+ assert.strictEqual(c.name, "selfdoc", "existing marker name wins");
56
+ assert.strictEqual(c.description, "pre-documented", "existing description preserved");
57
+ assert.strictEqual(c.pack, "linked-pack", "--pack grafted onto a self-documented script");
58
+
59
+ // ── missingRequires: keys absent from keyring AND env are reported; present env
60
+ // keys are not ──
61
+ process.env.api_key = "present";
62
+ delete process.env.api_secret;
63
+ const missing = tools.missingRequires(tools.findTool("hello"));
64
+ assert.ok(!missing.includes("api_key"), "a key present in env is not 'missing'");
65
+ assert.ok(missing.includes("api_secret"), "a key absent everywhere is 'missing'");
66
+ delete process.env.api_key;
67
+
68
+ // ── runEnv returns an env object (keyring merged or graceful fallback) ──
69
+ const env = tools.runEnv();
70
+ assert.ok(env && typeof env === "object" && env.PATH, "runEnv yields an env object carrying PATH");
71
+
72
+ // ── toolNameFromPath only recognises a direct child of TOOLS_DIR ──
73
+ assert.strictEqual(
74
+ tools.toolNameFromPath(path.join(process.env.TOOLS_DIR, "hello")),
75
+ "hello",
76
+ "a file directly in TOOLS_DIR resolves to its tool name"
77
+ );
78
+ assert.strictEqual(tools.toolNameFromPath("/etc/passwd"), null, "a path outside TOOLS_DIR → null");
79
+ assert.strictEqual(
80
+ tools.toolNameFromPath(path.join(process.env.TOOLS_DIR, "sub", "deep")),
81
+ null,
82
+ "a nested path under TOOLS_DIR → null"
83
+ );
84
+
85
+ // ── parseHeader returns null for a script with no marker line ──
86
+ assert.strictEqual(tools.parseHeader("#!/bin/sh\necho hi\n"), null, "no marker → not a tool");
87
+
88
+ // ── remove ──
89
+ assert.ok(tools.removeTool("hello"), "removeTool returns the removed tool");
90
+ assert.strictEqual(tools.findTool("hello"), null, "removed tool is gone");
91
+
92
+ console.log("tools OK");