@inetafrica/open-claudia 2.6.58 → 2.7.0
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/README.md +8 -3
- package/bin/cli.js +5 -1
- package/bin/kpi.js +55 -0
- package/bin/tool.js +177 -23
- package/core/dream.js +384 -11
- package/core/pack-review.js +32 -4
- package/core/recall/discoverer.js +92 -24
- package/core/recall/graph.js +29 -2
- package/core/recall/kpi.js +227 -0
- package/core/recall/metrics.js +198 -4
- package/core/recall/tuning.js +125 -0
- package/core/runner.js +4 -0
- package/core/system-prompt.js +1 -0
- package/core/tool-repo.js +270 -0
- package/core/tools.js +268 -59
- package/core/transcript-index.js +21 -1
- package/package.json +2 -2
- package/test-recall-discoverer.js +21 -6
- package/test-tools.js +139 -19
package/core/tools.js
CHANGED
|
@@ -1,18 +1,29 @@
|
|
|
1
|
-
// Reusable tools:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Reusable tools: the interface operational work happens THROUGH — not
|
|
2
|
+
// souvenirs saved after the fact. The agent searches for a tool first, extends
|
|
3
|
+
// a missing verb, or scaffolds; scripts are for probing unknown APIs only.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// A tool is a memory object, not a file — a directory:
|
|
6
|
+
// tools/<name>/tool executable (any language, shebang)
|
|
7
|
+
// tools/<name>/TOOL.md Interface · Known issues · State · Journal (prose)
|
|
8
|
+
// tools/<name>/state.json machine telemetry, stamped by the runtime only
|
|
9
|
+
// The tools dir is git-initialised (core/tool-repo.js); every add/change
|
|
10
|
+
// commits, so git log is the upgrade history. Pre-2.7 flat-file tools are
|
|
11
|
+
// still readable and are migrated to directories on first CLI use.
|
|
12
|
+
//
|
|
13
|
+
// This is the executable sibling of context packs: packs hold judgment
|
|
14
|
+
// (when/why/what needs approval), tools hold mechanics (how). Tools surface in
|
|
15
|
+
// the system prompt as an always-on index and are read on demand via
|
|
16
|
+
// `open-claudia tool show <name>` (progressive disclosure).
|
|
10
17
|
//
|
|
11
18
|
// Preauth: tools run with the operational keyring merged into their env (see
|
|
12
|
-
// runEnv())
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
19
|
+
// runEnv()). A tool references a credential as $inet_central_user and never
|
|
20
|
+
// hardcodes a secret — addTool/updateToolHeader REFUSE sources that match
|
|
21
|
+
// secret patterns, because the auto-commit history is forever.
|
|
22
|
+
//
|
|
23
|
+
// Risk tiers mechanise the ask-first rule: every tool declares read-only /
|
|
24
|
+
// write / destructive. runGate() refuses write-tier runs without --yes and
|
|
25
|
+
// destructive-tier runs without --yes-destructive; approval itself always
|
|
26
|
+
// comes from the user — the flag is the agent's deliberate confirmation step.
|
|
16
27
|
|
|
17
28
|
const fs = require("fs");
|
|
18
29
|
const path = require("path");
|
|
@@ -24,7 +35,20 @@ const TOOLS_DIR = process.env.TOOLS_DIR ? path.resolve(process.env.TOOLS_DIR) :
|
|
|
24
35
|
// parse from the leading comment block. Language-agnostic: we accept "#" (sh,
|
|
25
36
|
// python, ruby) and "//" (js, ts, go) comment prefixes.
|
|
26
37
|
const MARKER = "open-claudia-tool";
|
|
27
|
-
const HEADER_KEYS = ["description", "pack", "requires", "usage"];
|
|
38
|
+
const HEADER_KEYS = ["description", "pack", "requires", "usage", "risk"];
|
|
39
|
+
const RISK_TIERS = ["read-only", "write", "destructive"];
|
|
40
|
+
|
|
41
|
+
// Normalise a risk value to one of RISK_TIERS ("" when unrecognised). Blast
|
|
42
|
+
// radius on LIVE systems is what counts: writing a local output file is still
|
|
43
|
+
// read-only; mutating an API/DB/cluster is write; deleting or irreversible is
|
|
44
|
+
// destructive.
|
|
45
|
+
function normalizeRisk(val) {
|
|
46
|
+
const v = String(val || "").toLowerCase().trim().replace(/[_\s]+/g, "-");
|
|
47
|
+
if (["read-only", "readonly", "read", "ro"].includes(v)) return "read-only";
|
|
48
|
+
if (["write", "w", "mutating"].includes(v)) return "write";
|
|
49
|
+
if (["destructive", "dangerous"].includes(v)) return "destructive";
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
28
52
|
|
|
29
53
|
function ensureDir() {
|
|
30
54
|
fs.mkdirSync(TOOLS_DIR, { recursive: true, mode: 0o700 });
|
|
@@ -35,12 +59,28 @@ function sanitizeName(name) {
|
|
|
35
59
|
.replace(/[^a-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 60);
|
|
36
60
|
}
|
|
37
61
|
|
|
62
|
+
// ── Layout resolution ────────────────────────────────────────────────────────
|
|
63
|
+
// Directory layout (2.7+) vs legacy flat file. A name resolves to the directory
|
|
64
|
+
// shape when tools/<name>/tool exists; otherwise to the flat file if present;
|
|
65
|
+
// otherwise (for creation) to the directory shape.
|
|
66
|
+
function toolPaths(name) {
|
|
67
|
+
const n = sanitizeName(name);
|
|
68
|
+
const dir = path.join(TOOLS_DIR, n);
|
|
69
|
+
const exec = path.join(dir, "tool");
|
|
70
|
+
const isDir = (() => { try { return fs.statSync(exec).isFile(); } catch (e) { return false; } })();
|
|
71
|
+
if (isDir) return { name: n, layout: "dir", dir, exec, doc: path.join(dir, "TOOL.md"), state: path.join(dir, "state.json") };
|
|
72
|
+
const isFile = (() => { try { return fs.statSync(dir).isFile(); } catch (e) { return false; } })();
|
|
73
|
+
if (isFile) return { name: n, layout: "file", dir: TOOLS_DIR, exec: dir, doc: null, state: null };
|
|
74
|
+
return { name: n, layout: "new", dir, exec, doc: path.join(dir, "TOOL.md"), state: path.join(dir, "state.json") };
|
|
75
|
+
}
|
|
76
|
+
|
|
38
77
|
// ── Run telemetry ────────────────────────────────────────────────────────────
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
78
|
+
// Directory tools: per-tool state.json, written ONLY by the runtime (never
|
|
79
|
+
// hand-edited, never committed — gitignored; TOOL.md's State section is the
|
|
80
|
+
// curated prose view). Plain JSON, not SQLite: must work on node:20 pods.
|
|
81
|
+
// Legacy flat tools keep the old .usage.json sidecar until migrated.
|
|
43
82
|
const USAGE_FILE = path.join(TOOLS_DIR, ".usage.json");
|
|
83
|
+
const HISTORY_CAP = 20;
|
|
44
84
|
|
|
45
85
|
function readUsage() {
|
|
46
86
|
try { return JSON.parse(fs.readFileSync(USAGE_FILE, "utf-8")) || {}; }
|
|
@@ -52,12 +92,58 @@ function writeUsage(u) {
|
|
|
52
92
|
catch (e) { /* best-effort */ }
|
|
53
93
|
}
|
|
54
94
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
95
|
+
function readState(name) {
|
|
96
|
+
const p = toolPaths(name);
|
|
97
|
+
if (!p.state) return {};
|
|
98
|
+
try { return JSON.parse(fs.readFileSync(p.state, "utf-8")) || {}; }
|
|
99
|
+
catch (e) { return {}; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function writeState(name, s) {
|
|
103
|
+
const p = toolPaths(name);
|
|
104
|
+
const file = p.state || path.join(TOOLS_DIR, sanitizeName(name), "state.json");
|
|
105
|
+
try {
|
|
106
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
107
|
+
fs.writeFileSync(file, JSON.stringify(s, null, 1), { mode: 0o600 });
|
|
108
|
+
} catch (e) { /* best-effort */ }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Authoritative telemetry stamp — called by `tool run` itself with the real
|
|
112
|
+
// outcome (exit code, duration, argv). Tracks per-verb health (verb = first
|
|
113
|
+
// non-flag arg) so TOOL.md can report "assign: last real run <date>" honestly,
|
|
114
|
+
// plus a capped run history. A non-zero exit is the trigger for a known-issue
|
|
115
|
+
// entry in TOOL.md (the CLI nudges; the foreground agent writes it).
|
|
116
|
+
function recordRunResult(name, { args = [], exit = 0, ms = 0 } = {}) {
|
|
117
|
+
const n = sanitizeName(name);
|
|
118
|
+
if (!n) return;
|
|
119
|
+
if (toolPaths(n).layout !== "dir") { recordRun(n); return; } // legacy fallback
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
const s = readState(n);
|
|
122
|
+
if (!s.createdAt) s.createdAt = now;
|
|
123
|
+
s.lastUsed = now;
|
|
124
|
+
s.runCount = (s.runCount || 0) + 1;
|
|
125
|
+
s.lastExit = exit;
|
|
126
|
+
if (exit === 0) s.lastSuccess = now; else s.lastFailure = now;
|
|
127
|
+
const verb = (args.find((a) => a && !String(a).startsWith("-")) || "(default)").slice(0, 40);
|
|
128
|
+
s.verbs = s.verbs || {};
|
|
129
|
+
const v = s.verbs[verb] || { runs: 0 };
|
|
130
|
+
v.runs += 1;
|
|
131
|
+
v.lastExit = exit;
|
|
132
|
+
if (exit === 0) v.lastSuccess = now; else v.lastFailure = now;
|
|
133
|
+
s.verbs[verb] = v;
|
|
134
|
+
const argLine = args.join(" ").slice(0, 120);
|
|
135
|
+
s.history = [{ at: now, args: argLine, exit, ms }, ...(s.history || [])].slice(0, HISTORY_CAP);
|
|
136
|
+
writeState(n, s);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Bump a tool's run counter without outcome detail. Still called at end of turn
|
|
140
|
+
// from the runner's transcript parse; for directory tools that's a no-op (the
|
|
141
|
+
// CLI already stamped the authoritative result — double-counting would lie),
|
|
142
|
+
// so this only feeds the legacy sidecar for unmigrated flat tools.
|
|
58
143
|
function recordRun(name) {
|
|
59
144
|
const n = sanitizeName(name);
|
|
60
145
|
if (!n) return;
|
|
146
|
+
if (toolPaths(n).layout === "dir") return; // CLI stamps state.json directly
|
|
61
147
|
const u = readUsage();
|
|
62
148
|
const cur = u[n] || { runCount: 0, lastUsed: "" };
|
|
63
149
|
cur.runCount = (cur.runCount || 0) + 1;
|
|
@@ -66,13 +152,18 @@ function recordRun(name) {
|
|
|
66
152
|
writeUsage(u);
|
|
67
153
|
}
|
|
68
154
|
|
|
69
|
-
// Stamp an immutable createdAt the first time a tool is registered. Kept in
|
|
70
|
-
//
|
|
71
|
-
// mtime, which resets on every edit and would make an edited-but-never-run
|
|
72
|
-
// look freshly minted. The dream's zero-run hygiene ages tools from this.
|
|
155
|
+
// Stamp an immutable createdAt the first time a tool is registered. Kept in
|
|
156
|
+
// telemetry (not the header) so editing the file never disturbs it — unlike
|
|
157
|
+
// file mtime, which resets on every edit and would make an edited-but-never-run
|
|
158
|
+
// tool look freshly minted. The dream's zero-run hygiene ages tools from this.
|
|
73
159
|
function recordCreate(name) {
|
|
74
160
|
const n = sanitizeName(name);
|
|
75
161
|
if (!n) return;
|
|
162
|
+
if (toolPaths(n).layout === "dir") {
|
|
163
|
+
const s = readState(n);
|
|
164
|
+
if (!s.createdAt) { s.createdAt = new Date().toISOString(); writeState(n, s); }
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
76
167
|
const u = readUsage();
|
|
77
168
|
const cur = u[n] || { runCount: 0, lastUsed: "" };
|
|
78
169
|
if (!cur.createdAt) cur.createdAt = new Date().toISOString();
|
|
@@ -152,7 +243,7 @@ function uncomment(line) {
|
|
|
152
243
|
// script is only a tool if the block contains an "open-claudia-tool: <name>"
|
|
153
244
|
// line.
|
|
154
245
|
function parseHeader(content) {
|
|
155
|
-
const out = { name: "", description: "", pack: "", requires: [], usage: "" };
|
|
246
|
+
const out = { name: "", description: "", pack: "", requires: [], usage: "", risk: "" };
|
|
156
247
|
const lines = String(content || "").split("\n");
|
|
157
248
|
let started = false;
|
|
158
249
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -171,40 +262,59 @@ function parseHeader(content) {
|
|
|
171
262
|
const val = kv[2].trim();
|
|
172
263
|
if (key === MARKER) out.name = sanitizeName(val);
|
|
173
264
|
else if (key === "requires") out.requires = val.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
|
|
265
|
+
else if (key === "risk") out.risk = normalizeRisk(val);
|
|
174
266
|
else if (HEADER_KEYS.includes(key)) out[key] = val;
|
|
175
267
|
}
|
|
176
268
|
return out.name ? out : null;
|
|
177
269
|
}
|
|
178
270
|
|
|
271
|
+
// Path of a tool's executable (legacy signature kept: several callers treat
|
|
272
|
+
// "the tool file" as the thing you run/edit).
|
|
179
273
|
function toolFile(name) {
|
|
180
|
-
return
|
|
274
|
+
return toolPaths(name).exec;
|
|
181
275
|
}
|
|
182
276
|
|
|
183
277
|
function readTool(name, usageMap) {
|
|
184
|
-
const
|
|
278
|
+
const p = toolPaths(name);
|
|
185
279
|
let content;
|
|
186
|
-
try { content = fs.readFileSync(
|
|
280
|
+
try { content = fs.readFileSync(p.exec, "utf-8"); } catch (e) { return null; }
|
|
187
281
|
const header = parseHeader(content);
|
|
188
282
|
if (!header) return null;
|
|
189
283
|
let stat = null;
|
|
190
|
-
try { stat = fs.statSync(
|
|
191
|
-
const
|
|
284
|
+
try { stat = fs.statSync(p.exec); } catch (e) {}
|
|
285
|
+
const telemetry = p.layout === "dir"
|
|
286
|
+
? readState(header.name || p.name)
|
|
287
|
+
: (usageMap || readUsage())[header.name || p.name] || {};
|
|
192
288
|
return {
|
|
193
|
-
name: header.name ||
|
|
289
|
+
name: header.name || p.name,
|
|
194
290
|
description: header.description || "",
|
|
195
291
|
pack: header.pack || "",
|
|
196
292
|
requires: header.requires || [],
|
|
197
293
|
usage: header.usage || "",
|
|
198
|
-
|
|
294
|
+
risk: header.risk || "",
|
|
295
|
+
layout: p.layout,
|
|
296
|
+
file: p.exec,
|
|
297
|
+
docFile: p.doc,
|
|
199
298
|
executable: stat ? !!(stat.mode & 0o111) : false,
|
|
200
299
|
updatedAt: stat ? stat.mtime.toISOString() : "",
|
|
201
|
-
createdAt:
|
|
202
|
-
runCount:
|
|
203
|
-
lastUsed:
|
|
300
|
+
createdAt: telemetry.createdAt || "",
|
|
301
|
+
runCount: telemetry.runCount || 0,
|
|
302
|
+
lastUsed: telemetry.lastUsed || "",
|
|
303
|
+
lastExit: telemetry.lastExit,
|
|
304
|
+
verbs: telemetry.verbs || null,
|
|
305
|
+
history: telemetry.history || null,
|
|
204
306
|
content,
|
|
205
307
|
};
|
|
206
308
|
}
|
|
207
309
|
|
|
310
|
+
// A tool's TOOL.md prose (Interface · Known issues · State · Journal), "" when
|
|
311
|
+
// absent — loaded on demand (tool show), never in the always-on index.
|
|
312
|
+
function readToolDoc(name) {
|
|
313
|
+
const p = toolPaths(name);
|
|
314
|
+
if (!p.doc) return "";
|
|
315
|
+
try { return fs.readFileSync(p.doc, "utf-8"); } catch (e) { return ""; }
|
|
316
|
+
}
|
|
317
|
+
|
|
208
318
|
function listTools() {
|
|
209
319
|
let entries;
|
|
210
320
|
try { entries = fs.readdirSync(TOOLS_DIR); } catch (e) { return []; }
|
|
@@ -212,8 +322,11 @@ function listTools() {
|
|
|
212
322
|
const tools = [];
|
|
213
323
|
for (const name of entries) {
|
|
214
324
|
if (name.startsWith(".")) continue;
|
|
215
|
-
|
|
216
|
-
|
|
325
|
+
const full = path.join(TOOLS_DIR, name);
|
|
326
|
+
let st;
|
|
327
|
+
try { st = fs.statSync(full); } catch (e) { continue; }
|
|
328
|
+
if (st.isDirectory() && !fs.existsSync(path.join(full, "tool"))) continue; // junk dir, not a tool
|
|
329
|
+
if (!st.isDirectory() && !st.isFile()) continue;
|
|
217
330
|
const t = readTool(name, usage);
|
|
218
331
|
if (t) tools.push(t);
|
|
219
332
|
}
|
|
@@ -229,10 +342,11 @@ function findTool(name) {
|
|
|
229
342
|
|
|
230
343
|
// Build the header comment block for a script that doesn't already declare one.
|
|
231
344
|
// Uses the comment prefix that matches the script's shebang/extension.
|
|
232
|
-
function buildHeader(prefix, { name, description, pack, requires, usage }) {
|
|
345
|
+
function buildHeader(prefix, { name, description, pack, requires, usage, risk }) {
|
|
233
346
|
const lines = [`${prefix} ${MARKER}: ${name}`];
|
|
234
347
|
if (description) lines.push(`${prefix} description: ${description}`);
|
|
235
348
|
if (pack) lines.push(`${prefix} pack: ${pack}`);
|
|
349
|
+
if (risk) lines.push(`${prefix} risk: ${risk}`);
|
|
236
350
|
if (requires && requires.length) lines.push(`${prefix} requires: ${[].concat(requires).join(", ")}`);
|
|
237
351
|
if (usage) lines.push(`${prefix} usage: ${usage}`);
|
|
238
352
|
return lines.join("\n");
|
|
@@ -320,26 +434,47 @@ function checkSyntax(file, content) {
|
|
|
320
434
|
} catch (e) { return null; }
|
|
321
435
|
}
|
|
322
436
|
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
437
|
+
// Refuse content carrying a hardcoded credential. The tools repo auto-commits,
|
|
438
|
+
// and git history is forever — so unlike the advisory lints, secrets BLOCK.
|
|
439
|
+
function assertNoSecrets(content, what) {
|
|
440
|
+
const hits = [];
|
|
441
|
+
for (const [re, label] of SECRET_PATTERNS) if (re.test(String(content || ""))) hits.push(label);
|
|
442
|
+
if (hits.length) {
|
|
443
|
+
throw new Error(`${what || "content"} contains hardcoded secret(s): ${hits.join(", ")}. ` +
|
|
444
|
+
"Reference credentials as $KEYRING_VAR (declare via --requires) — refusing to save into the git-tracked tools repo.");
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Register a script as a tool. New tools land in the directory layout (tool +
|
|
449
|
+
// TOOL.md + state.json); re-adding over a legacy flat file upgrades it. The
|
|
450
|
+
// source must carry a header (synthesised from opts if absent), gets a
|
|
451
|
+
// fail-safe `risk: write` tier when none is declared, is refused if it embeds
|
|
452
|
+
// a secret, and the save is git-committed. Returns the stored tool.
|
|
326
453
|
function addTool(srcPath, opts = {}) {
|
|
327
454
|
ensureDir();
|
|
328
455
|
let content;
|
|
329
456
|
try { content = fs.readFileSync(srcPath, "utf-8"); }
|
|
330
457
|
catch (e) { throw new Error(`cannot read ${srcPath}: ${e.message}`); }
|
|
331
458
|
|
|
459
|
+
assertNoSecrets(content, path.basename(srcPath));
|
|
460
|
+
|
|
332
461
|
let header = parseHeader(content);
|
|
333
462
|
const name = sanitizeName(opts.name || (header && header.name) || path.basename(srcPath).replace(/\.[^.]+$/, ""));
|
|
334
463
|
if (!name) throw new Error("tool needs a name (pass --name or add an 'open-claudia-tool:' header line)");
|
|
335
464
|
|
|
465
|
+
const optRisk = normalizeRisk(opts.risk);
|
|
466
|
+
let defaultedRisk = false;
|
|
467
|
+
|
|
336
468
|
if (!header) {
|
|
337
469
|
// No header in the source — synthesise one so the tool is self-documenting.
|
|
338
470
|
const prefix = commentPrefixFor(content, srcPath);
|
|
471
|
+
let risk = optRisk;
|
|
472
|
+
if (!risk) { risk = "write"; defaultedRisk = true; } // fail-safe: ungated only when declared read-only
|
|
339
473
|
const block = buildHeader(prefix, {
|
|
340
474
|
name,
|
|
341
475
|
description: opts.description || "",
|
|
342
476
|
pack: opts.pack || "",
|
|
477
|
+
risk,
|
|
343
478
|
requires: opts.requires || [],
|
|
344
479
|
usage: opts.usage || "",
|
|
345
480
|
});
|
|
@@ -351,26 +486,59 @@ function addTool(srcPath, opts = {}) {
|
|
|
351
486
|
content = block + "\n" + content;
|
|
352
487
|
}
|
|
353
488
|
header = parseHeader(content);
|
|
354
|
-
} else
|
|
355
|
-
// Source had a header but no pack link and the caller supplied one — add it.
|
|
489
|
+
} else {
|
|
356
490
|
const prefix = commentPrefixFor(content, srcPath);
|
|
357
|
-
|
|
358
|
-
new RegExp(`((#|//)\\s*${MARKER}:.*\\n)`),
|
|
359
|
-
|
|
360
|
-
);
|
|
491
|
+
const injectAfterMarker = (line) => {
|
|
492
|
+
content = content.replace(new RegExp(`((#|//)\\s*${MARKER}:.*\\n)`), `$1${line}\n`);
|
|
493
|
+
};
|
|
494
|
+
if (opts.pack && !header.pack) injectAfterMarker(`${prefix} pack: ${opts.pack}`);
|
|
495
|
+
if (!header.risk) {
|
|
496
|
+
const risk = optRisk || "write";
|
|
497
|
+
if (!optRisk) defaultedRisk = true;
|
|
498
|
+
injectAfterMarker(`${prefix} risk: ${risk}`);
|
|
499
|
+
}
|
|
500
|
+
header = parseHeader(content);
|
|
361
501
|
}
|
|
362
502
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
503
|
+
// Upgrade a legacy flat file in place: the new content replaces it in the
|
|
504
|
+
// directory layout, carrying the old sidecar telemetry across.
|
|
505
|
+
const pre = toolPaths(name);
|
|
506
|
+
if (pre.layout === "file") {
|
|
507
|
+
const u = readUsage();
|
|
508
|
+
const old = u[name] || {};
|
|
509
|
+
try { fs.rmSync(pre.exec, { force: true }); } catch (e) {}
|
|
510
|
+
delete u[name];
|
|
511
|
+
writeUsage(u);
|
|
512
|
+
fs.mkdirSync(path.join(TOOLS_DIR, name), { recursive: true, mode: 0o700 });
|
|
513
|
+
writeState(name, { createdAt: old.createdAt || new Date().toISOString(), lastUsed: old.lastUsed || "", runCount: old.runCount || 0 });
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const p = toolPaths(name);
|
|
517
|
+
fs.mkdirSync(path.dirname(p.exec), { recursive: true, mode: 0o700 });
|
|
518
|
+
fs.writeFileSync(p.exec, content, { mode: 0o700 });
|
|
519
|
+
try { fs.chmodSync(p.exec, 0o700); } catch (e) {}
|
|
520
|
+
if (p.doc && !fs.existsSync(p.doc)) {
|
|
521
|
+
try {
|
|
522
|
+
const repo = require("./tool-repo");
|
|
523
|
+
fs.writeFileSync(p.doc, repo.toolMdSkeleton({ ...header, journalLine: "Registered via tool add." }), { mode: 0o600 });
|
|
524
|
+
} catch (e) { /* skeleton optional */ }
|
|
525
|
+
}
|
|
366
526
|
recordCreate(name);
|
|
367
|
-
|
|
527
|
+
try { require("./tool-repo").commitTool(name, `${pre.layout === "new" ? "add" : "update"} ${name}`); } catch (e) {}
|
|
528
|
+
const stored = readTool(name);
|
|
529
|
+
if (stored) stored.defaultedRisk = defaultedRisk;
|
|
530
|
+
return stored;
|
|
368
531
|
}
|
|
369
532
|
|
|
370
533
|
function removeTool(name) {
|
|
371
534
|
const tool = findTool(name);
|
|
372
535
|
if (!tool) return null;
|
|
373
|
-
|
|
536
|
+
const p = toolPaths(name);
|
|
537
|
+
try {
|
|
538
|
+
if (p.layout === "dir") fs.rmSync(path.join(TOOLS_DIR, p.name), { recursive: true, force: true });
|
|
539
|
+
else fs.rmSync(p.exec, { force: true });
|
|
540
|
+
} catch (e) { return null; }
|
|
541
|
+
try { require("./tool-repo").commitTool(p.name, `remove ${p.name}`); } catch (e) {}
|
|
374
542
|
return tool;
|
|
375
543
|
}
|
|
376
544
|
|
|
@@ -387,6 +555,11 @@ function updateToolHeader(name, opts = {}) {
|
|
|
387
555
|
const description = opts.description !== undefined ? opts.description : t.description;
|
|
388
556
|
const pack = opts.pack !== undefined ? opts.pack : t.pack;
|
|
389
557
|
const usage = opts.usage !== undefined ? opts.usage : t.usage;
|
|
558
|
+
let risk = t.risk;
|
|
559
|
+
if (opts.risk !== undefined) {
|
|
560
|
+
risk = normalizeRisk(opts.risk);
|
|
561
|
+
if (!risk) throw new Error(`invalid risk tier "${opts.risk}" — use one of: ${RISK_TIERS.join(", ")}`);
|
|
562
|
+
}
|
|
390
563
|
let requires = opts.requires !== undefined ? opts.requires : t.requires;
|
|
391
564
|
if (!Array.isArray(requires)) requires = String(requires || "").split(/[,\s]+/).filter(Boolean);
|
|
392
565
|
|
|
@@ -412,6 +585,7 @@ function updateToolHeader(name, opts = {}) {
|
|
|
412
585
|
const fieldLines = [];
|
|
413
586
|
if (description) fieldLines.push(`${prefix} description: ${description}`);
|
|
414
587
|
if (pack) fieldLines.push(`${prefix} pack: ${pack}`);
|
|
588
|
+
if (risk) fieldLines.push(`${prefix} risk: ${risk}`);
|
|
415
589
|
if (requires.length) fieldLines.push(`${prefix} requires: ${requires.join(", ")}`);
|
|
416
590
|
if (usage) fieldLines.push(`${prefix} usage: ${usage}`);
|
|
417
591
|
|
|
@@ -420,11 +594,40 @@ function updateToolHeader(name, opts = {}) {
|
|
|
420
594
|
else newBlock = [`${prefix} ${MARKER}: ${t.name}`, ...fieldLines, ...kept];
|
|
421
595
|
|
|
422
596
|
const next = [...lines.slice(0, headStart), ...newBlock, ...lines.slice(blockEnd)].join("\n");
|
|
597
|
+
assertNoSecrets(next, `tool ${t.name}`);
|
|
423
598
|
fs.writeFileSync(t.file, next, { mode: 0o700 });
|
|
424
599
|
try { fs.chmodSync(t.file, 0o700); } catch (e) {}
|
|
600
|
+
try { require("./tool-repo").commitTool(t.name, `set ${t.name} metadata`); } catch (e) {}
|
|
425
601
|
return readTool(t.name);
|
|
426
602
|
}
|
|
427
603
|
|
|
604
|
+
// ── Risk-tier run gate ───────────────────────────────────────────────────────
|
|
605
|
+
// Mechanises ask-first: read-only runs freely; write-tier requires --yes;
|
|
606
|
+
// destructive-tier requires --yes-destructive (a plain --yes habit must not
|
|
607
|
+
// unlock it) and gets a spelled-out warning. An undeclared tier is treated as
|
|
608
|
+
// write — fail-safe until classified. The flag is the agent's deliberate
|
|
609
|
+
// confirmation step AFTER user approval; it never replaces asking the user.
|
|
610
|
+
function runGate(tool, argv = []) {
|
|
611
|
+
const declared = normalizeRisk(tool && tool.risk);
|
|
612
|
+
const tier = declared || "write";
|
|
613
|
+
const note = declared ? "" : ` (no risk tier declared — treated as write; classify: open-claudia tool set ${tool.name} --risk read-only|write|destructive)`;
|
|
614
|
+
if (tier === "read-only") return { ok: true, tier };
|
|
615
|
+
if (tier === "destructive") {
|
|
616
|
+
if (argv.includes("--yes-destructive")) return { ok: true, tier };
|
|
617
|
+
return {
|
|
618
|
+
ok: false, tier,
|
|
619
|
+
message: `⛔ DESTRUCTIVE tool "${tool.name}" — this can delete or irreversibly change live state.\n` +
|
|
620
|
+
`Warn the user what exactly will happen and get explicit approval, then re-run with --yes-destructive.`,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
if (argv.includes("--yes")) return { ok: true, tier };
|
|
624
|
+
return {
|
|
625
|
+
ok: false, tier,
|
|
626
|
+
message: `⛔ "${tool.name}" is write-tier${note} — it changes live system state.\n` +
|
|
627
|
+
`Confirm the action with the user first, then re-run with --yes.`,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
428
631
|
// Env for running a tool: the bot's standard subprocess env (PATH + keyring
|
|
429
632
|
// creds merged) so tools are pre-authed the same way the agent is. Falls back to
|
|
430
633
|
// a plain keyring merge if config isn't importable (e.g. standalone test).
|
|
@@ -448,20 +651,26 @@ function missingRequires(tool) {
|
|
|
448
651
|
return tool.requires.filter((k) => !present.has(k) && process.env[k] === undefined);
|
|
449
652
|
}
|
|
450
653
|
|
|
451
|
-
// Recognise a Write/Edit aimed at a tool
|
|
654
|
+
// Recognise a Write/Edit aimed at a tool (for chat announcements). Legacy flat
|
|
655
|
+
// file (tools/<name>) or the directory layout's agent-editable members
|
|
656
|
+
// (tools/<name>/tool, tools/<name>/TOOL.md). state.json is machine telemetry —
|
|
657
|
+
// hand edits to it are not a tool change, so it maps to null.
|
|
452
658
|
function toolNameFromPath(filePath) {
|
|
453
659
|
if (!filePath) return null;
|
|
454
660
|
const resolved = path.resolve(String(filePath));
|
|
455
661
|
const rel = path.relative(TOOLS_DIR, resolved);
|
|
456
662
|
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
457
|
-
|
|
458
|
-
return
|
|
663
|
+
const parts = rel.split(path.sep);
|
|
664
|
+
if (parts.length === 1) return parts[0] || null;
|
|
665
|
+
if (parts.length === 2 && (parts[1] === "tool" || parts[1] === "TOOL.md")) return parts[0];
|
|
666
|
+
return null;
|
|
459
667
|
}
|
|
460
668
|
|
|
461
669
|
module.exports = {
|
|
462
|
-
TOOLS_DIR, MARKER, USAGE_FILE,
|
|
463
|
-
parseHeader, readTool, listTools, findTool, addTool, removeTool, updateToolHeader,
|
|
464
|
-
runEnv, missingRequires, toolNameFromPath, sanitizeName, ensureDir,
|
|
670
|
+
TOOLS_DIR, MARKER, USAGE_FILE, RISK_TIERS,
|
|
671
|
+
parseHeader, readTool, readToolDoc, listTools, findTool, addTool, removeTool, updateToolHeader,
|
|
672
|
+
runEnv, missingRequires, toolNameFromPath, sanitizeName, ensureDir, normalizeRisk,
|
|
673
|
+
toolPaths, readState, writeState, recordRunResult, runGate,
|
|
465
674
|
matchTools, recordRun, recordCreate, readUsage,
|
|
466
|
-
lintToolSource, checkSyntax, envRefsIn,
|
|
675
|
+
lintToolSource, checkSyntax, envRefsIn, assertNoSecrets,
|
|
467
676
|
};
|
package/core/transcript-index.js
CHANGED
|
@@ -145,6 +145,26 @@ function rebuild(transcriptsDir = defaultTranscriptsDir()) {
|
|
|
145
145
|
return indexAll(transcriptsDir);
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
// Nightly upkeep: catch up new appends, then drop index rows for transcript
|
|
149
|
+
// files that no longer exist on disk — dead sessions stop matching and the
|
|
150
|
+
// FTS table stays proportional to what is actually recallable.
|
|
151
|
+
function tend(transcriptsDir = defaultTranscriptsDir()) {
|
|
152
|
+
const db = open(transcriptsDir);
|
|
153
|
+
if (!db) return { available: false, added: 0, prunedFiles: 0 };
|
|
154
|
+
const { added } = indexAll(transcriptsDir);
|
|
155
|
+
let prunedFiles = 0;
|
|
156
|
+
try {
|
|
157
|
+
const rows = db.prepare("SELECT path FROM files").all();
|
|
158
|
+
for (const r of rows) {
|
|
159
|
+
if (fs.existsSync(r.path)) continue;
|
|
160
|
+
db.prepare("DELETE FROM entries WHERE file = ?").run(r.path);
|
|
161
|
+
db.prepare("DELETE FROM files WHERE path = ?").run(r.path);
|
|
162
|
+
prunedFiles++;
|
|
163
|
+
}
|
|
164
|
+
} catch (e) {}
|
|
165
|
+
return { available: true, added, prunedFiles };
|
|
166
|
+
}
|
|
167
|
+
|
|
148
168
|
// FTS5 MATCH chokes on unbalanced quotes/operators in natural queries, so
|
|
149
169
|
// by default each term is quoted (implicit AND). Pass raw=true for full
|
|
150
170
|
// FTS5 query syntax (NEAR, OR, prefix*).
|
|
@@ -184,4 +204,4 @@ function stats(transcriptsDir = defaultTranscriptsDir()) {
|
|
|
184
204
|
}
|
|
185
205
|
}
|
|
186
206
|
|
|
187
|
-
module.exports = { available, open, indexFile, indexAll, rebuild, search, stats, sanitizeQuery, defaultTranscriptsDir };
|
|
207
|
+
module.exports = { available, open, indexFile, indexAll, rebuild, tend, search, stats, sanitizeQuery, defaultTranscriptsDir };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
|
|
5
5
|
"main": "bot.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"setup": "node setup.js",
|
|
11
11
|
"start": "node bot.js",
|
|
12
|
-
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js"
|
|
12
|
+
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-evolution.js"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"bot.js",
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Discoverer engine: pre-gate, seed → (graph) → fail-open injection. The LLM
|
|
2
2
|
// walker is disabled here (RECALL_DISCOVERER_WALKER=off) so the engine takes
|
|
3
|
-
// its deterministic fail-open path:
|
|
3
|
+
// its deterministic fail-open path. Contract: the seeds tier keeps ALL
|
|
4
|
+
// user-origin keyword seeds (packs, entities, tools); the full tier without a
|
|
5
|
+
// judge keeps only the top-3 user-origin pack/entity seeds — an unjudged tool
|
|
6
|
+
// or episode is a guess, not a memory.
|
|
4
7
|
const assert = require("assert");
|
|
5
8
|
const fs = require("fs");
|
|
6
9
|
const os = require("os");
|
|
@@ -101,16 +104,28 @@ const helpers = { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEnti
|
|
|
101
104
|
});
|
|
102
105
|
assert.strictEqual(typeof out2.packBlock, "string");
|
|
103
106
|
|
|
104
|
-
// tool seeding: a
|
|
105
|
-
// renders
|
|
107
|
+
// tool seeding (seeds tier): a ≤4-word command matching a tool keeps it as a
|
|
108
|
+
// user-origin seed and renders the toolBlock; no tool match → empty toolBlock
|
|
106
109
|
const outTool = await disc.run({
|
|
107
|
-
userText: "audit
|
|
108
|
-
fullContext: "audit
|
|
110
|
+
userText: "audit mikrotik router fleet", contextText: "",
|
|
111
|
+
fullContext: "audit mikrotik router fleet", packLimit: 6, budget: {}, helpers,
|
|
109
112
|
});
|
|
110
|
-
assert.
|
|
113
|
+
assert.strictEqual(outTool.tier, "seeds", "4-word command lands on the seeds tier");
|
|
114
|
+
assert.ok(outTool.toolMatches.some((m) => m.name === "mikrotik-audit"), "matching tool is kept (seeds-tier user seed)");
|
|
111
115
|
assert.ok(/mikrotik-audit/.test(outTool.toolBlock), "kept tool renders into the toolBlock");
|
|
112
116
|
assert.ok(/Tools that may help here/.test(outTool.toolBlock), "toolBlock carries its heading");
|
|
113
117
|
|
|
118
|
+
// full-tier fail-open: with no judge, an unjudged TOOL is a guess — dropped.
|
|
119
|
+
// (Measured before the fix: a failed walk kept 15 unjudged seeds including
|
|
120
|
+
// WiFi tools for a dream question.) Pack/entity user seeds still inject.
|
|
121
|
+
const outToolFull = await disc.run({
|
|
122
|
+
userText: "audit the mikrotik router fleet inventory please", contextText: "",
|
|
123
|
+
fullContext: "audit the mikrotik router fleet inventory please", packLimit: 6, budget: {}, helpers,
|
|
124
|
+
});
|
|
125
|
+
assert.strictEqual(outToolFull.tier, "full", "substantive ask lands on the full tier");
|
|
126
|
+
assert.strictEqual(outToolFull.toolMatches.length, 0, "fail-open drops unjudged tools at full tier");
|
|
127
|
+
assert.strictEqual(outToolFull.toolBlock, "", "no unjudged toolBlock at full tier");
|
|
128
|
+
|
|
114
129
|
const outNoTool = await disc.run({
|
|
115
130
|
userText: "help with the mobile app", contextText: "", fullContext: "help with the mobile app",
|
|
116
131
|
packLimit: 6, budget: {}, helpers,
|