@inetafrica/open-claudia 2.6.53 → 2.6.55

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.6.55
4
+ - **Make fixing a tool as cheap as creating one.** The tool subsystem was structurally tilted toward sprawl: creating a tool was a trusting one-liner with no validation, while *fixing* one had no verb at all — so the path of least resistance was always another near-duplicate. This release closes that gap from both ends. **A first-class fix verb:** `open-claudia tool set <name> [--desc|--pack|--requires|--usage]` rewrites a tool's header metadata in place (new `updateToolHeader()` in `core/tools.js`) — it refreshes only the recognised field lines, preserving the marker, any hand-written comments, and the immutable `createdAt`, and never touches the body. `open-claudia tool edit <name>` prints the source path (stdout) so the agent's Edit/Write or the user's `$EDITOR` can open it directly, with the metadata-only hint on stderr. **An add-time safety net** (all advisory, never blocking — a tool the agent just wrote is usually right): `tool add`/`tool set` now lint the source and surface (1) hardcoded secrets — `sk-ant-`/`sk-proj-`/`sk-`/`AKIA`/`gh*_`/`xox*`/PEM/inline-Bearer literals that should be `$KEYRING` refs, mirroring the redactor; (2) requires/keyring mismatches — keyring keys the script reads but `--requires` omits (with a ready-to-paste `tool set` fix), and declared keys it never reads; (3) syntax errors via the script's own interpreter (`bash -n` / `node --check` / `py_compile`, silent when the interpreter or type is unknown — never cries wolf); and (4) a dangling `--pack` link to a pack that doesn't exist. **Immutable creation telemetry:** the `.usage.json` sidecar now stamps `createdAt` once at add-time (surfaced on every tool record); the nightly dream's "cold since creation" check ages by this stamp instead of file mtime, so a body edit can no longer reset a never-run tool's staleness clock (falls back to mtime for tools created before the stamp existed). Extends `test-tools.js` (createdAt immutability, `envRefsIn`, `lintToolSource`, `checkSyntax`, `updateToolHeader`).
5
+ - **Stop the phantom-outage restart storm — just keep polling.** The Telegram adapter was killing the bot over network blips that weren't real. A side-by-side network monitor (probing 1.1.1.1 *and* api.telegram.org every 60s) showed the connection healthy throughout, yet `bot.log` had logged 20k+ `EFATAL: read ETIMEDOUT` → "Network lost" events: the long-poll's pooled keep-alive socket goes stale on a macOS sleep/wake or an idle-NAT eviction, and every reused poll then times out instantly. The old handler treated *any* such timeout as a lost network — loudly stopping/restarting polling and, after 6 quick attempts (a sleep/wake error burst exhausts those in seconds), calling `process.exit(1)` for launchd to relaunch. That voluntary suicide *was* the "why does it keep restarting" the bot kept doing. Three changes in `channels/telegram/adapter.js`: (1) **fresh socket per poll** (`keepAlive:false`) so there's no pooled socket to go stale — the root of the ETIMEDOUT storm; (2) **gentle, throttled recovery** — a transient timeout no longer screams or tears down on the first hit (with keep-alive off the next poll self-heals on a new connection); it logs at most once a minute and only actively restarts polling if errors persist; (3) **never exit over a network blip** — only a `409 Conflict` (a second poller on the same token) exits immediately; a genuinely wedged loop now has to fail unbroken for a full 10 minutes before a clean relaunch, versus the old ~90s hair-trigger. The diagnostic `netmon.sh` that proved the network was fine stays running.
6
+
7
+ ## v2.6.54
8
+ - **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).
9
+ - **`/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.
10
+ - **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.
11
+ - **`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.
12
+ - **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.
13
+
3
14
  ## v2.6.53
4
15
  - **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
16
 
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|set|edit|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,13 +3,22 @@
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 "..."]
10
+ // open-claudia tool set <name> [--desc ...] — fix a tool's metadata in place
11
+ // [--pack <dir>] [--requires "k1,k2"] [--usage "..."]
12
+ // open-claudia tool edit <name> — print the file path to open & edit
9
13
  // open-claudia tool run <name> [args...] — run it with keyring pre-loaded
10
14
  // open-claudia tool remove <name> — delete one
11
15
  // open-claudia tool path — print the tools directory
12
16
  //
17
+ // Fix before fork: when a tool is wrong, prefer `tool set` (metadata) or `tool
18
+ // edit` (open the file) over adding a near-duplicate. `add` lints the source on
19
+ // save — flags hardcoded secrets, requires/keyring mismatches, syntax errors,
20
+ // and dangling pack links — all advisory, never blocking.
21
+ //
13
22
  // Credentials: a tool runs with the operational keyring merged into its env, so
14
23
  // reference creds as $NAME inside the script. `open-claudia keyring list` shows
15
24
  // what's available; never hardcode secrets in a tool.
@@ -33,6 +42,48 @@ function parseFlags(argv) {
33
42
  return { positional, flags };
34
43
  }
35
44
 
