@inetafrica/open-claudia 2.6.53 → 2.6.54

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.6.54
4
+ - **Tools as first-class discoverer nodes.** Reusable tools now go through the same recall pipeline as packs and entities — seed → activate → judge → render — instead of being dumped wholesale into every prompt. Until now `buildToolIndexBlock` listed *all* ~40 tools on every turn; that flat block is gone. In its place, the discoverer (`core/recall/discoverer.js`) seeds tool candidates from a new in-memory `matchTools()` (lexical scoring over name/description = strong, requires/pack/source = weak; `core/tools.js`), activates the tools belonging to any pack already in the candidate set (read from the pack↔tool graph + tool `pack:` headers — the latency-sensitive recall `expand()` stays pack/entity-only, zero regression risk), and lets the haiku walker judge them with a "keep a tool only if running it would actually help" instruction. Survivors render into a focused **"Tools that may help here"** block; the system prompt now just states the count and points to `tool search`/`tool list` for the rest. `matchTools` is in-memory (works on every node version, unlike the SQLite graphs).
5
+ - **`/tooltrace` toggle (default off).** Mirrors `/recall`. When on, posts short 🔧 lines around each reply: which tools were **surfaced** for the turn, which were **run**, and any **created/updated** — so you can watch the tool layer work. All three signals are now gated behind this one toggle (`settings.showToolTrace`); the run/created/updated banners were removed from the always-on path and the surfaced list was split out of the 🧠 `/recall` banner, so the two toggles are cleanly independent.
6
+ - **Tool usage telemetry.** A JSON sidecar (`.usage.json` in the tools dir: `runCount` + `lastUsed` per tool, recorded at end-of-turn for each successful run) works on every node version. `matchTools` breaks score ties by `runCount` so well-worn tools surface first.
7
+ - **`tool search` + add-time duplicate guard.** New `open-claudia tool search <query>` for finding a tool on demand; `tool add` now warns (non-blocking) when an existing tool looks similar, nudging toward extending it with a subcommand over spawning a near-duplicate.
8
+ - **Dream tool hygiene.** The nightly pass now also flags tools created-but-never-run for 30+ days and near-duplicate tool pairs (alongside the existing dangling-`--pack` flag). Flag-only — a tool is never auto-deleted. Extends `test-tools.js` (matchTools/telemetry) and `test-recall-discoverer.js` (tool seeding). Degrades to a graceful no-op where `node:sqlite` is unavailable.
9
+
3
10
  ## v2.6.53
4
11
  - **Pack↔tool contextual edges — surface how a tool is used in a topic.** Completes the v2.6.49 tool story. The directed tool-graph answers "what runs *next*"; this adds the orthogonal question "how is a tool *used in this context*". A new `pack_tool_edges` table (`core/tool-graph.js`, sharing the existing SQLite store, decay and pruning) records, for each turn, the reusable tools that ran while a context pack was open — keyed by `(pack, tool, command-shape)` so distinct invocations of the same tool coexist and the strongest surface. Capture is automatic and deterministic (no hand-authoring): `runner.js` already tracks the tools run and the packs opened each turn, so at end-of-turn it records a pack↔tool edge per (opened-pack × run-tool), Hebbian-reinforced. Commands are stored as **shapes, not literals** — a new `shapeCommand()` strips ids, paths, numbers and quoted text to placeholders (`spaces docs --task-id <id> <path>`) so the memory is a reusable pattern, not a stale one-off; `parseToolRuns()` extracts `{name, shape}` from a shell command (split across chained invocations) and feeds both graphs. Surfacing: when a pack is injected into context, `formatPackForContext` appends a **"Tools used in this pack"** block with the 1–2 strongest invocations, so the agent sees the concrete command instead of re-deriving it; `tool show` gains the inverse "Used in packs" view; an optional `intent` column lets a one-line *why* be enriched later without a migration. The nightly dream tends these edges alongside the follows-edges (decay + prune orphaned tools/packs). Degrades to a graceful no-op where `node:sqlite` is unavailable, exactly like the existing tool-graph. Extends `test-tool-graph.js`.
5
12
 
package/bin/cli.js CHANGED
@@ -370,7 +370,7 @@ Memory tools:
370
370
  open-claudia transcript-window <pattern> Search project transcript, show hits with context
371
371
  (alias: tw; --help for options)
372
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)
373
+ open-claudia tool list|search|show|add|run|remove Reusable tools: executable scripts the agent saves & re-runs (keyring preauth)
374
374
  open-claudia entity list|show|match|note Entity notes: people/places/projects memory
375
375
  open-claudia lessons list|add|remove|show Always-loaded learned rules (cross-cutting, promoted after a miss)
376
376
  open-claudia ideas list|add|remove|show Self-improvement backlog (captured by the nightly dream)
package/bin/tool.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // heredoc. The executable sibling of context packs (`open-claudia pack`).
4
4
  //
5
5
  // open-claudia tool list — index (name — description [skill])
6
+ // open-claudia tool search <query> — lexical match (find before adding a dup)
6
7
  // open-claudia tool show <name> — full docs, path, keyring status
7
8
  // open-claudia tool add <path> [--name n] — register a script as a tool
8
9
  // [--pack <dir>] [--desc "..."] [--requires "k1,k2"] [--usage "..."]
@@ -53,6 +54,25 @@ function run(args) {
53
54
  break;
54
55
  }
55
56
 
57
+ case "search":
58
+ case "find": {
59
+ const query = rest.join(" ").trim();
60
+ if (!query) { console.error("Usage: tool search <query>"); process.exitCode = 1; return; }
61
+ const matches = tools.matchTools(query, { limit: 10 });
62
+ if (!matches.length) {
63
+ console.log(`No tools match "${query}". See all: open-claudia tool list`);
64
+ return;
65
+ }
66
+ console.log(`${matches.length} tool(s) matching "${query}":\n`);
67
+ for (const m of matches) {
68
+ const t = tools.findTool(m.name);
69
+ const needs = t && t.requires && t.requires.length ? ` (keyring: ${t.requires.join(", ")})` : "";
70
+ console.log(`• ${m.name} — ${m.description || "(no description)"}${needs}`);
71
+ }
72
+ console.log(`\nRun: open-claudia tool run <name> [args] · Docs: open-claudia tool show <name>`);
73
+ break;
74
+ }
75
+
56
76
  case "show":
