@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 +7 -0
- package/bin/cli.js +1 -1
- package/bin/tool.js +32 -1
- package/core/actions.js +6 -0
- package/core/dream.js +26 -1
- package/core/handlers.js +24 -1
- package/core/recall/discoverer.js +96 -5
- package/core/runner.js +46 -7
- package/core/system-prompt.js +15 -29
- package/core/tool-graph.js +12 -3
- package/core/tools.js +99 -3
- package/package.json +1 -1
- package/test-recall-discoverer.js +28 -0
- package/test-tools.js +30 -0
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
|
|
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
|
-
|
|
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))
|
|
922
|
-
else
|
|
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))
|
|
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 (
|
|
1032
|
+
if (injected && injected.recall) {
|
|
1011
1033
|
const r = injected.recall;
|
|
1012
1034
|
const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
-
|
|
1015
|
-
if (
|
|
1016
|
-
|
|
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
|
package/core/system-prompt.js
CHANGED
|
@@ -66,37 +66,18 @@ function buildSkillIndexBlock() {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
// Always-on tool
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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
|
|
79
|
-
if (
|
|
80
|
-
|
|
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
|
}
|
package/core/tool-graph.js
CHANGED
|
@@ -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".
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
@@ -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");
|