45
+ // Advisory safety report for a freshly-added or just-edited tool. Never blocks —
46
+ // a tool the agent just wrote is usually right; we surface risks and let the
47
+ // human/agent decide. Covers: hardcoded secrets, requires/keyring mismatch,
48
+ // syntax errors (when we have the interpreter), and dangling pack links.
49
+ function reportSafety(t) {
50
+ // Hardcoded secrets + requires/keyring reconciliation.
51
+ try {
52
+ const { secrets, undeclared, unused } = tools.lintToolSource(t.content, { requires: t.requires });
53
+ if (secrets.length) {
54
+ console.log(`\n⚠ Hardcoded secret(s) detected: ${secrets.join(", ")}.`);
55
+ console.log(" Replace with a $KEYRING_VAR reference — keyring perms + log redaction only protect creds stored there.");
56
+ }
57
+ if (undeclared.length) {
58
+ console.log(`\n⚠ Reads keyring key(s) not in --requires: ${undeclared.join(", ")}.`);
59
+ console.log(` Add them so a missing key is caught up-front: open-claudia tool set ${t.name} --requires "${[...t.requires, ...undeclared].join(",")}"`);
60
+ }
61
+ if (unused.length) {
62
+ console.log(`\nNote: --requires lists key(s) the script never reads: ${unused.join(", ")}.`);
63
+ }
64
+ } catch (e) { /* lint optional */ }
65
+
66
+ // Syntax check (only when we recognise + have the interpreter).
67
+ try {
68
+ const syn = tools.checkSyntax(t.file, t.content);
69
+ if (syn && !syn.ok) {
70
+ console.log(`\n⚠ Syntax check failed:\n${syn.error.split("\n").map((l) => " " + l).join("\n")}`);
71
+ console.log(` Fix it: open-claudia tool edit ${t.name}`);
72
+ }
73
+ } catch (e) { /* syntax check optional */ }
74
+
75
+ // Dangling pack link.
76
+ if (t.pack) {
77
+ try {
78
+ const pack = require("../core/packs").readPack(t.pack);
79
+ if (!pack) {
80
+ console.log(`\n⚠ Linked skill pack "${t.pack}" doesn't exist.`);
81
+ console.log(` Create it (open-claudia pack ...) or relink: open-claudia tool set ${t.name} --pack <existing-dir>`);
82
+ }
83
+ } catch (e) { /* packs optional */ }
84
+ }
85
+ }
86
+
36
87
  function run(args) {
37
88
  const cmd = (args[0] || "list").toLowerCase();
38
89
  const rest = args.slice(1);
@@ -53,6 +104,25 @@ function run(args) {
53
104
  break;
54
105
  }
55
106
 
107
+ case "search":
108
+ case "find": {
109
+ const query = rest.join(" ").trim();
110
+ if (!query) { console.error("Usage: tool search <query>"); process.exitCode = 1; return; }
111
+ const matches = tools.matchTools(query, { limit: 10 });
112
+ if (!matches.length) {
113
+ console.log(`No tools match "${query}". See all: open-claudia tool list`);
114
+ return;
115
+ }
116
+ console.log(`${matches.length} tool(s) matching "${query}":\n`);
117
+ for (const m of matches) {
118
+ const t = tools.findTool(m.name);
119
+ const needs = t && t.requires && t.requires.length ? ` (keyring: ${t.requires.join(", ")})` : "";
120
+ console.log(`• ${m.name} — ${m.description || "(no description)"}${needs}`);
121
+ }
122
+ console.log(`\nRun: open-claudia tool run <name> [args] · Docs: open-claudia tool show <name>`);
123
+ break;
124
+ }
125
+
56
126
  case "show":
57
127
  case "cat": {
58
128
  const name = rest[0];
@@ -112,12 +182,70 @@ function run(args) {
112
182
  const missing = tools.missingRequires(t);
113
183
  if (missing.length) console.log(`Note: missing keyring keys it needs: ${missing.join(", ")}`);
114
184
  }
185
+ // Add-time safety net (advisory): secrets, requires/keyring mismatch,
186
+ // syntax, dangling pack link.
187
+ reportSafety(t);
188
+ // Similarity guard (warn, don't block): if existing tools already cover
189
+ // this, prefer extending one with a subcommand over a near-duplicate.
190
+ try {
191
+ const near = tools.matchTools([t.name, t.description].filter(Boolean).join(" "), { limit: 3 })
192
+ .filter((m) => m.name !== t.name);
193
+ if (near.length) {
194
+ console.log(`\nHeads up — ${near.length} existing tool(s) look similar:`);
195
+ for (const m of near) console.log(` • ${m.name} — ${m.description || "(no description)"}`);
196
+ console.log("Consider extending one with a new subcommand instead of keeping a near-duplicate (open-claudia tool show <name>).");
197
+ }
198
+ } catch (e) { /* best-effort */ }
115
199
  } catch (e) {
116
200
  console.error(`Could not add tool: ${e.message}`); process.exitCode = 1;
117
201
  }
118
202
  break;
119
203
  }
120
204
 