57
77
  case "cat": {
58
78
  const name = rest[0];
@@ -112,6 +132,17 @@ function run(args) {
112
132
  const missing = tools.missingRequires(t);
113
133
  if (missing.length) console.log(`Note: missing keyring keys it needs: ${missing.join(", ")}`);
114
134
  }
135
+ // Similarity guard (warn, don't block): if existing tools already cover
136
+ // this, prefer extending one with a subcommand over a near-duplicate.
137
+ try {
138
+ const near = tools.matchTools([t.name, t.description].filter(Boolean).join(" "), { limit: 3 })
139
+ .filter((m) => m.name !== t.name);
140
+ if (near.length) {
141
+ console.log(`\nHeads up — ${near.length} existing tool(s) look similar:`);
142
+ for (const m of near) console.log(` • ${m.name} — ${m.description || "(no description)"}`);
143
+ console.log("Consider extending one with a new subcommand instead of keeping a near-duplicate (open-claudia tool show <name>).");
144
+ }
145
+ } catch (e) { /* best-effort */ }
115
146
  } catch (e) {
116
147
  console.error(`Could not add tool: ${e.message}`); process.exitCode = 1;
117
148
  }
@@ -149,7 +180,7 @@ function run(args) {
149
180
  break;
150
181
 
151
182
  default:
152
- console.log('Usage: open-claudia tool [list | show <name> | add <path> [flags] | run <name> [args] | remove <name> | path]');
183
+ console.log('Usage: open-claudia tool [list | search <query> | show <name> | add <path> [flags] | run <name> [args] | remove <name> | path]');
153
184
  }
154
185
  }
155
186
 
package/core/actions.js CHANGED
@@ -290,6 +290,12 @@ async function handleAction(envelope) {
290
290
  await send(`Recall debug: ${state.settings.showRecall ? "on" : "off"}`);
291
291
  return;
292
292
  }
