@inetafrica/open-claudia 2.7.0 → 2.7.1
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 +4 -0
- package/core/recall/discoverer.js +71 -3
- package/core/system-prompt.js +14 -7
- package/package.json +1 -1
- package/test-recall-discoverer.js +36 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.7.1
|
|
4
|
+
- **The system prompt now teaches tool-FIRST, not tool-after (enforcement 5d).** The fixed "Reusable tools" section in `core/system-prompt.js` was the root cause of the baseline failure: it taught "crystallise the script you just wrote" — do the work raw, save a tool afterwards. Rewritten as "Tools are the interface": before operational work, walk the loop **search → use (read TOOL.md first, never guess args) → extend the missing verb → scaffold first** — and only then do the work *through* the tool. Raw one-liners and heredocs are demoted to probing/diagnosis; a broken tool gets fixed or journaled (`tool note --issue`), never routed around. The section also documents the risk gate honestly: the `--yes`/`--yes-destructive` flag is the agent's mechanical acknowledgment *after* the user approves — it never replaces asking first.
|
|
5
|
+
- **Tool-scout: operational turns can no longer meet silence (enforcement 5a).** Today an empty tool match is silent, and silence reads as permission to improvise. The discoverer now runs a pattern-based scout (no LLM, ~zero cost) over each seeds/full-tier turn: a table of high-precision operational domains — named hosts (central.inet.africa, ticket-central, chat-central, git.coders.africa…), prod IP ranges, infra commands (kubectl, argocd, mongosh…) — each with a `covers` matcher against the tool registry. When a turn trips a domain: if a registry tool covers it but wasn't auto-surfaced, the block points at it by name (`tool show X`, then work through it); if **nothing** covers it, the block says so and hands over the exact scaffold command (`tool scaffold <suggested-name> --risk write`). Domains already covered by a surfaced tool stay quiet, and non-operational chatter never trips the patterns (precision over recall — they expand from audit evidence later, per 5c). Gap signals flow into recall metrics (`toolGaps`) and the engine result for future `/tooltrace` surfacing. Extends `test-recall-discoverer.js` with pure-function and through-the-engine scout coverage.
|
|
6
|
+
|
|
3
7
|
## v2.7.0
|
|
4
8
|
- **Tools become memory objects (tool-first architecture, Phase 1).** A tool is no longer a flat script — it's a directory: `tools/<name>/tool` (the executable), `TOOL.md` (Interface · Known issues · State · Journal — the curated, human-readable condition of the tool), and `state.json` (machine telemetry, runtime-written, gitignored). The tools dir is now a **git repository**: every add, metadata change, note, and removal auto-commits (with a per-invocation identity, never touching git config), so `git log` *is* the upgrade history and any change is one revert from undone. `tool show` is tiered disclosure: header metadata, live telemetry, TOOL.md, and the recent commit log — the source only with `--source`, so reading about a tool no longer costs its whole body in context.
|
|
5
9
|
- **Risk tiers + run gate.** Every tool declares `risk: read-only | write | destructive` in its header (absent = treated as write, fail-safe). `tool run` now enforces the ask-first rule mechanically: read-only runs freely; write requires `--yes`; destructive requires `--yes-destructive` — and a plain `--yes` does **not** unlock a destructive tool. Refusals exit 3 with a clear message. The flags pass through to the tool unstripped, so tools with their own confirmation gates (e.g. prod-k8s) keep working. `tool add`/`tool set` accept `--risk`; adding without it defaults to write and says so.
|
|
@@ -27,6 +27,65 @@ const toolsLib = require("../tools");
|
|
|
27
27
|
// entity-only). Optional — absent on old node where node:sqlite is missing.
|
|
28
28
|
let toolGraph = null;
|
|
29
29
|
try { toolGraph = require("../tool-graph"); } catch (e) { /* old node */ }
|
|
30
|
+
|
|
31
|
+
// ── Tool-scout (enforcement 5a): operational-domain gap detection ────────────
|
|
32
|
+
// When a turn looks operational, an empty tool match must not stay silent —
|
|
33
|
+
// silence reads as permission to improvise a raw script. v1 is pure patterns,
|
|
34
|
+
// no LLM: `test` decides the turn touches an operational domain (named hosts,
|
|
35
|
+
// prod IPs, infra commands — precision over recall so local/dev chatter never
|
|
36
|
+
// trips it); `covers` finds a registry tool that fronts that domain (matched
|
|
37
|
+
// on name + description); `slug` is the suggested scaffold name when nothing
|
|
38
|
+
// in the registry covers it. Patterns expand from audit evidence (5c).
|
|
39
|
+
const OPERATIONAL_DOMAINS = [
|
|
40
|
+
{ label: "Kubernetes cluster ops", test: /\bkubectl\b|\bargocd\b|control[- ]plane|\brollout (?:status|restart|history)\b/i, covers: /k8s|kubernetes|kubectl|cluster/i, slug: "prod-k8s" },
|
|
41
|
+
{ label: "in-cluster MongoDB", test: /\bmongosh\b|\bmongo\b[^\n]*--eval|\bdb\.\w+\.(?:find|update\w*|delete\w*|insert\w*|aggregate)\(/i, covers: /mongo|k8s/i, slug: "prod-k8s" },
|
|
42
|
+
{ label: "inet-central platform API", test: /central\.inet\.africa|\binet[- ]central\b/i, covers: /inet[- ]?central/i, slug: "inet-central-cli" },
|
|
43
|
+
{ label: "ticket-central API", test: /ticketcentral|\bticket[- ]central\b/i, covers: /\bticket/i, slug: "kticket" },
|
|
44
|
+
{ label: "kazee chat-central API", test: /\bchat[- ]central\b/i, covers: /kazee-chat|chat/i, slug: "kchat" },
|
|
45
|
+
{ label: "kazee spaces API", test: /spaces\.kazee|\bspaces[- ]central\b/i, covers: /spaces/i, slug: "spaces" },
|
|
46
|
+
{ label: "GitLab / coders.africa", test: /git\.coders\.africa|codersafrica@|\bgitlab\b/i, covers: /gitlab|coders/i, slug: "gitlab-ops" },
|
|
47
|
+
{ label: "MikroTik / RouterOS", test: /mikrotik|routeros|winbox|\/rest\/(?:ip|ppp|queue)\b/i, covers: /mikrotik|routeros/i, slug: "mikrotik" },
|
|
48
|
+
{ label: "iNet radius/ops backend", test: /radius\.inet\.africa|ops\.inet\.africa/i, covers: /radius|pppoe/i, slug: "inet-radius" },
|
|
49
|
+
{ label: "production network host", test: /\b10\.200\.\d{1,3}\.\d{1,3}\b|\b102\.222\.4\.\d{1,3}\b/, covers: null, slug: "" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
// Pure + exported for tests. Returns [{ label, kind: "use", tool }] when a
|
|
53
|
+
// registry tool covers a tripped domain but was not surfaced this turn, or
|
|
54
|
+
// [{ label, kind: "scaffold", slug }] when nothing in the registry covers it.
|
|
55
|
+
// Domains already covered by a surfaced (kept) tool produce no signal.
|
|
56
|
+
function scoutToolGaps(userText, keptToolNames, allTools) {
|
|
57
|
+
const text = String(userText || "");
|
|
58
|
+
if (!text) return [];
|
|
59
|
+
let all = allTools;
|
|
60
|
+
if (!all) { try { all = toolsLib.listTools(); } catch (e) { return []; } }
|
|
61
|
+
const kept = [...(keptToolNames || [])].map((n) => String(n).toLowerCase());
|
|
62
|
+
const gaps = [];
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
for (const d of OPERATIONAL_DOMAINS) {
|
|
65
|
+
if (!d.test.test(text)) continue;
|
|
66
|
+
if (d.covers && kept.some((n) => d.covers.test(n))) continue;
|
|
67
|
+
const hit = d.covers
|
|
68
|
+
? all.find((t) => d.covers.test(t.name) || d.covers.test(String(t.description || "")))
|
|
69
|
+
: null;
|
|
70
|
+
if (hit && kept.includes(hit.name.toLowerCase())) continue;
|
|
71
|
+
const g = hit
|
|
72
|
+
? { label: d.label, kind: "use", tool: hit.name }
|
|
73
|
+
: { label: d.label, kind: "scaffold", slug: d.slug || "" };
|
|
74
|
+
const key = g.kind + ":" + (g.tool || g.slug || g.label);
|
|
75
|
+
if (seen.has(key)) continue;
|
|
76
|
+
seen.add(key);
|
|
77
|
+
gaps.push(g);
|
|
78
|
+
}
|
|
79
|
+
return gaps;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderToolGaps(gaps) {
|
|
83
|
+
if (!gaps || !gaps.length) return "";
|
|
84
|
+
const lines = gaps.map((g) => g.kind === "use"
|
|
85
|
+
? `- ${g.label}: covered by existing tool \`${g.tool}\` (not auto-surfaced this turn) — \`open-claudia tool show ${g.tool}\`, then work through it.`
|
|
86
|
+
: `- ${g.label}: NO tool covers this domain. Scaffold before improvising: \`open-claudia tool scaffold ${g.slug || "<system-name>"} --desc "<what it fronts>" --risk write\` — implement the verb you need, then do the work through it.`);
|
|
87
|
+
return `\n\n## Tool-first check (operational turn)\nThis turn touches operational domains. Do the operational work THROUGH a tool — raw one-liners only to probe:\n${lines.join("\n")}\n`;
|
|
88
|
+
}
|
|
30
89
|
// Episodic memory: FTS over past transcripts (same optionality as the graphs).
|
|
31
90
|
let transcriptIndex = null;
|
|
32
91
|
try { transcriptIndex = require("../transcript-index"); } catch (e) { /* old node */ }
|
|
@@ -236,7 +295,7 @@ async function run(ctx) {
|
|
|
236
295
|
const tier = recallTier(userText, seedCount);
|
|
237
296
|
if (tier === "skip") {
|
|
238
297
|
metrics.logTurn({ engine: "discoverer", model: WALKER_MODEL, query: userText, gated: true, tier, latencyMs: Date.now() - started });
|
|
239
|
-
return { packBlock: "", entityBlock: "", toolBlock: "", episodeBlock: "", packMatches: [], entityMatches: [], toolMatches: [], episodeMatches: [], why: {}, gated: true, tier };
|
|
298
|
+
return { packBlock: "", entityBlock: "", toolBlock: "", episodeBlock: "", packMatches: [], entityMatches: [], toolMatches: [], toolGaps: [], episodeMatches: [], why: {}, gated: true, tier };
|
|
240
299
|
}
|
|
241
300
|
|
|
242
301
|
// 3: spreading activation from seeds across the graph (full tier only — the
|
|
@@ -464,6 +523,14 @@ async function run(ctx) {
|
|
|
464
523
|
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`;
|
|
465
524
|
}
|
|
466
525
|
|
|
526
|
+
// 5a tool-scout: replace the silence of an empty/partial tool match with an
|
|
527
|
+
// explicit use-this-tool or scaffold-first signal on operational turns. Runs
|
|
528
|
+
// on both seeds and full tiers (short command-like turns are exactly the
|
|
529
|
+
// operational ones); skip-tier pleasantries never reach here.
|
|
530
|
+
let toolGaps = [];
|
|
531
|
+
try { toolGaps = scoutToolGaps(userText, toolMatches.map((m) => m.name)); } catch (e) {}
|
|
532
|
+
if (toolGaps.length) toolBlock += renderToolGaps(toolGaps);
|
|
533
|
+
|
|
467
534
|
// Past-conversation episodes the walker kept (fail-open drops them — an
|
|
468
535
|
// unjudged transcript snippet is a guess, not a memory).
|
|
469
536
|
const episodeMatches = episodeCands
|
|
@@ -497,12 +564,13 @@ async function run(ctx) {
|
|
|
497
564
|
walkerMs: walkRes.walkerMs,
|
|
498
565
|
walker: walkRes.source,
|
|
499
566
|
costUsd: walkRes.costUsd || 0,
|
|
567
|
+
toolGaps: toolGaps.length ? toolGaps.map((g) => `${g.kind}:${g.tool || g.slug || g.label}`) : undefined,
|
|
500
568
|
});
|
|
501
569
|
|
|
502
570
|
return {
|
|
503
571
|
packBlock, entityBlock, toolBlock, episodeBlock, packMatches: finalPacks, entityMatches: finalEnts,
|
|
504
|
-
toolMatches, episodeMatches, why: whyMap ? Object.fromEntries(whyMap) : {}, gated: false, tier,
|
|
572
|
+
toolMatches, toolGaps, episodeMatches, why: whyMap ? Object.fromEntries(whyMap) : {}, gated: false, tier,
|
|
505
573
|
};
|
|
506
574
|
}
|
|
507
575
|
|
|
508
|
-
module.exports = { name: "discoverer", run, needsRecall, recallTier, walk, cascadeVerdict };
|
|
576
|
+
module.exports = { name: "discoverer", run, needsRecall, recallTier, walk, cascadeVerdict, scoutToolGaps, renderToolGaps };
|
package/core/system-prompt.js
CHANGED
|
@@ -79,18 +79,25 @@ function buildToolIndexBlock() {
|
|
|
79
79
|
if (count) {
|
|
80
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`;
|
|
81
81
|
} else {
|
|
82
|
-
listing = "\nNo reusable tools
|
|
82
|
+
listing = "\nNo reusable tools exist yet — your first operational task starts by scaffolding the tool for it, not by improvising a script.\n";
|
|
83
83
|
}
|
|
84
84
|
} catch (e) {
|
|
85
85
|
return "";
|
|
86
86
|
}
|
|
87
|
-
return `\n##
|
|
88
|
-
|
|
87
|
+
return `\n## Tools are the interface (tool-first)
|
|
88
|
+
Operational work — hitting an API, mutating a live system, driving an interface, any command you would re-derive next time — happens THROUGH a reusable tool, not around one. BEFORE doing the work, walk this loop in order:
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
1. **Search** — check the "Tools that may help here" block if present; otherwise \`open-claudia tool search <query>\` or \`tool list\`.
|
|
91
|
+
2. **Use** — a tool covers it: read it first (\`open-claudia tool show <name>\` gives docs, State, known issues, recent changes — never guess its args; \`--source\` only when modifying), then \`open-claudia tool run <name> <verb> [args]\`.
|
|
92
|
+
3. **Extend** — right tool, missing verb: add the verb to that tool (\`tool edit <name>\`, then \`tool note <name> --journal "added <verb>"\`) and run it. One CLI per system, verbs as subcommands — never fork a near-duplicate.
|
|
93
|
+
4. **Scaffold** — no tool fronts the system: \`open-claudia tool scaffold <name> --desc "..." --risk <read-only|write|destructive> [--pack <dir>] [--requires "key1,key2"]\` FIRST, implement the verb you need, then do the work through it.
|
|
94
|
+
|
|
95
|
+
Raw shell one-liners and heredocs are for probing and diagnosis only — never the operational action itself. If a tool is broken, fix it or journal the defect (\`tool note <name> --issue "..."\`); do not route around it with a script.
|
|
96
|
+
|
|
97
|
+
- Risk gate: read-only tools run freely; write-tier needs \`--yes\`; destructive needs \`--yes-destructive\`. The flag is your mechanical acknowledgment AFTER the user approves the action — it never replaces asking first.
|
|
98
|
+
- Tools carry their own memory: TOOL.md (Interface · Known issues · State · Journal) is the manual, state.json records every run (per-verb health), and every change is a git commit. Keep TOOL.md truthful as you work; update the tool in place when behaviour changes.
|
|
99
|
+
- Preauth: the operational keyring is merged into tool runs — and into your own Bash. Reference creds as \`$inet_central_user\` etc. (\`open-claudia keyring list\` shows names); declare a tool's needs via \`--requires\` so a missing key reports before the run; never write a secret into a tool file (saves are refused).
|
|
100
|
+
- Announce in one line when you scaffold or change a tool, same as packs. Full CLI: \`open-claudia tool list|search|show|scaffold|add|set|note|edit|run|migrate|remove\`.
|
|
94
101
|
${listing}`;
|
|
95
102
|
}
|
|
96
103
|
|
package/package.json
CHANGED
|
@@ -124,7 +124,12 @@ const helpers = { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEnti
|
|
|
124
124
|
});
|
|
125
125
|
assert.strictEqual(outToolFull.tier, "full", "substantive ask lands on the full tier");
|
|
126
126
|
assert.strictEqual(outToolFull.toolMatches.length, 0, "fail-open drops unjudged tools at full tier");
|
|
127
|
-
|
|
127
|
+
// 5a: the drop no longer leaves silence — the turn is operational (mikrotik),
|
|
128
|
+
// so the scout points at the covering tool by name instead of injecting it
|
|
129
|
+
// unjudged. No "Tools that may help here" injection, but a Tool-first check.
|
|
130
|
+
assert.ok(!/Tools that may help here/.test(outToolFull.toolBlock), "no unjudged tool injection at full tier");
|
|
131
|
+
assert.ok(/Tool-first check/.test(outToolFull.toolBlock), "scout replaces fail-open silence on an operational turn");
|
|
132
|
+
assert.ok(/mikrotik-audit/.test(outToolFull.toolBlock), "scout names the covering tool");
|
|
128
133
|
|
|
129
134
|
const outNoTool = await disc.run({
|
|
130
135
|
userText: "help with the mobile app", contextText: "", fullContext: "help with the mobile app",
|
|
@@ -133,6 +138,36 @@ const helpers = { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEnti
|
|
|
133
138
|
assert.strictEqual(outNoTool.toolBlock, "", "no tool match → empty toolBlock");
|
|
134
139
|
assert.strictEqual(outNoTool.toolMatches.length, 0, "no tool match → no toolMatches");
|
|
135
140
|
|
|
141
|
+
// --- 5a tool-scout: operational-domain gap detection (pure function) ---
|
|
142
|
+
const reg = [{ name: "prod-k8s", description: "Production k8s cluster ops" }];
|
|
143
|
+
let gaps = disc.scoutToolGaps("check the pods with kubectl on prod", [], reg);
|
|
144
|
+
assert.deepStrictEqual(gaps, [{ label: "Kubernetes cluster ops", kind: "use", tool: "prod-k8s" }],
|
|
145
|
+
"operational turn + registry tool not surfaced → use signal");
|
|
146
|
+
gaps = disc.scoutToolGaps("check the pods with kubectl on prod", ["prod-k8s"], reg);
|
|
147
|
+
assert.deepStrictEqual(gaps, [], "kept tool covering the domain → no signal");
|
|
148
|
+
gaps = disc.scoutToolGaps("run kubectl and a mongosh eval on the cluster", [], reg);
|
|
149
|
+
assert.strictEqual(gaps.length, 1, "two domains covered by one tool dedupe to one signal");
|
|
150
|
+
gaps = disc.scoutToolGaps("pull the open tickets from ticket-central", [], reg);
|
|
151
|
+
assert.deepStrictEqual(gaps, [{ label: "ticket-central API", kind: "scaffold", slug: "kticket" }],
|
|
152
|
+
"operational turn + nothing in registry → scaffold-first signal with suggested name");
|
|
153
|
+
gaps = disc.scoutToolGaps("what should we cook for dinner tonight", [], reg);
|
|
154
|
+
assert.deepStrictEqual(gaps, [], "non-operational turn → scout stays silent");
|
|
155
|
+
assert.strictEqual(disc.renderToolGaps([]), "", "no gaps → no block");
|
|
156
|
+
assert.ok(/tool scaffold kticket/.test(disc.renderToolGaps([{ label: "ticket-central API", kind: "scaffold", slug: "kticket" }])),
|
|
157
|
+
"scaffold signal carries the exact command");
|
|
158
|
+
|
|
159
|
+
// scout through the engine: an operational turn with no covering tool in the
|
|
160
|
+
// fixture registry renders the Tool-first check even though toolMatches is empty
|
|
161
|
+
const outGap = await disc.run({
|
|
162
|
+
userText: "pull the open tickets from the ticket-central api", contextText: "",
|
|
163
|
+
fullContext: "pull the open tickets from the ticket-central api", packLimit: 6, budget: {}, helpers,
|
|
164
|
+
});
|
|
165
|
+
assert.strictEqual(outGap.toolMatches.length, 0, "no tool matches the ticket domain");
|
|
166
|
+
assert.ok(/Tool-first check/.test(outGap.toolBlock), "gap block renders on an operational turn");
|
|
167
|
+
assert.ok(/tool scaffold kticket/.test(outGap.toolBlock), "gap block carries the scaffold command");
|
|
168
|
+
assert.ok(Array.isArray(outGap.toolGaps) && outGap.toolGaps[0].kind === "scaffold", "toolGaps returned in the result");
|
|
169
|
+
assert.deepStrictEqual(gated.toolGaps, [], "gated turn returns empty toolGaps");
|
|
170
|
+
|
|
136
171
|
// seeds tier: a short command-like turn skips graph+walker but still injects
|
|
137
172
|
// keyword seeds directly (classic baseline, ~ms path).
|
|
138
173
|
builtPacks = null;
|