205
+ case "set":
206
+ case "fix": {
207
+ const { positional, flags } = parseFlags(rest);
208
+ const name = positional[0];
209
+ if (!name) {
210
+ console.error('Usage: tool set <name> [--desc "..."] [--pack dir] [--requires "k1,k2"] [--usage "..."]');
211
+ process.exitCode = 1; return;
212
+ }
213
+ if (!tools.findTool(name)) {
214
+ console.error(`No tool named "${name}". Run: open-claudia tool list`); process.exitCode = 1; return;
215
+ }
216
+ const patch = {};
217
+ if (typeof flags.desc === "string") patch.description = flags.desc;
218
+ else if (typeof flags.description === "string") patch.description = flags.description;
219
+ if (typeof flags.pack === "string") patch.pack = flags.pack;
220
+ if (typeof flags.requires === "string") patch.requires = flags.requires.split(/[,\s]+/).filter(Boolean);
221
+ if (typeof flags.usage === "string") patch.usage = flags.usage;
222
+ if (Object.keys(patch).length === 0) {
223
+ console.error('Nothing to set. Pass at least one of --desc / --pack / --requires / --usage. Body edits: open-claudia tool edit ' + name);
224
+ process.exitCode = 1; return;
225
+ }
226
+ const updated = tools.updateToolHeader(name, patch);
227
+ if (!updated) { console.error(`Could not update "${name}".`); process.exitCode = 1; return; }
228
+ console.log(`Updated tool "${updated.name}".`);
229
+ console.log(` Description: ${updated.description || "(none)"}`);
230
+ if (updated.pack) console.log(` Skill pack: ${updated.pack}`);
231
+ if (updated.requires.length) console.log(` Requires: ${updated.requires.join(", ")}`);
232
+ if (updated.usage) console.log(` Usage: ${updated.usage}`);
233
+ reportSafety(updated);
234
+ break;
235
+ }
236
+
237
+ case "edit": {
238
+ const name = rest[0];
239
+ if (!name) { console.error("Usage: tool edit <name>"); process.exitCode = 1; return; }
240
+ const t = tools.findTool(name);
241
+ if (!t) { console.error(`No tool named "${name}". Run: open-claudia tool list`); process.exitCode = 1; return; }
242
+ // Body edits are an Edit/Write job — print the path so the agent (or the
243
+ // user's $EDITOR) can open it directly. Metadata fixes: open-claudia tool set.
244
+ console.log(t.file);
245
+ console.error(`Open and edit the source above. Header/metadata only: open-claudia tool set ${t.name} [flags]`);
246
+ break;
247
+ }
248
+
121
249
  case "run":
122
250
  case "exec": {
123
251
  const name = rest[0];
@@ -149,7 +277,7 @@ function run(args) {
149
277
  break;
150
278
 
151
279
  default:
152
- console.log('Usage: open-claudia tool [list | show <name> | add <path> [flags] | run <name> [args] | remove <name> | path]');
280
+ console.log('Usage: open-claudia tool [list | search <query> | show <name> | add <path> [flags] | set <name> [flags] | edit <name> | run <name> [args] | remove <name> | path]');
153
281
  }
154
282
  }
155
283
 
@@ -19,16 +19,23 @@ class TelegramAdapter {
19
19
  this.token = token;
20
20
  this.ownerChatId = ownerChatId;
21
21
  this.chatIds = chatIds || [];
22
- // Own the HTTP(S) agent so we can drop dead keep-alive sockets on reconnect.
23
- this._agent = new https.Agent({ keepAlive: true });
22
+ // Dial a fresh socket per poll (keepAlive:false). The long-poll sits idle up
23
+ // to 30s; with a *pooled* keep-alive socket, a macOS sleep/wake or an idle-NAT
24
+ // eviction silently kills that one socket and every later poll reuses it and
25
+ // ETIMEDOUTs — forever. A new connection each poll (~every 30s, trivial for
26
+ // one bot) sidesteps the stale-socket trap that drove the old restart storm.
27
+ // We still own the agent so a heal can destroy any lingering socket.
28
+ this._agent = new https.Agent({ keepAlive: false });
24
29
  this.bot = new TelegramBot(token, {
25
30
  polling: { autoStart: false, params: { timeout: 30 } },
26
31
  request: { agent: this._agent },
27
32
  });
28
33
  this._listeners = { message: new Set(), action: new Set() };
29
- this._reconnectTimer = null;
30
- this._recoveryTimer = null;
31
- this._reconnectAttempts = 0;
34
+ this._healTimer = null; // one active recovery in flight at a time
35
+ this._healthyTimer = null; // fires once polling's been quiet long enough
36
+ this._wedgedSince = 0; // start of the current unrecovered error streak
37
+ this._lastHiccupLog = 0; // throttle the transient-error log (1/min)
38
+ this._healBackoff = 0; // grows per consecutive heal, capped
32
39
  this._wireInbound();
33
40
  }
34
41
 
@@ -50,60 +57,73 @@ class TelegramAdapter {
50
57
  }
51
58
 
52
59
  async stop() {
53
- if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
54
- if (this._recoveryTimer) { clearTimeout(this._recoveryTimer); this._recoveryTimer = null; }
60
+ if (this._healTimer) { clearTimeout(this._healTimer); this._healTimer = null; }
61
+ if (this._healthyTimer) { clearTimeout(this._healthyTimer); this._healthyTimer = null; }
55
62
  try { await this.bot.stopPolling(); } catch (e) {}
56
63
  }
57
64
 