293
+ if (d.startsWith("tt:")) {
294
+ state.settings.showToolTrace = d.slice(3) === "on";
295
+ saveState();
296
+ await send(`Tool trace: ${state.settings.showToolTrace ? "on" : "off"}`);
297
+ return;
298
+ }
293
299
  if (d.startsWith("cw:")) {
294
300
  const v = d.slice(3);
295
301
  if (v === "default") state.settings.compactWindow = null;
package/core/dream.js CHANGED
@@ -838,10 +838,35 @@ async function runDream({ trigger = "manual" } = {}) {
838
838
  if (tg.edges || tg.decayed || tg.pruned) {
839
839
  bits.push(`🔧 Tool graph: ${tg.edges} edges / ${tg.nodes} nodes (decayed ${tg.decayed}, pruned ${tg.pruned}).`);
840
840
  }
841
- const dangling = toolsLib.listTools().filter((t) => t.pack && !packs.readPack(t.pack));
841
+ const allTools = toolsLib.listTools();
842
+ const dangling = allTools.filter((t) => t.pack && !packs.readPack(t.pack));
842
843
  if (dangling.length) {
843
844
  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
  }
846
+ // Zero-run tools: created but never run and gone cold. Telemetry runCount
847
+ // is authoritative; file mtime stands in for "how long ago". Flag only —
848
+ // a never-used tool may still be worth keeping, so the user decides.
849
+ const STALE_DAYS = Number(process.env.TOOL_STALE_DAYS || 30);
850
+ const cutoff = Date.now() - STALE_DAYS * 86400000;
851
+ const coldUnused = allTools.filter((t) => !(t.runCount > 0) && !t.lastUsed).filter((t) => {
852
+ try { return fs.statSync(t.file).mtimeMs < cutoff; } catch (e) { return false; }
853
+ });
854
+ if (coldUnused.length) {
855
+ bits.push(`🗃️ ${coldUnused.length} tool(s) created but never run in ${STALE_DAYS}+ days: ${coldUnused.map((t) => t.name).join(", ")} — keep, fold into another, or remove.`);
856
+ }
857
+ // Near-duplicate pairs: two tools whose name+description overlap strongly,
858
+ // a sign one should have been a subcommand of the other. Dedupe by sorted
859
+ // pair key; flag only (consolidating is a judgement call for the user).
860
+ const DUP_THRESHOLD = Number(process.env.TOOL_DUP_THRESHOLD || 4);
861
+ const dupPairs = new Set();
862
+ for (const t of allTools) {
863
+ const near = toolsLib.matchTools([t.name, t.description].filter(Boolean).join(" "), { threshold: DUP_THRESHOLD, limit: 5 })
864
+ .filter((m) => m.name !== t.name);
865
+ for (const m of near) dupPairs.add([t.name, m.name].sort().join(" ↔ "));
866
+ }
867
+ if (dupPairs.size) {
868
+ bits.push(`👯 ${dupPairs.size} near-duplicate tool pair(s): ${[...dupPairs].join(", ")} — consider merging into one tool with subcommands.`);
869
+ }
845
870
  toolNote = bits.join("\n");
846
871
  } catch (e) { /* tool graph is best-effort (old node) */ }
847
872
 
package/core/handlers.js CHANGED
@@ -123,7 +123,7 @@ register({
123
123
  if (!authorized(env)) return;
124
124
  send([
125
125
  "Session: /session /sessions /projects /continue /status /stop /end",
126
- "Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine /recall",
126
+ "Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine /recall /tooltrace",
127
127
  "Identity: /whoami /link",
128
128
  "Team: /people /intros /auth (owner)",
129
129
  "Automation: /cron /vault /soul /dreamsummary",
@@ -743,6 +743,29 @@ register({
743
743
  },
744
744
  });
745
745
 
746
+ register({
747
+ name: "tooltrace", description: "Show tool activity each turn (surfaced/ran/updated)", args: "[on|off]",
748
+ handler: async (env, { tail }) => {
749
+ if (!authorized(env)) return;
750
+ const { settings } = currentState();
751
+ if (tail) {
752
+ const v = tail.trim().toLowerCase();
753
+ if (v === "on" || v === "true") settings.showToolTrace = true;
754
+ else if (v === "off" || v === "false") settings.showToolTrace = false;
755
+ else return send(`Usage: /tooltrace [on|off]. Currently ${settings.showToolTrace ? "on" : "off"}.`);
756
+ saveState();
757
+ return send(`Tool trace: ${settings.showToolTrace ? "on" : "off"}`);
758
+ }
759
+ send(
760
+ `Tool trace: ${settings.showToolTrace ? "on" : "off"}\n\n` +
761
+ "When on, I post short 🔧 lines around each reply: which reusable tools were surfaced for the turn, which I actually ran, and any I created or updated. Lets you watch the tool layer work. Off by default.",
762
+ { keyboard: { inline_keyboard: [
763
+ [{ text: "On", callback_data: "tt:on" }, { text: "Off", callback_data: "tt:off" }],
764
+ ] } },
765
+ );
766
+ },
767
+ });
768
+
746
769
  register({
747
770
  name: "budget", description: "Set max spend for next task", args: "[$N]",
748
771
  handler: async (env, { tail }) => {
@@ -19,6 +19,13 @@ const graph = require("./graph");
19
19
  const metrics = require("./metrics");
20
20
  const { spawnSubagent } = require("../subagent");
21
21
  const warmWalker = require("./warm-walker");
22
+ const toolsLib = require("../tools");
23
+ // Tool-graph is the directed-usage source of truth (pack↔tool edges). The
24
+ // discoverer READS it to activate tools that belong to a surfaced pack; it never
25
+ // writes the recall graph with tool nodes (keeps the hot expand() path pack/
26
+ // entity-only). Optional — absent on old node where node:sqlite is missing.
27
+ let toolGraph = null;
28
+ try { toolGraph = require("../tool-graph"); } catch (e) { /* old node */ }
22
29
 
23
30
  const WALKER_MODEL = process.env.RECALL_DISCOVERER_MODEL || "haiku";
24
31
  const WALKER_TIMEOUT_MS = Number(process.env.RECALL_DISCOVERER_TIMEOUT_MS || 25000);
@@ -64,6 +71,18 @@ function excerptFor(id, packsLib, entitiesLib) {
64
71
  const body = [p.sections.Stance, p.sections.State].filter(Boolean).join("\n").trim();
65
72
  return { id, name: p.name, description: p.description, excerpt: clip(body, EXCERPT_CHARS) };
66
73
  }
74
+ if (id.startsWith("tool:")) {
75
+ const name = id.slice(5);
76
+ const t = toolsLib.findTool(name);
77
+ if (!t) return null;
78
+ // Excerpt the walker judges a tool against: what it does (usage) plus the
79
+ // concrete command shapes it's actually been run as in a topic, so "would
80
+ // this help here?" is answerable without reading the source.
81
+ let shapes = [];
82
+ if (toolGraph) { try { shapes = toolGraph.toolPacks(name, 2).map((p) => p.command).filter(Boolean); } catch (e) {} }
83
+ const excerpt = [t.usage, shapes.length ? `e.g. ${shapes.join(" | ")}` : ""].filter(Boolean).join(" — ");
84
+ return { id, name, description: t.description, excerpt: clip(excerpt, EXCERPT_CHARS) };
85
+ }
67
86
  const e = entitiesLib.readEntity(id.slice(7));
68
87
  if (!e) return null;
69
88
  return { id, name: e.name, description: e.description, excerpt: clip((e.sections.Notes || "").trim(), EXCERPT_CHARS) };
@@ -75,6 +94,7 @@ const WALKER_SYSTEM = [
75
94
  "You are given the user's message, recent conversation, and candidate memory nodes (some matched by keyword, some pulled in by graph links).",
76
95
  "Decide which nodes are GENUINELY relevant to what the user is doing now.",
77
96
  "Keep graph-linked nodes ONLY if they actually bear on the task (e.g. a shared theme/commit-style that governs the thing being worked on). Drop incidental keyword overlaps.",
97
+ "Some candidates are reusable tools (id starts `tool:`). Keep a tool ONLY if running it would actually help with THIS task; drop tools merely related by keyword.",
78
98
  "For each kept node write a terse why (≤12 words). Quote concrete facts verbatim; never invent.",
79
99
  'Reply with ONLY a JSON array: [{"id":"pack:foo","why":"shared lime theme governs this app"}]. Use [] if none.',
80
100
  ].join("\n");
@@ -154,12 +174,22 @@ async function run(ctx) {
154
174
  );
155
175
  } catch (e) {}
156
176
 
157
- const seedCount = packSeeds.length + entSeeds.length;
177
+ // Tool seeds: direct FTS-style match of the user's words (and context) against
178
+ // the tool corpus — the SEED stage for tools, peer to pack/entity seeding.
179
+ const toolSeedByName = new Map();
180
+ try {
181
+ for (const t of toolsLib.matchTools(userText, { limit: 5 })) toolSeedByName.set(t.name, { ...t, origin: "user" });
182
+ if (fullContext) for (const t of toolsLib.matchTools(fullContext, { limit: 5 })) {
183
+ if (!toolSeedByName.has(t.name)) toolSeedByName.set(t.name, { ...t, origin: "context" });
184
+ }
185
+ } catch (e) {}
186
+
187
+ const seedCount = packSeeds.length + entSeeds.length + toolSeedByName.size;
158
188
 
159
189
  // 1: pre-gate.
160
190
  if (!needsRecall(userText, seedCount)) {
161
191
  metrics.logTurn({ engine: "discoverer", query: userText, gated: true, latencyMs: Date.now() - started });
162
- return { packBlock: "", entityBlock: "", packMatches: [], entityMatches: [], why: {}, gated: true };
192
+ return { packBlock: "", entityBlock: "", toolBlock: "", packMatches: [], entityMatches: [], toolMatches: [], why: {}, gated: true };
163
193
  }
164
194
 
165
195
  // 3: spreading activation from seeds across the graph.
@@ -182,6 +212,47 @@ async function run(ctx) {
182
212
  candidates.push({ ...base, activated: !seedIds.has(id), via: act ? act.via : null });
183
213
  }
184
214
 
215
+ // Tool candidates = direct tool seeds + tools that belong to any candidate
216
+ // pack (declared via the tool's `pack:` header, or used-in-topic per the
217
+ // tool-graph). This is the tool "activation": tools ride on whatever packs the
218
+ // FTS+graph already surfaced, so a pack seed pulls in its tools the user never
219
+ // named — then the walker decides which actually help. via != null => activated.
220
+ const candPackDirs = new Set();
221
+ for (const id of candIds) if (id.startsWith("pack:")) candPackDirs.add(id.slice(5));
222
+ const toolCand = new Map();
223
+ for (const [name, t] of toolSeedByName) toolCand.set(name, { name, description: t.description, via: null });
224
+ if (candPackDirs.size) {
225
+ try {
226
+ const byPack = new Map();
227
+ for (const t of toolsLib.listTools()) {
228
+ if (!t.pack) continue;
229
+ if (!byPack.has(t.pack)) byPack.set(t.pack, []);
230
+ byPack.get(t.pack).push(t);
231
+ }
232
+ for (const dir of candPackDirs) {
233
+ for (const t of (byPack.get(dir) || [])) {
234
+ if (!toolCand.has(t.name)) toolCand.set(t.name, { name: t.name, description: t.description, via: `pack:${dir}` });
235
+ }
236
+ }
237
+ } catch (e) {}
238
+ if (toolGraph) {
239
+ try {
240
+ for (const dir of candPackDirs) {
241
+ for (const u of toolGraph.packTools(dir, 3)) {
242
+ if (toolCand.has(u.tool)) continue;
243
+ const t = toolsLib.findTool(u.tool);
244
+ if (t) toolCand.set(u.tool, { name: u.tool, description: t.description, via: `pack:${dir}` });
245
+ }
246
+ }
247
+ } catch (e) {}
248
+ }
249
+ }
250
+ for (const c of toolCand.values()) {
251
+ const base = excerptFor(`tool:${c.name}`, packsLib, entitiesLib);
252
+ if (!base) continue;
253
+ candidates.push({ ...base, activated: !!c.via, via: c.via });
254
+ }
255
+
185
256
  const whyById = (await walk(userText, contextText || fullContext, candidates)) || null;
186
257
 
187
258
  // Decide the kept set. Walker result wins; on fail-open keep the user-origin
@@ -192,7 +263,8 @@ async function run(ctx) {
192
263
  } else {
193
264
  keptIds = new Set(
194
265
  [...packSeeds.filter((m) => m.origin === "user").map((m) => `pack:${m.dir}`),
195
- ...entSeeds.filter((m) => m.origin === "user").map((m) => `entity:${m.slug}`)],
266
+ ...entSeeds.filter((m) => m.origin === "user").map((m) => `entity:${m.slug}`),
267
+ ...[...toolSeedByName.values()].filter((t) => t.origin === "user").map((t) => `tool:${t.name}`)],
196
268
  );
197
269
  }
198
270
 
@@ -229,6 +301,25 @@ async function run(ctx) {
229
301
  if (bullets.length) packBlock = `\n\n### Why these surfaced (discoverer)\n${bullets.join("\n")}${packBlock}`;
230
302
  }
231
303
 
304
+ // Tools the walker kept (or, on fail-open, user-origin tool seeds). Rendered as
305
+ // their own contextual block — this replaces the old flat "all 40 tools every
306
+ // turn" dump: tools surface only when they bear on the task, with a why.
307
+ const toolMatches = [];
308
+ for (const c of toolCand.values()) {
309
+ if (keptIds.has(`tool:${c.name}`)) {
310
+ toolMatches.push({ name: c.name, description: c.description, why: whyById ? whyById.get(`tool:${c.name}`) || "" : "" });
311
+ }
312
+ }
313
+ let toolBlock = "";
314
+ if (toolMatches.length) {
315
+ const lines = toolMatches.map((m) => {
316
+ const t = toolsLib.findTool(m.name);
317
+ const needs = t && t.requires && t.requires.length ? ` (keyring: ${t.requires.join(", ")})` : "";
318
+ return `- ${m.name} — ${m.description || "(no description)"}${needs}${m.why ? ` — ${m.why}` : ""}`;
319
+ });
320
+ toolBlock = `\n\n## Tools that may help here\nSurfaced for this turn — run with \`open-claudia tool run <name> [args]\`; read docs/source with \`open-claudia tool show <name>\`:\n${lines.join("\n")}\n`;
321
+ }
322
+
232
323
  metrics.logTurn({
233
324
  engine: "discoverer",
234
325
  query: userText,
@@ -240,8 +331,8 @@ async function run(ctx) {
240
331
  });
241
332
 
242
333
  return {
243
- packBlock, entityBlock, packMatches: finalPacks, entityMatches: finalEnts,
244
- why: whyById ? Object.fromEntries(whyById) : {}, gated: false,
334
+ packBlock, entityBlock, toolBlock, packMatches: finalPacks, entityMatches: finalEnts,
335
+ toolMatches, why: whyById ? Object.fromEntries(whyById) : {}, gated: false,
245
336
  };
246
337
  }
247
338
 
package/core/runner.js CHANGED
@@ -889,6 +889,13 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
889
889
  skillNotified.add(key);
890
890
  chatContext.run(store, () => send(text).catch(() => {}));
891
891
  };
892
+ // Tool-activity visibility (surfaced / ran / created-updated). Off by default,
893
+ // toggled per-channel with /tooltrace — mirrors /recall's showRecall. Dedup +
894
+ // delivery reuse notifySkill so a tool touched twice a turn announces once.
895
+ const notifyToolTrace = (key, text) => {
896
+ if (!settings.showToolTrace) return;
897
+ notifySkill(key, text);
898
+ };
892
899
  const noteSkillToolUse = (toolName, input) => {
893
900
  try {
894
901
  if (toolName === "Skill" && input?.skill) {
@@ -918,8 +925,8 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
918
925
  }
919
926
  const toolName2 = toolsLib.toolNameFromPath(filePath);
920
927
  if (toolName2) {
921
- if (toolsLib.findTool(toolName2)) notifySkill(`tool:${toolName2}`, `🔧 Updating tool: ${toolName2} — open-claudia tool show ${toolName2} to inspect.`);
922
- else notifySkill(`tool:${toolName2}`, `🔧 New tool: ${toolName2} — open-claudia tool run ${toolName2} to use it.`);
928
+ if (toolsLib.findTool(toolName2)) notifyToolTrace(`tool:${toolName2}`, `🔧 Updating tool: ${toolName2} — open-claudia tool show ${toolName2} to inspect.`);
929
+ else notifyToolTrace(`tool:${toolName2}`, `🔧 New tool: ${toolName2} — open-claudia tool run ${toolName2} to use it.`);
923
930
  return;
924
931
  }
925
932
  const dir = skillsLib.skillNameFromPath(filePath);
@@ -971,8 +978,23 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
971
978
  // next); the sanitized command shape feeds pack↔tool capture (how a tool
972
979
  // is used in a topic). Parsing lives in tool-graph so it stays testable.
973
980
  try {
974
- for (const run of require("./tool-graph").parseToolRuns(cmd)) toolRunsThisTurn.push(run);
981
+ for (const run of require("./tool-graph").parseToolRuns(cmd)) {
982
+ toolRunsThisTurn.push(run);
983
+ // Visibility (gated by /tooltrace): one 🔧 line per distinct tool run
984
+ // this turn. Telemetry (recordRun) fires at end-of-turn on success.
985
+ notifyToolTrace(`ran:${run.name}`, `🔧 Ran tool: ${run.name}`);
986
+ }
975
987
  } catch (e) { /* graph optional (old node) */ }
988
+ // `open-claudia tool add <src> [--name X]` — a tool being saved/updated.
989
+ // The Write/Edit path above only catches direct edits to TOOLS_DIR; `tool
990
+ // add` copies an external file in, so detect it here. Gated by /tooltrace.
991
+ const addRe = /\btool\s+add\s+(\S+)([^\n;|&]*)/gi;
992
+ while ((m = addRe.exec(cmd))) {
993
+ let name = (m[2].match(/--name\s+["']?([^\s"']+)/i) || [])[1];
994
+ if (!name) name = (String(m[1]).split(/[\\/]/).pop() || "").replace(/\.[^.]+$/, "");
995
+ name = toolsLib.sanitizeName(name);
996
+ if (name) notifyToolTrace(`added:${name}`, `🛠️ Saved tool: ${name} — open-claudia tool show ${name} to inspect.`);
997
+ }
976
998
  } catch (e) { /* announcements are best-effort */ }
977
999
  };
978
1000
 
@@ -1007,13 +1029,21 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1007
1029
  // drained here to keep the per-turn buffer from leaking into the next turn.)
1008
1030
  try {
1009
1031
  const injected = require("./system-prompt").consumeLastInjected();
1010
- if (settings.showRecall && injected && injected.recall) {
1032
+ if (injected && injected.recall) {
1011
1033
  const r = injected.recall;
1012
1034
  const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1013
1035
  const fmt = (arr, icon) => arr.map((x) => (x.why ? `${icon} <b>${esc(x.name)}</b> — ${esc(x.why)}` : `${icon} <b>${esc(x.name)}</b>`));
1014
- const lines = [...fmt(r.packs || [], "📦"), ...fmt(r.entities || [], "👤")];
1015
- if (lines.length) send(`🧠 <b>Recall this turn</b> (${esc(r.engine)})\n${lines.join("\n")}`, telegramHtmlOpts()).catch(() => {});
1016
- else if (r.gated) send(`🧠 <b>Recall</b> (${esc(r.engine)}): skipped by pre-gate — trivial turn.`, telegramHtmlOpts()).catch(() => {});
1036
+ // 🧠 packs/entities recall gated by /recall.
1037
+ if (settings.showRecall) {
1038
+ const lines = [...fmt(r.packs || [], "📦"), ...fmt(r.entities || [], "👤")];
1039
+ if (lines.length) send(`🧠 <b>Recall this turn</b> (${esc(r.engine)})\n${lines.join("\n")}`, telegramHtmlOpts()).catch(() => {});
1040
+ else if (r.gated) send(`🧠 <b>Recall</b> (${esc(r.engine)}): skipped by pre-gate — trivial turn.`, telegramHtmlOpts()).catch(() => {});
1041
+ }
1042
+ // 🔧 tools surfaced this turn (first-class discoverer nodes) — gated by /tooltrace.
1043
+ if (settings.showToolTrace) {
1044
+ const toolLines = fmt(r.tools || [], "🔧");
1045
+ if (toolLines.length) send(`🔧 <b>Tools surfaced this turn</b>\n${toolLines.join("\n")}`, telegramHtmlOpts()).catch(() => {});
1046
+ }
1017
1047
  }
1018
1048
  } catch (e) { /* best-effort */ }
1019
1049
  // /stop landed during the pre-spawn window (recall/compaction): bail before
@@ -1369,6 +1399,15 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1369
1399
  catch (e) { /* best-effort */ }
1370
1400
  }
1371
1401
 
1402
+ // Tool usage telemetry: a JSON sidecar (runCount + lastUsed per tool) that
1403
+ // works on every node version (unlike the SQLite graphs above). matchTools
1404
+ // breaks score ties by runCount so well-worn tools surface first, and dream
1405
+ // hygiene flags tools created-but-never-run. Fires for any successful run.
1406
+ if (turnSucceeded && toolRunsThisTurn.length) {
1407
+ try { for (const t of toolRunsThisTurn) toolsLib.recordRun(t.name); }
1408
+ catch (e) { /* best-effort */ }
1409
+ }
1410
+
1372
1411
  // Pack↔tool contextual edges: a reusable tool run while a context pack was
1373
1412
  // open this turn records "this tool was used in this topic, as `<shape>`",
1374
1413
  // so next time the pack is active the discoverer can surface the concrete
@@ -66,37 +66,18 @@ 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.
69
+ // Always-on tool guidance: teaches the BEHAVIOUR (crystallise procedures into
70
+ // tools; how to find and run them). It does NOT dump the tool list — that used to
71
+ // inject all ~40 tools every turn regardless of relevance. Tools now surface
72
+ // contextually as first-class discoverer nodes (see discoverer.js toolBlock),
73
+ // the same seed→activate→judge→render path as packs/entities; this block only
74
+ // names the count and the lookup commands (`tool list` / `tool search`).
75
75
  function buildToolIndexBlock() {
76
76
  let listing = "";
77
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";
78
+ const count = require("./tools").listTools().length;
79
+ if (count) {
80
+ listing = `\nYou have ${count} reusable tool${count === 1 ? "" : "s"}. The ones relevant to a turn are surfaced automatically below (\"Tools that may help here\"). To find others on demand: \`open-claudia tool search <query>\` (lexical match) or \`open-claudia tool list\`; read docs/source with \`open-claudia tool show <name>\`; run with \`open-claudia tool run <name> [args]\`.\n`;
100
81
  } else {
101
82
  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
83
  }
@@ -761,18 +742,23 @@ async function promptWithDynamicContext(prompt, opts = {}) {
761
742
  userText, contextText, fullContext, packLimit, budget, helpers,
762
743
  });
763
744
  const { packBlock, entityBlock } = result;
745
+ const toolBlock = result.toolBlock || "";
764
746
  const why = result.why || {};
765
747
  lastInjected.recall = {
766
748
  engine: engine.name || recall.activeEngineName(settings),
767
749
  gated: !!result.gated,
768
750
  packs: (result.packMatches || []).map((m) => ({ name: m.name || m.dir, why: why[`pack:${m.dir}`] || "" })),
769
751
  entities: (result.entityMatches || []).map((m) => ({ name: m.name || m.slug, why: why[`entity:${m.slug}`] || "" })),
752
+ // Tools surfaced as first-class discoverer nodes this turn (name + why).
753
+ // Drives the /tooltrace "surfaced" banner; the toolBlock below is the
754
+ // context the agent actually sees.
755
+ tools: (result.toolMatches || []).map((m) => ({ name: m.name, why: m.why || why[`tool:${m.name}`] || "" })),
770
756
  };
771
757
  const budgetNote = budget.omitted > 0
772
758
  ? `\n\n## Memory budget\n${budget.omitted} matched memory item${budget.omitted === 1 ? " was" : "s were"} omitted to keep this turn under the recall budget (${budget.maxChars} chars). Use \`open-claudia pack show <dir>\`, \`entity show <slug>\`, or transcript search if deeper context is needed.`
773
759
  : "";
774
760
  const transcriptPointer = opts.includeTranscriptPointer ? `${transcriptPointerNote(state)}\n\n` : "";
775
- return `${transcriptPointer}${buildDynamicContextBlock()}${packBlock}${entityBlock}${budgetNote}${voiceReplyGuidance()}\n\nCurrent user request:\n${prompt}`;
761
+ return `${transcriptPointer}${buildDynamicContextBlock()}${packBlock}${entityBlock}${toolBlock}${budgetNote}${voiceReplyGuidance()}\n\nCurrent user request:\n${prompt}`;
776
762
  } catch (e) {
777
763
  return prompt;
778
764
  }
@@ -5,9 +5,18 @@
5
5
  // Why separate: the recall graph spreads activation over packs+entities to pick
6
6
  // which MEMORY to surface. Tools are a different corpus with different physics —
7
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.
8
+ // symmetric "these are related". So tools get their own tiny graph and the recall
9
+ // graph's hot expand() path stays pack/entity-only (no tuning, no crowding of
10
+ // structural pack edges, works unchanged on every node version).
11
+ //
12
+ // How tools still reach the discoverer: this graph is the directed-usage source
13
+ // of truth, and the discoverer READS it (recordPackTool → packTools/toolPacks) to
14
+ // activate the tools belonging to a pack it already surfaced — then its haiku
15
+ // walker judges each tool with a why and drops incidental ones. That walker is
16
+ // the noise filter an earlier "mixing would be noise" worry assumed didn't exist;
17
+ // because the discoverer judges candidates, tools participate as first-class
18
+ // nodes (seed via matchTools → activate via this graph → judge → render) WITHOUT
19
+ // ever being written into the recall graph as nodes.
11
20
  //
12
21
  // Why directed: A→B ("B follows A") carries real information that B→A does not.
13
22
  // auth-then-fetch is a pipeline; fetch-then-auth is nonsense. Edges are stored
package/core/tools.js CHANGED
@@ -35,6 +35,97 @@ function sanitizeName(name) {
35
35
  .replace(/[^a-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 60);
36
36
  }
37
37
 
38
+ // ── Run telemetry ────────────────────────────────────────────────────────────
39
+ // A tiny JSON sidecar (name -> { runCount, lastUsed }) kept beside the tools.
40
+ // Deliberately NOT a SQLite graph: telemetry must work on the node:20 pods where
41
+ // node:sqlite is absent, and it's a flat counter, not a graph. Dotfile so
42
+ // listTools (which skips ".") never mistakes it for a tool.
43
+ const USAGE_FILE = path.join(TOOLS_DIR, ".usage.json");
44
+
45
+ function readUsage() {
46
+ try { return JSON.parse(fs.readFileSync(USAGE_FILE, "utf-8")) || {}; }
47
+ catch (e) { return {}; }
48
+ }
49
+
50
+ function writeUsage(u) {
51
+ try { ensureDir(); fs.writeFileSync(USAGE_FILE, JSON.stringify(u), { mode: 0o600 }); }
52
+ catch (e) { /* best-effort */ }
53
+ }
54
+
55
+ // Bump a tool's run counter. Called at end of turn from the runner when it parses
56
+ // an `open-claudia tool run <name>` out of the shell. Feeds matchTools' tiebreak
57
+ // (popular tools rank up) and the dream's zero-run hygiene flag.
58
+ function recordRun(name) {
59
+ const n = sanitizeName(name);
60
+ if (!n) return;
61
+ const u = readUsage();
62
+ const cur = u[n] || { runCount: 0, lastUsed: "" };
63
+ cur.runCount = (cur.runCount || 0) + 1;
64
+ cur.lastUsed = new Date().toISOString();
65
+ u[n] = cur;
66
+ writeUsage(u);
67
+ }
68
+
69
+ // ── Lexical matching (in-memory) ─────────────────────────────────────────────
70
+ // Tools are few and short, so we score in memory rather than standing up a third
71
+ // FTS index — and unlike the recall/tool graphs this then works on every node
72
+ // version. Mirrors matchPacks' semantics: a hit in a strong field (name /
73
+ // description / usage) counts 2, a weak-field hit (requires / pack / source) 1,
74
+ // tools at/above the threshold win. This is the SEED stage of the discoverer for
75
+ // the tool corpus — keyword feeds the agentic walker, it does not replace it.
76
+ const TOOL_STOPWORDS = new Set(("a an and are as at be but by can did do for from had has have how i if in is it its " +
77
+ "me my no not of on or our so that the their then there this to up us was we what when where which who why " +
78
+ "will with you your yes yeah ok okay please thanks just like dont im its run tool use").split(" "));
79
+
80
+ function toolQueryTerms(text) {
81
+ const seen = new Set();
82
+ const terms = [];
83
+ for (const raw of String(text || "").toLowerCase().split(/[^a-z0-9_.-]+/)) {
84
+ const t = raw.replace(/^[.-]+|[.-]+$/g, "");
85
+ if (t.length < 2 || TOOL_STOPWORDS.has(t) || seen.has(t)) continue;
86
+ seen.add(t);
87
+ terms.push(t);
88
+ if (terms.length >= 40) break;
89
+ }
90
+ return terms;
91
+ }
92
+
93
+ function wordsOf(s) {
94
+ return new Set(String(s || "").toLowerCase().split(/[^a-z0-9_.-]+/).filter(Boolean));
95
+ }
96
+
97
+ // A term hits a word set on exact match, or a light prefix stem (deploy~deploys,
98
+ // upload~uploads) for terms long enough that a prefix isn't noise.
99
+ function termHits(term, wordSet) {
100
+ if (wordSet.has(term)) return true;
101
+ if (term.length >= 4) {
102
+ for (const w of wordSet) if (w.startsWith(term) || term.startsWith(w)) return true;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ // Score registered tools against free text. Returns [{ name, description, score,
108
+ // runCount }] over the threshold, strongest first (runCount breaks ties so a
109
+ // well-worn tool outranks a never-run namesake).
110
+ function matchTools(text, { limit = 5, threshold = null } = {}) {
111
+ const terms = toolQueryTerms(text);
112
+ if (!terms.length) return [];
113
+ const min = threshold ?? Number(process.env.TOOL_MATCH_THRESHOLD || 2);
114
+ const out = [];
115
+ for (const t of listTools()) {
116
+ const strong = wordsOf([t.name, t.description, t.usage].filter(Boolean).join(" "));
117
+ const weak = wordsOf([(t.requires || []).join(" "), t.pack, t.content].filter(Boolean).join(" "));
118
+ let score = 0;
119
+ for (const term of terms) {
120
+ if (termHits(term, strong)) score += 2;
121
+ else if (termHits(term, weak)) score += 1;
122
+ }
123
+ if (score >= min) out.push({ name: t.name, description: t.description, score, runCount: t.runCount || 0 });
124
+ }
125
+ out.sort((a, b) => b.score - a.score || (b.runCount || 0) - (a.runCount || 0) || a.name.localeCompare(b.name));
126
+ return out.slice(0, Math.max(1, Math.min(10, limit)));
127
+ }
128
+
38
129
  // Strip a leading comment prefix ("# " / "// ") from a header line. Returns the
39
130
  // remainder, or null if the line is not a comment (header block has ended).
40
131
  function uncomment(line) {
@@ -75,7 +166,7 @@ function toolFile(name) {
75
166
  return path.join(TOOLS_DIR, sanitizeName(name));
76
167
  }
77
168
 
78
- function readTool(name) {
169
+ function readTool(name, usageMap) {
79
170
  const file = toolFile(name);
80
171
  let content;
81
172
  try { content = fs.readFileSync(file, "utf-8"); } catch (e) { return null; }
@@ -83,6 +174,7 @@ function readTool(name) {
83
174
  if (!header) return null;
84
175
  let stat = null;
85
176
  try { stat = fs.statSync(file); } catch (e) {}
177
+ const usage = (usageMap || readUsage())[header.name || sanitizeName(name)] || {};
86
178
  return {
87
179
  name: header.name || sanitizeName(name),
88
180
  description: header.description || "",
@@ -92,6 +184,8 @@ function readTool(name) {
92
184
  file,
93
185
  executable: stat ? !!(stat.mode & 0o111) : false,
94
186
  updatedAt: stat ? stat.mtime.toISOString() : "",
187
+ runCount: usage.runCount || 0,
188
+ lastUsed: usage.lastUsed || "",
95
189
  content,
96
190
  };
97
191
  }
@@ -99,12 +193,13 @@ function readTool(name) {
99
193
  function listTools() {
100
194
  let entries;
101
195
  try { entries = fs.readdirSync(TOOLS_DIR); } catch (e) { return []; }
196
+ const usage = readUsage();
102
197
  const tools = [];
103
198
  for (const name of entries) {
104
199
  if (name.startsWith(".")) continue;
105
200
  try { if (!fs.statSync(path.join(TOOLS_DIR, name)).isFile()) continue; }
106
201
  catch (e) { continue; }
107
- const t = readTool(name);
202
+ const t = readTool(name, usage);
108
203
  if (t) tools.push(t);
109
204
  }
110
205
  tools.sort((a, b) => a.name.localeCompare(b.name));
@@ -221,7 +316,8 @@ function toolNameFromPath(filePath) {
221
316
  }
222
317
 
223
318
  module.exports = {
224
- TOOLS_DIR, MARKER,
319
+ TOOLS_DIR, MARKER, USAGE_FILE,
225
320
  parseHeader, readTool, listTools, findTool, addTool, removeTool,
226
321
  runEnv, missingRequires, toolNameFromPath, sanitizeName, ensureDir,
322
+ matchTools, recordRun, readUsage,
227
323
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.53",
3
+ "version": "2.6.54",
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": {
@@ -10,8 +10,19 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "recall-disc-"));
10
10
  process.env.RECALL_GRAPH_DB = path.join(tmp, "graph.db");
11
11
  process.env.RECALL_DISCOVERER_WALKER = "off";
12
12
  process.env.RECALL_METRICS = "off";
13
+ process.env.TOOLS_DIR = path.join(tmp, "tools");
13
14
 
14
15
  const disc = require("./core/recall/discoverer");
16
+ const toolsLib = require("./core/tools");
17
+
18
+ // A reusable tool whose description matches a query should seed the discoverer
19
+ // as a first-class candidate (origin user), survive the walker-off fail-open,
20
+ // and render into the toolBlock — exactly like a pack/entity seed.
21
+ toolsLib.addTool((() => {
22
+ const s = path.join(tmp, "mikrotik-audit.sh");
23
+ fs.writeFileSync(s, "#!/usr/bin/env bash\necho audit\n");
24
+ return s;
25
+ })(), { name: "mikrotik-audit", description: "audit the mikrotik router fleet inventory" });
15
26
 
16
27
  // --- pre-gate ---
17
28
  assert.strictEqual(disc.needsRecall("", 0), false, "empty → no recall");
@@ -82,5 +93,22 @@ const helpers = { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEnti
82
93
  });
83
94
  assert.strictEqual(typeof out2.packBlock, "string");
84
95
 
96
+ // tool seeding: a tool matching the user text surfaces as a kept tool match and
97
+ // renders into toolBlock; a turn that matches no tool yields an empty toolBlock
98
+ const outTool = await disc.run({
99
+ userText: "audit the mikrotik router fleet", contextText: "",
100
+ fullContext: "audit the mikrotik router fleet", packLimit: 6, budget: {}, helpers,
101
+ });
102
+ assert.ok(outTool.toolMatches.some((m) => m.name === "mikrotik-audit"), "matching tool is kept (fail-open user seed)");
103
+ assert.ok(/mikrotik-audit/.test(outTool.toolBlock), "kept tool renders into the toolBlock");
104
+ assert.ok(/Tools that may help here/.test(outTool.toolBlock), "toolBlock carries its heading");
105
+
106
+ const outNoTool = await disc.run({
107
+ userText: "help with the mobile app", contextText: "", fullContext: "help with the mobile app",
108
+ packLimit: 6, budget: {}, helpers,
109
+ });
110
+ assert.strictEqual(outNoTool.toolBlock, "", "no tool match → empty toolBlock");
111
+ assert.strictEqual(outNoTool.toolMatches.length, 0, "no tool match → no toolMatches");
112
+
85
113
  console.log("recall discoverer OK");
86
114
  })().catch((e) => { console.error(e); process.exit(1); });
package/test-tools.js CHANGED
@@ -85,6 +85,36 @@ assert.strictEqual(
85
85
  // ── parseHeader returns null for a script with no marker line ──
86
86
  assert.strictEqual(tools.parseHeader("#!/bin/sh\necho hi\n"), null, "no marker → not a tool");
87
87
 
88
+ // ── matchTools: lexical scoring over name/description (strong) vs requires/pack
89
+ // /source (weak); a non-matching query returns nothing ──
90
+ const mHello = tools.matchTools("say hi");
91
+ assert.ok(mHello.some((m) => m.name === "hello"), "matchTools finds 'hello' by its description terms");
92
+ const mFetch = tools.matchTools("fetch a thing");
93
+ assert.ok(mFetch.some((m) => m.name === "fetch"), "matchTools finds 'fetch'");
94
+ assert.ok(!mFetch.some((m) => m.name === "hello"), "an unrelated tool does not match");
95
+ assert.deepStrictEqual(tools.matchTools("zzzznomatchatall"), [], "no terms hit → empty match");
96
+ assert.deepStrictEqual(tools.matchTools(""), [], "empty query → empty match");
97
+
98
+ // ── recordRun telemetry: bumps runCount + stamps lastUsed, visible via readUsage
99
+ // and surfaced on the tool record itself ──
100
+ assert.strictEqual(tools.findTool("hello").runCount, 0, "fresh tool has runCount 0");
101
+ tools.recordRun("hello");
102
+ assert.strictEqual(tools.readUsage().hello.runCount, 1, "recordRun bumps the usage sidecar");
103
+ assert.ok(tools.readUsage().hello.lastUsed, "recordRun stamps lastUsed");
104
+ assert.strictEqual(tools.findTool("hello").runCount, 1, "runCount surfaces on the tool record");
105
+ tools.recordRun("hello");
106
+ assert.strictEqual(tools.findTool("hello").runCount, 2, "a second run increments again");
107
+
108
+ // ── matchTools tie-break: equal score → more-run tool ranks first ──
109
+ for (const n of ["alpha", "beta"]) {
110
+ const s = path.join(tmp, `${n}.sh`);
111
+ fs.writeFileSync(s, "#!/usr/bin/env bash\necho x\n");
112
+ tools.addTool(s, { name: n, description: "common widget gadget" });
113
+ }
114
+ tools.recordRun("beta");
115
+ const ranked = tools.matchTools("common widget gadget").map((m) => m.name);
116
+ assert.ok(ranked.indexOf("beta") < ranked.indexOf("alpha"), "on equal score, the more-run tool ranks first");
117
+
88
118
  // ── remove ──
89
119
  assert.ok(tools.removeTool("hello"), "removeTool returns the removed tool");
90
120
  assert.strictEqual(tools.findTool("hello"), null, "removed tool is gone");