58
- // Recover from a wedged poll loop. The library reuses one keep-alive socket;
59
- // after a sleep/wake or network blip that socket dies and every poll then
60
- // ETIMEDOUTs forever restarting polling alone reuses the same dead socket.
61
- // So we destroy the agent (forcing a fresh connection), back off, and if we
62
- // still can't recover after several tries we exit so launchd (KeepAlive)
63
- // relaunches a fully clean process.
64
- _scheduleReconnect() {
65
- if (this._reconnectTimer) return; // one cycle in flight; swallow the flood
66
- if (this._recoveryTimer) { clearTimeout(this._recoveryTimer); this._recoveryTimer = null; }
67
- this._reconnectAttempts += 1;
68
- const MAX = 6;
69
- if (this._reconnectAttempts > MAX) {
70
- console.error(`Polling unrecoverable after ${MAX} attempts — exiting for supervisor restart.`);
65
+ // A transient network error on the long-poll. The network is almost never
66
+ // actually down netmon shows it healthy while these fire it's a single
67
+ // stale socket. So we DON'T scream "Network lost" and tear everything down on
68
+ // the first timeout the way the old code did: with keep-alive off the next
69
+ // poll already dials fresh and self-heals. We log at most once a minute, only
70
+ // actively recover if errors persist, and NEVER exit over a network blip — the
71
+ // old exit-for-relaunch *was* the restart the bot kept doing on every idle
72
+ // timeout (a sleep/wake burst could exhaust all 6 attempts in seconds).
73
+ _onPollingHiccup(msg) {
74
+ const now = Date.now();
75
+ if (!this._wedgedSince) this._wedgedSince = now;
76
+ if (now - this._lastHiccupLog > 60000) {
77
+ this._lastHiccupLog = now;
78
+ console.log(`Polling hiccup (${msg}) — retrying on a fresh connection.`);
79
+ }
80
+ if (this._healTimer) return; // a heal is already scheduled; let it run
81
+ this._healBackoff = Math.min(this._healBackoff + 1, 5);
82
+ const delay = Math.min(15000, 500 * 2 ** this._healBackoff); // 1s … 15s
83
+ this._healTimer = setTimeout(() => this._heal(), delay);
84
+ }
85
+
86
+ async _heal() {
87
+ this._healTimer = null;
88
+ // Backstop only: if polling has been unbroken-wedged for 10 minutes, a
89
+ // restart-in-place won't fix it — exit for a clean launchd relaunch. On a
90
+ // healthy network this never fires (heals recover in seconds); it replaces
91
+ // the old hair-trigger ~90s exit that caused the restart storm.
92
+ if (this._wedgedSince && Date.now() - this._wedgedSince > 600000) {
93
+ console.error("Polling wedged >10min — exiting for a clean restart.");
71
94
  process.exit(1);
72
95
  }
73
- const delay = Math.min(30000, 1000 * 2 ** this._reconnectAttempts); // 2s,4s,8s,16s,30s,30s
74
- console.log(`Network lost. Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this._reconnectAttempts}/${MAX})...`);
75
- this._reconnectTimer = setTimeout(async () => {
76
- this._reconnectTimer = null;
77
- try {
78
- try { await this.bot.stopPolling(); } catch (e) {}
79
- try { this._agent.destroy(); } catch (e) {} // drop the dead pooled socket
80
- await new Promise((r) => setTimeout(r, 1000));
81
- await this.bot.startPolling();
82
- console.log("Polling restarted.");
83
- // No fresh error within 30s ⇒ genuinely recovered; reset the counter.
84
- this._recoveryTimer = setTimeout(() => {
85
- this._recoveryTimer = null;
86
- if (this._reconnectAttempts) console.log("Connection healthy again.");
87
- this._reconnectAttempts = 0;
88
- }, 30000);
89
- } catch (e) {
90
- console.error("Reconnect attempt failed:", e.message);
91
- this._scheduleReconnect(); // back off and retry; don't die on first miss
92
- }
93
- }, delay);
96
+ try {
97
+ try { await this.bot.stopPolling(); } catch (e) {}
98
+ try { this._agent.destroy(); } catch (e) {} // drop any lingering pooled socket
99
+ await new Promise((r) => setTimeout(r, 500));
100
+ await this.bot.startPolling();
101
+ } catch (e) {
102
+ return; // restart didn't take the next hiccup reschedules a heal
103
+ }
104
+ // Quiet for 30s after a restart ⇒ genuinely recovered.
105
+ if (this._healthyTimer) clearTimeout(this._healthyTimer);
106
+ this._healthyTimer = setTimeout(() => {
107
+ this._healthyTimer = null;
108
+ if (this._wedgedSince) console.log("Polling healthy again.");
109
+ this._wedgedSince = 0;
110
+ this._healBackoff = 0;
111
+ }, 30000);
94
112
  }
95
113
 
96
114
  _wireInbound() {
97
115
  this.bot.on("polling_error", (err) => {
98
116
  const msg = err.message || "";
99
- console.error("Polling error:", msg);
100
117
  if (msg.includes("409 Conflict")) {
118
+ // Two pollers can't share one token — this one must bow out.
101
119
  console.error("Another instance is polling. Exiting.");
102
120
  process.exit(1);
103
121
  }
104
122
  if (/ETIMEDOUT|ECONNRESET|ENOTFOUND|ENETUNREACH|EAI_AGAIN|EFATAL|socket hang up/i.test(msg)) {
105
- this._scheduleReconnect();
123
+ this._onPollingHiccup(msg); // transient — heal quietly, never exit
124
+ return;
106
125
  }
126
+ console.error("Polling error:", msg); // unexpected — surface, keep polling
107
127
  });
108
128
 
109
129
  this.bot.on("message", (msg) => {
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,39 @@ 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; createdAt measures "how long since it was made" (a body
848
+ // edit bumps mtime and would reset the clock, so prefer the immutable stamp
849
+ // and fall back to mtime only for tools added before createdAt existed).
850
+ // Flag only — a never-used tool may still be worth keeping, user decides.
851
+ const STALE_DAYS = Number(process.env.TOOL_STALE_DAYS || 30);
852
+ const cutoff = Date.now() - STALE_DAYS * 86400000;
853
+ const coldUnused = allTools.filter((t) => !(t.runCount > 0) && !t.lastUsed).filter((t) => {
854
+ const born = t.createdAt ? Date.parse(t.createdAt) : NaN;
855
+ if (!Number.isNaN(born)) return born < cutoff;
856
+ try { return fs.statSync(t.file).mtimeMs < cutoff; } catch (e) { return false; }
857
+ });
858
+ if (coldUnused.length) {
859
+ 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.`);
860
+ }
861
+ // Near-duplicate pairs: two tools whose name+description overlap strongly,
862
+ // a sign one should have been a subcommand of the other. Dedupe by sorted
863
+ // pair key; flag only (consolidating is a judgement call for the user).
864
+ const DUP_THRESHOLD = Number(process.env.TOOL_DUP_THRESHOLD || 4);
865
+ const dupPairs = new Set();
866
+ for (const t of allTools) {
867
+ const near = toolsLib.matchTools([t.name, t.description].filter(Boolean).join(" "), { threshold: DUP_THRESHOLD, limit: 5 })
868
+ .filter((m) => m.name !== t.name);
869
+ for (const m of near) dupPairs.add([t.name, m.name].sort().join(" ↔ "));
870
+ }
871
+ if (dupPairs.size) {
872
+ bits.push(`👯 ${dupPairs.size} near-duplicate tool pair(s): ${[...dupPairs].join(", ")} — consider merging into one tool with subcommands.`);
873
+ }
845
874
  toolNote = bits.join("\n");
846
875
  } catch (e) { /* tool graph is best-effort (old node) */ }
847
876
 
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,111 @@ 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
+ // Stamp an immutable createdAt the first time a tool is registered. Kept in the
70
+ // sidecar (not the header) so editing the file never disturbs it — unlike file
71
+ // mtime, which resets on every edit and would make an edited-but-never-run tool
72
+ // look freshly minted. The dream's zero-run hygiene ages tools from this.
73
+ function recordCreate(name) {
74
+ const n = sanitizeName(name);
75
+ if (!n) return;
76
+ const u = readUsage();
77
+ const cur = u[n] || { runCount: 0, lastUsed: "" };
78
+ if (!cur.createdAt) cur.createdAt = new Date().toISOString();
79
+ u[n] = cur;
80
+ writeUsage(u);
81
+ }
82
+
83
+ // ── Lexical matching (in-memory) ─────────────────────────────────────────────
84
+ // Tools are few and short, so we score in memory rather than standing up a third
85
+ // FTS index — and unlike the recall/tool graphs this then works on every node
86
+ // version. Mirrors matchPacks' semantics: a hit in a strong field (name /
87
+ // description / usage) counts 2, a weak-field hit (requires / pack / source) 1,
88
+ // tools at/above the threshold win. This is the SEED stage of the discoverer for
89
+ // the tool corpus — keyword feeds the agentic walker, it does not replace it.
90
+ 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 " +
91
+ "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 " +
92
+ "will with you your yes yeah ok okay please thanks just like dont im its run tool use").split(" "));
93
+
94
+ function toolQueryTerms(text) {
95
+ const seen = new Set();
96
+ const terms = [];
97
+ for (const raw of String(text || "").toLowerCase().split(/[^a-z0-9_.-]+/)) {
98
+ const t = raw.replace(/^[.-]+|[.-]+$/g, "");
99
+ if (t.length < 2 || TOOL_STOPWORDS.has(t) || seen.has(t)) continue;
100
+ seen.add(t);
101
+ terms.push(t);
102
+ if (terms.length >= 40) break;
103
+ }
104
+ return terms;
105
+ }
106
+
107
+ function wordsOf(s) {
108
+ return new Set(String(s || "").toLowerCase().split(/[^a-z0-9_.-]+/).filter(Boolean));
109
+ }
110
+
111
+ // A term hits a word set on exact match, or a light prefix stem (deploy~deploys,
112
+ // upload~uploads) for terms long enough that a prefix isn't noise.
113
+ function termHits(term, wordSet) {
114
+ if (wordSet.has(term)) return true;
115
+ if (term.length >= 4) {
116
+ for (const w of wordSet) if (w.startsWith(term) || term.startsWith(w)) return true;
117
+ }
118
+ return false;
119
+ }
120
+
121
+ // Score registered tools against free text. Returns [{ name, description, score,
122
+ // runCount }] over the threshold, strongest first (runCount breaks ties so a
123
+ // well-worn tool outranks a never-run namesake).
124
+ function matchTools(text, { limit = 5, threshold = null } = {}) {
125
+ const terms = toolQueryTerms(text);
126
+ if (!terms.length) return [];
127
+ const min = threshold ?? Number(process.env.TOOL_MATCH_THRESHOLD || 2);
128
+ const out = [];
129
+ for (const t of listTools()) {
130
+ const strong = wordsOf([t.name, t.description, t.usage].filter(Boolean).join(" "));
131
+ const weak = wordsOf([(t.requires || []).join(" "), t.pack, t.content].filter(Boolean).join(" "));
132
+ let score = 0;
133
+ for (const term of terms) {
134
+ if (termHits(term, strong)) score += 2;
135
+ else if (termHits(term, weak)) score += 1;
136
+ }
137
+ if (score >= min) out.push({ name: t.name, description: t.description, score, runCount: t.runCount || 0 });
138
+ }
139
+ out.sort((a, b) => b.score - a.score || (b.runCount || 0) - (a.runCount || 0) || a.name.localeCompare(b.name));
140
+ return out.slice(0, Math.max(1, Math.min(10, limit)));
141
+ }
142
+
38
143
  // Strip a leading comment prefix ("# " / "// ") from a header line. Returns the
39
144
  // remainder, or null if the line is not a comment (header block has ended).
40
145
  function uncomment(line) {
@@ -75,7 +180,7 @@ function toolFile(name) {
75
180
  return path.join(TOOLS_DIR, sanitizeName(name));
76
181
  }
77
182
 
78
- function readTool(name) {
183
+ function readTool(name, usageMap) {
79
184
  const file = toolFile(name);
80
185
  let content;
81
186
  try { content = fs.readFileSync(file, "utf-8"); } catch (e) { return null; }
@@ -83,6 +188,7 @@ function readTool(name) {
83
188
  if (!header) return null;
84
189
  let stat = null;
85
190
  try { stat = fs.statSync(file); } catch (e) {}
191
+ const usage = (usageMap || readUsage())[header.name || sanitizeName(name)] || {};
86
192
  return {
87
193
  name: header.name || sanitizeName(name),
88
194
  description: header.description || "",
@@ -92,6 +198,9 @@ function readTool(name) {
92
198
  file,
93
199
  executable: stat ? !!(stat.mode & 0o111) : false,
94
200
  updatedAt: stat ? stat.mtime.toISOString() : "",
201
+ createdAt: usage.createdAt || "",
202
+ runCount: usage.runCount || 0,
203
+ lastUsed: usage.lastUsed || "",
95
204
  content,
96
205
  };
97
206
  }
@@ -99,12 +208,13 @@ function readTool(name) {
99
208
  function listTools() {
100
209
  let entries;
101
210
  try { entries = fs.readdirSync(TOOLS_DIR); } catch (e) { return []; }
211
+ const usage = readUsage();
102
212
  const tools = [];
103
213
  for (const name of entries) {
104
214
  if (name.startsWith(".")) continue;
105
215
  try { if (!fs.statSync(path.join(TOOLS_DIR, name)).isFile()) continue; }
106
216
  catch (e) { continue; }
107
- const t = readTool(name);
217
+ const t = readTool(name, usage);
108
218
  if (t) tools.push(t);
109
219
  }
110
220
  tools.sort((a, b) => a.name.localeCompare(b.name));
@@ -134,6 +244,82 @@ function commentPrefixFor(content, srcPath) {
134
244
  return "#";
135
245
  }
136
246
 
247
+ // ── Creation-time guards ─────────────────────────────────────────────────────
248
+ // Cheap static checks surfaced when a tool is registered, so a broken, insecure,
249
+ // or mis-declared tool is caught at `tool add` time instead of at first run. All
250
+ // advisory — they shape warnings, they never block the save (a tool the agent
251
+ // just wrote is usually right; we surface the risk and let the human/agent act).
252
+
253
+ // Hardcoded-secret shapes (mirrors the redactor, plus a few common providers). A
254
+ // match means a literal secret is baked into the file — it should be a $KEYRING
255
+ // reference instead, so the keyring's perms + log redaction actually protect it.
256
+ const SECRET_PATTERNS = [
257
+ [/sk-ant-[A-Za-z0-9._-]{8,}/, "Anthropic key (sk-ant-…)"],
258
+ [/sk-proj-[A-Za-z0-9._-]{8,}/, "OpenAI project key (sk-proj-…)"],
259
+ [/\bsk-[A-Za-z0-9]{20,}/, "OpenAI-style key (sk-…)"],
260
+ [/\bAKIA[0-9A-Z]{16}\b/, "AWS access key id (AKIA…)"],
261
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}/, "GitHub token (ghp_…)"],
262
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}/, "Slack token (xox…)"],
263
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "private key block"],
264
+ [/(?:Bearer\s+)[A-Za-z0-9._=-]{20,}/, "inline Bearer token"],
265
+ ];
266
+
267
+ // Names a script reads from the environment: $VAR, ${VAR}, process.env.VAR,
268
+ // process.env["VAR"], os.environ["VAR"], os.getenv("VAR"). Used to reconcile a
269
+ // tool's actual credential use against its declared --requires.
270
+ function envRefsIn(content) {
271
+ const refs = new Set();
272
+ const text = String(content || "");
273
+ let m;
274
+ const res = [
275
+ /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g,
276
+ /process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
277
+ /process\.env\[\s*["']([A-Za-z_][A-Za-z0-9_]*)["']\s*\]/g,
278
+ /os\.environ(?:\.get)?[\[(]\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g,
279
+ /os\.getenv\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g,
280
+ ];
281
+ for (const re of res) while ((m = re.exec(text))) refs.add(m[1]);
282
+ return refs;
283
+ }
284
+
285
+ // Static lint of a tool's source against the keyring + its declared requires.
286
+ // Returns { secrets, undeclared, unused } — all advisory:
287
+ // secrets — hardcoded credential literals that should be $VAR refs
288
+ // undeclared — keyring keys the script reads but --requires omits (so
289
+ // missingRequires can't pre-warn → cryptic runtime failure)
290
+ // unused — --requires keys the script never references
291
+ function lintToolSource(content, { requires = [], keyringKeys = null } = {}) {
292
+ const text = String(content || "");
293
+ const secrets = [];
294
+ for (const [re, label] of SECRET_PATTERNS) if (re.test(text)) secrets.push(label);
295
+ let keys = keyringKeys;
296
+ if (!keys) { try { keys = require("./keyring").keys(); } catch (e) { keys = []; } }
297
+ const keySet = new Set(keys);
298
+ const refs = envRefsIn(text);
299
+ const declared = new Set((requires || []).map((s) => String(s).trim()).filter(Boolean));
300
+ const undeclared = [...refs].filter((r) => keySet.has(r) && !declared.has(r));
301
+ const unused = [...declared].filter((d) => !refs.has(d));
302
+ return { secrets, undeclared, unused };
303
+ }
304
+
305
+ // Syntax-check a tool via its interpreter, if we recognise one. Returns
306
+ // { ok, error } or null when we can't check (unknown type or interpreter not
307
+ // installed) — never cry wolf over a checker we don't have.
308
+ function checkSyntax(file, content) {
309
+ const first = String(content || "").split("\n")[0] || "";
310
+ let cmd = null, args = null;
311
+ if (/\b(bash|sh)\b/.test(first) || /\.sh$/i.test(file)) { cmd = "bash"; args = ["-n", file]; }
312
+ else if (/\b(node|deno|bun)\b/.test(first) || /\.(c?js|mjs)$/i.test(file)) { cmd = "node"; args = ["--check", file]; }
313
+ else if (/\bpython3?\b/.test(first) || /\.py$/i.test(file)) { cmd = "python3"; args = ["-m", "py_compile", file]; }
314
+ if (!cmd) return null;
315
+ try {
316
+ const r = require("child_process").spawnSync(cmd, args, { encoding: "utf-8" });
317
+ if (r.error) return null;
318
+ if (r.status === 0) return { ok: true, error: "" };
319
+ return { ok: false, error: (r.stderr || r.stdout || "").trim().split("\n").slice(0, 3).join("\n") };
320
+ } catch (e) { return null; }
321
+ }
322
+
137
323
  // Register a script as a tool. Copies it into TOOLS_DIR under a sanitized name,
138
324
  // ensures it carries a header (synthesising one from opts if absent), and makes
139
325
  // it executable. Returns the stored tool.
@@ -177,6 +363,7 @@ function addTool(srcPath, opts = {}) {
177
363
  const dest = toolFile(name);
178
364
  fs.writeFileSync(dest, content, { mode: 0o700 });
179
365
  try { fs.chmodSync(dest, 0o700); } catch (e) {}
366
+ recordCreate(name);
180
367
  return readTool(name);
181
368
  }
182
369
 
@@ -187,6 +374,57 @@ function removeTool(name) {
187
374
  return tool;
188
375
  }
189
376
 
377
+ // Update a registered tool's header metadata in place (description/pack/requires
378
+ // /usage) without touching its body, name, or createdAt — the "fix" verb, so
379
+ // improving a tool is as cheap as creating one (reinforces extend-don't-
380
+ // duplicate). Preserves the marker line and any hand-written comments in the
381
+ // header; only the recognised field lines are refreshed. Body edits stay the job
382
+ // of Edit/Write. Returns the updated tool, or null if no such tool.
383
+ function updateToolHeader(name, opts = {}) {
384
+ const t = findTool(name);
385
+ if (!t) return null;
386
+ const prefix = commentPrefixFor(t.content, t.file);
387
+ const description = opts.description !== undefined ? opts.description : t.description;
388
+ const pack = opts.pack !== undefined ? opts.pack : t.pack;
389
+ const usage = opts.usage !== undefined ? opts.usage : t.usage;
390
+ let requires = opts.requires !== undefined ? opts.requires : t.requires;
391
+ if (!Array.isArray(requires)) requires = String(requires || "").split(/[,\s]+/).filter(Boolean);
392
+
393
+ const lines = t.content.split("\n");
394
+ let headStart = 0;
395
+ if (lines[0] && lines[0].startsWith("#!")) headStart = 1;
396
+ while (headStart < lines.length && lines[headStart].trim() === "") headStart++;
397
+
398
+ const kept = []; // preserved comment lines (marker + any hand notes)
399
+ let markerIdx = -1;
400
+ let j = headStart;
401
+ for (; j < lines.length; j++) {
402
+ const body = uncomment(lines[j]);
403
+ if (body === null) break; // comment block ended
404
+ const kv = body.match(/^([a-zA-Z-]+)\s*:\s*(.*)$/);
405
+ const key = kv ? kv[1].toLowerCase() : null;
406
+ if (key === MARKER) { markerIdx = kept.length; kept.push(lines[j]); }
407
+ else if (key && HEADER_KEYS.includes(key)) { /* drop — re-added below */ }
408
+ else kept.push(lines[j]); // keep arbitrary comment
409
+ }
410
+ const blockEnd = j;
411
+
412
+ const fieldLines = [];
413
+ if (description) fieldLines.push(`${prefix} description: ${description}`);
414
+ if (pack) fieldLines.push(`${prefix} pack: ${pack}`);
415
+ if (requires.length) fieldLines.push(`${prefix} requires: ${requires.join(", ")}`);
416
+ if (usage) fieldLines.push(`${prefix} usage: ${usage}`);
417
+
418
+ let newBlock;
419
+ if (markerIdx >= 0) newBlock = [...kept.slice(0, markerIdx + 1), ...fieldLines, ...kept.slice(markerIdx + 1)];
420
+ else newBlock = [`${prefix} ${MARKER}: ${t.name}`, ...fieldLines, ...kept];
421
+
422
+ const next = [...lines.slice(0, headStart), ...newBlock, ...lines.slice(blockEnd)].join("\n");
423
+ fs.writeFileSync(t.file, next, { mode: 0o700 });
424
+ try { fs.chmodSync(t.file, 0o700); } catch (e) {}
425
+ return readTool(t.name);
426
+ }
427
+
190
428
  // Env for running a tool: the bot's standard subprocess env (PATH + keyring
191
429
  // creds merged) so tools are pre-authed the same way the agent is. Falls back to
192
430
  // a plain keyring merge if config isn't importable (e.g. standalone test).
@@ -221,7 +459,9 @@ function toolNameFromPath(filePath) {
221
459
  }
222
460
 
223
461
  module.exports = {
224
- TOOLS_DIR, MARKER,
225
- parseHeader, readTool, listTools, findTool, addTool, removeTool,
462
+ TOOLS_DIR, MARKER, USAGE_FILE,
463
+ parseHeader, readTool, listTools, findTool, addTool, removeTool, updateToolHeader,
226
464
  runEnv, missingRequires, toolNameFromPath, sanitizeName, ensureDir,
465
+ matchTools, recordRun, recordCreate, readUsage,
466
+ lintToolSource, checkSyntax, envRefsIn,
227
467
  };
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.55",
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,95 @@ 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
+
118
+ // ── createdAt: stamped once at add, surfaced on the record, immutable across a
119
+ // body rewrite (so dream's "cold since creation" clock can't be reset by an
120
+ // edit) ──
121
+ assert.ok(tools.findTool("hello").createdAt, "addTool stamps createdAt");
122
+ const bornHello = tools.findTool("hello").createdAt;
123
+ tools.recordCreate("hello"); // a second create-record must NOT move the stamp
124
+ assert.strictEqual(tools.findTool("hello").createdAt, bornHello, "createdAt is immutable once set");
125
+
126
+ // ── envRefsIn: pulls every shape of env read out of a script ──
127
+ const refs = tools.envRefsIn('echo $FOO ${BAR}\nx=process.env.BAZ\ny=process.env["QUX"]\n');
128
+ for (const k of ["FOO", "BAR", "BAZ", "QUX"]) assert.ok(refs.has(k), `envRefsIn finds ${k}`);
129
+
130
+ // ── lintToolSource: hardcoded secrets, undeclared keyring reads, unused requires
131
+ // (all advisory; keyringKeys passed explicitly so the test is hermetic) ──
132
+ const lint = tools.lintToolSource(
133
+ 'curl -H "Authorization: Bearer sk-ant-abcdefgh12345678" "$inet_central_user" "$missing_decl"\n',
134
+ { requires: ["unused_key"], keyringKeys: ["inet_central_user", "unused_key"] }
135
+ );
136
+ assert.ok(lint.secrets.some((s) => /Anthropic/.test(s)), "lint flags the sk-ant- literal");
137
+ assert.ok(lint.secrets.some((s) => /Bearer/.test(s)), "lint flags the inline Bearer token");
138
+ assert.ok(lint.undeclared.includes("inet_central_user"), "a keyring key read but not declared is 'undeclared'");
139
+ assert.ok(!lint.undeclared.includes("missing_decl"), "a non-keyring env read is not flagged undeclared");
140
+ assert.ok(lint.unused.includes("unused_key"), "a declared key the script never reads is 'unused'");
141
+
142
+ // ── checkSyntax: catches a broken bash script, passes a good one, and returns
143
+ // null for a type it can't check ──
144
+ const goodSh = path.join(tmp, "good.sh");
145
+ fs.writeFileSync(goodSh, "#!/usr/bin/env bash\necho ok\n");
146
+ assert.deepStrictEqual(tools.checkSyntax(goodSh, fs.readFileSync(goodSh, "utf-8")), { ok: true, error: "" }, "valid bash → ok");
147
+ const badSh = path.join(tmp, "bad.sh");
148
+ fs.writeFileSync(badSh, "#!/usr/bin/env bash\nif [ ; then echo hi\n");
149
+ const badRes = tools.checkSyntax(badSh, fs.readFileSync(badSh, "utf-8"));
150
+ assert.ok(badRes && badRes.ok === false && badRes.error, "broken bash → { ok:false, error }");
151
+ assert.strictEqual(tools.checkSyntax("/tmp/x.txt", "plain text, no shebang\n"), null, "unknown type → null (no crying wolf)");
152
+
153
+ // ── updateToolHeader (the 'fix' verb): refreshes recognised field lines in place,
154
+ // preserves the marker (exactly once), hand-written comments, and createdAt;
155
+ // leaves the body untouched ──
156
+ const fixSrc = path.join(tmp, "fixme.sh");
157
+ fs.writeFileSync(
158
+ fixSrc,
159
+ "#!/usr/bin/env bash\n# open-claudia-tool: fixme\n# description: old desc\n# a hand note worth keeping\necho \"$WIDGET_KEY\"\n"
160
+ );
161
+ const fixed0 = tools.addTool(fixSrc, {});
162
+ const bornFix = fixed0.createdAt;
163
+ const fixed = tools.updateToolHeader("fixme", { description: "new desc", requires: ["WIDGET_KEY"], usage: "fixme <x>" });
164
+ assert.strictEqual(fixed.description, "new desc", "updateToolHeader rewrites the description");
165
+ assert.deepStrictEqual(fixed.requires, ["WIDGET_KEY"], "updateToolHeader sets requires");
166
+ assert.strictEqual(fixed.usage, "fixme <x>", "updateToolHeader sets usage");
167
+ assert.strictEqual(fixed.createdAt, bornFix, "updateToolHeader preserves createdAt");
168
+ assert.ok(/a hand note worth keeping/.test(fixed.content), "updateToolHeader keeps hand-written comments");
169
+ assert.strictEqual((fixed.content.match(/open-claudia-tool: fixme/g) || []).length, 1, "marker survives exactly once");
170
+ assert.ok(/echo "\$WIDGET_KEY"/.test(fixed.content), "updateToolHeader leaves the body untouched");
171
+ // requires-string form is normalised to a list
172
+ const fixed2 = tools.updateToolHeader("fixme", { requires: "WIDGET_KEY, OTHER_KEY" });
173
+ assert.deepStrictEqual(fixed2.requires, ["WIDGET_KEY", "OTHER_KEY"], "string requires split into a list");
174
+ // a body that now reads OTHER_KEY would no longer be 'unused' — lint stays in sync
175
+ assert.strictEqual(tools.updateToolHeader("does-not-exist", { description: "x" }), null, "updateToolHeader on a missing tool → null");
176
+
88
177
  // ── remove ──
89
178
  assert.ok(tools.removeTool("hello"), "removeTool returns the removed tool");
90
179
  assert.strictEqual(tools.findTool("hello"), null, "removed tool is gone");