@inetafrica/open-claudia 2.6.48 → 2.6.50
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 +3 -0
- package/bin/cli.js +6 -0
- package/bin/pack.js +10 -0
- package/bin/tool.js +148 -0
- package/core/dream.js +23 -2
- package/core/handlers.js +2 -1
- package/core/runner.js +116 -15
- package/core/system-prompt.js +49 -1
- package/core/tool-graph.js +217 -0
- package/core/tools.js +227 -0
- package/package.json +5 -3
- package/test-tool-graph.js +97 -0
- package/test-tools.js +92 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.6.49
|
|
4
|
+
- **Reusable tools + a directed tool-usage graph.** Two linked features. First, **reusable tools** (`core/tools.js`, `bin/tool.js`): the executable sibling of context packs — when the agent works out an operational procedure (hit an API, drive an interface, transform a file) it crystallises the script into a re-runnable command instead of a throwaway heredoc. Tools are one executable file with a parseable comment header, run pre-authed (the operational keyring is merged into their env so they reference `$NAME` and never hardcode a secret), can link to an owning skill pack, and are surfaced every turn as an always-on index (full docs/source on demand via `open-claudia tool show <name>`). Full CLI: `open-claudia tool list|show|add|run|remove`; `/learn` now crystallises the executable part too. Second, a **directed tool-graph** (`core/tool-graph.js`): a "tool B follows tool A" graph kept deliberately separate from the recall graph — for tools the useful signal is the *order* a chain runs in (auth → list → download), so edges are stored and traversed directionally. Each run-sequence reinforces consecutive pairs (Hebbian, decayed nightly, no structural floor so unused chains prune away). `tool show` and the system-prompt index surface "usually followed by …"; `pack show` gains the reverse view (a pack's linked tools); the nightly dream tends the graph (decay + orphan prune) and flags tools whose `--pack` link dangles. Adds `test-tools.js` + `test-tool-graph.js`.
|
|
5
|
+
|
|
3
6
|
## v2.6.43
|
|
4
7
|
- **Fix the `🧠 Recall this turn` banner rendering raw HTML.** The recall debug banner (from `/recall on`) is built with `<b>` tags and HTML-escaped names, but its two `send()` calls in `runner.js` omitted the Telegram `parseMode: "HTML"` opts that every normal reply already passes via `telegramHtmlOpts()` — so the tags and `&` entities showed up literally in chat instead of rendering. Both recall sends now pass `telegramHtmlOpts()`, matching the rest of the reply path. Display-only; no change to recall behaviour itself.
|
|
5
8
|
|
package/bin/cli.js
CHANGED
|
@@ -284,6 +284,11 @@ switch (command) {
|
|
|
284
284
|
break;
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
case "tool": {
|
|
288
|
+
require("./tool").run(args.slice(1));
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
|
|
287
292
|
case "entity": {
|
|
288
293
|
require("./entity").run(args.slice(1));
|
|
289
294
|
break;
|
|
@@ -365,6 +370,7 @@ Memory tools:
|
|
|
365
370
|
open-claudia transcript-window <pattern> Search project transcript, show hits with context
|
|
366
371
|
(alias: tw; --help for options)
|
|
367
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)
|
|
368
374
|
open-claudia entity list|show|match|note Entity notes: people/places/projects memory
|
|
369
375
|
open-claudia lessons list|add|remove|show Always-loaded learned rules (cross-cutting, promoted after a miss)
|
|
370
376
|
open-claudia ideas list|add|remove|show Self-improvement backlog (captured by the nightly dream)
|
package/bin/pack.js
CHANGED
|
@@ -102,6 +102,16 @@ function run(args) {
|
|
|
102
102
|
const prov = packs.readProvenance(p.dir);
|
|
103
103
|
const authored = packs.SECTIONS.map((s) => `${s}=${prov.sections[s] || "user"}`).join(" ");
|
|
104
104
|
console.log(`\n# provenance: ${authored}${prov.lastWriter ? ` (last: ${prov.lastWriter} ${prov.ts})` : ""}`);
|
|
105
|
+
// Reverse view: the runnable siblings of this pack's prose. A pack's
|
|
106
|
+
// Procedure documents *how*; a linked tool *is* the how. Surfacing them
|
|
107
|
+
// here keeps the doc and its crystallised commands discoverable together.
|
|
108
|
+
try {
|
|
109
|
+
const linked = require("../core/tools").listTools().filter((t) => t.pack === p.dir);
|
|
110
|
+
if (linked.length) {
|
|
111
|
+
console.log(`\n# tools (${linked.length}) — run with 'open-claudia tool run <name>':`);
|
|
112
|
+
for (const t of linked) console.log(`# ${t.name} — ${t.description || "(no description)"}`);
|
|
113
|
+
}
|
|
114
|
+
} catch (e) { /* tools optional */ }
|
|
105
115
|
break;
|
|
106
116
|
}
|
|
107
117
|
|
package/bin/tool.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// CLI: manage and run reusable tools — executable scripts the agent saves so a
|
|
2
|
+
// procedure it worked out once becomes a re-runnable command, not a re-typed
|
|
3
|
+
// heredoc. The executable sibling of context packs (`open-claudia pack`).
|
|
4
|
+
//
|
|
5
|
+
// open-claudia tool list — index (name — description [skill])
|
|
6
|
+
// open-claudia tool show <name> — full docs, path, keyring status
|
|
7
|
+
// open-claudia tool add <path> [--name n] — register a script as a tool
|
|
8
|
+
// [--pack <dir>] [--desc "..."] [--requires "k1,k2"] [--usage "..."]
|
|
9
|
+
// open-claudia tool run <name> [args...] — run it with keyring pre-loaded
|
|
10
|
+
// open-claudia tool remove <name> — delete one
|
|
11
|
+
// open-claudia tool path — print the tools directory
|
|
12
|
+
//
|
|
13
|
+
// Credentials: a tool runs with the operational keyring merged into its env, so
|
|
14
|
+
// reference creds as $NAME inside the script. `open-claudia keyring list` shows
|
|
15
|
+
// what's available; never hardcode secrets in a tool.
|
|
16
|
+
|
|
17
|
+
const { spawnSync } = require("child_process");
|
|
18
|
+
const tools = require("../core/tools");
|
|
19
|
+
|
|
20
|
+
// Minimal flag parser: pulls --key value (and --key=value) out of argv, returns
|
|
21
|
+
// { positional, flags }.
|
|
22
|
+
function parseFlags(argv) {
|
|
23
|
+
const positional = [];
|
|
24
|
+
const flags = {};
|
|
25
|
+
for (let i = 0; i < argv.length; i++) {
|
|
26
|
+
const a = argv[i];
|
|
27
|
+
if (a.startsWith("--")) {
|
|
28
|
+
const eq = a.indexOf("=");
|
|
29
|
+
if (eq >= 0) { flags[a.slice(2, eq)] = a.slice(eq + 1); }
|
|
30
|
+
else { flags[a.slice(2)] = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : true; }
|
|
31
|
+
} else positional.push(a);
|
|
32
|
+
}
|
|
33
|
+
return { positional, flags };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function run(args) {
|
|
37
|
+
const cmd = (args[0] || "list").toLowerCase();
|
|
38
|
+
const rest = args.slice(1);
|
|
39
|
+
|
|
40
|
+
switch (cmd) {
|
|
41
|
+
case "list":
|
|
42
|
+
case "ls": {
|
|
43
|
+
const all = tools.listTools();
|
|
44
|
+
if (all.length === 0) {
|
|
45
|
+
return console.log(`No tools yet (${tools.TOOLS_DIR}).\nSave one with: open-claudia tool add <script-path> --pack <skill>`);
|
|
46
|
+
}
|
|
47
|
+
console.log(`${all.length} reusable tool(s) — run via 'open-claudia tool run <name>':\n`);
|
|
48
|
+
for (const t of all) {
|
|
49
|
+
const skill = t.pack ? ` [skill: ${t.pack}]` : "";
|
|
50
|
+
console.log(`• ${t.name} — ${t.description || "(no description)"}${skill}`);
|
|
51
|
+
}
|
|
52
|
+
console.log(`\nDocs: open-claudia tool show <name> · Dir: ${tools.TOOLS_DIR}`);
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
case "show":
|
|
57
|
+
case "cat": {
|
|
58
|
+
const name = rest[0];
|
|
59
|
+
if (!name) { console.error("Usage: tool show <name>"); process.exitCode = 1; return; }
|
|
60
|
+
const t = tools.findTool(name);
|
|
61
|
+
if (!t) { console.error(`No tool named "${name}". Run: open-claudia tool list`); process.exitCode = 1; return; }
|
|
62
|
+
console.log(`Tool: ${t.name}`);
|
|
63
|
+
if (t.description) console.log(`Description: ${t.description}`);
|
|
64
|
+
if (t.pack) console.log(`Skill pack: ${t.pack} (open-claudia pack show ${t.pack})`);
|
|
65
|
+
console.log(`Usage: open-claudia tool run ${t.name}${t.usage ? " (" + t.usage + ")" : ""}`);
|
|
66
|
+
if (t.requires.length) {
|
|
67
|
+
const missing = tools.missingRequires(t);
|
|
68
|
+
const status = missing.length ? `MISSING: ${missing.join(", ")} — set with 'open-claudia keyring set'` : "all present in keyring";
|
|
69
|
+
console.log(`Requires keyring keys: ${t.requires.join(", ")} (${status})`);
|
|
70
|
+
}
|
|
71
|
+
console.log(`File: ${t.file}${t.executable ? "" : " (not executable!)"}`);
|
|
72
|
+
// Directed tool-graph: what tends to run right after / before this one.
|
|
73
|
+
try {
|
|
74
|
+
const graph = require("../core/tool-graph");
|
|
75
|
+
const fmt = (rows) => rows.map((r) => `${r.name} (${r.weight.toFixed(1)})`).join(", ");
|
|
76
|
+
const after = graph.followers(t.name);
|
|
77
|
+
const before = graph.predecessors(t.name);
|
|
78
|
+
if (after.length) console.log(`Often followed by: ${fmt(after)}`);
|
|
79
|
+
if (before.length) console.log(`Often preceded by: ${fmt(before)}`);
|
|
80
|
+
} catch (e) { /* graph optional (old node) */ }
|
|
81
|
+
console.log(`\n----- source -----\n${t.content}`);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
case "add":
|
|
86
|
+
case "save": {
|
|
87
|
+
const { positional, flags } = parseFlags(rest);
|
|
88
|
+
const srcPath = positional[0];
|
|
89
|
+
if (!srcPath) {
|
|
90
|
+
console.error('Usage: tool add <path> [--name n] [--pack dir] [--desc "..."] [--requires "k1,k2"] [--usage "..."]');
|
|
91
|
+
process.exitCode = 1; return;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const t = tools.addTool(srcPath, {
|
|
95
|
+
name: typeof flags.name === "string" ? flags.name : undefined,
|
|
96
|
+
pack: typeof flags.pack === "string" ? flags.pack : undefined,
|
|
97
|
+
description: typeof flags.desc === "string" ? flags.desc : (typeof flags.description === "string" ? flags.description : undefined),
|
|
98
|
+
requires: typeof flags.requires === "string" ? flags.requires.split(/[,\s]+/).filter(Boolean) : undefined,
|
|
99
|
+
usage: typeof flags.usage === "string" ? flags.usage : undefined,
|
|
100
|
+
});
|
|
101
|
+
console.log(`Saved tool "${t.name}"${t.pack ? ` (skill: ${t.pack})` : ""}.`);
|
|
102
|
+
console.log(`Run it: open-claudia tool run ${t.name}`);
|
|
103
|
+
if (t.requires.length) {
|
|
104
|
+
const missing = tools.missingRequires(t);
|
|
105
|
+
if (missing.length) console.log(`Note: missing keyring keys it needs: ${missing.join(", ")}`);
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {
|
|
108
|
+
console.error(`Could not add tool: ${e.message}`); process.exitCode = 1;
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
case "run":
|
|
114
|
+
case "exec": {
|
|
115
|
+
const name = rest[0];
|
|
116
|
+
if (!name) { console.error("Usage: tool run <name> [args...]"); process.exitCode = 1; return; }
|
|
117
|
+
const t = tools.findTool(name);
|
|
118
|
+
if (!t) { console.error(`No tool named "${name}". Run: open-claudia tool list`); process.exitCode = 1; return; }
|
|
119
|
+
const missing = tools.missingRequires(t);
|
|
120
|
+
if (missing.length) {
|
|
121
|
+
console.error(`Cannot run ${t.name}: missing keyring keys ${missing.join(", ")}. Set them with 'open-claudia keyring set <name> <value>'.`);
|
|
122
|
+
process.exitCode = 1; return;
|
|
123
|
+
}
|
|
124
|
+
const r = spawnSync(t.file, rest.slice(1), { stdio: "inherit", env: tools.runEnv() });
|
|
125
|
+
if (r.error) { console.error(`Failed to run ${t.name}: ${r.error.message}`); process.exitCode = 1; return; }
|
|
126
|
+
process.exitCode = typeof r.status === "number" ? r.status : 1;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
case "remove":
|
|
131
|
+
case "rm": {
|
|
132
|
+
const name = rest[0];
|
|
133
|
+
if (!name) { console.error("Usage: tool remove <name>"); process.exitCode = 1; return; }
|
|
134
|
+
const removed = tools.removeTool(name);
|
|
135
|
+
console.log(removed ? `Removed tool "${removed.name}".` : `No tool named "${name}".`);
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
case "path":
|
|
140
|
+
console.log(tools.TOOLS_DIR);
|
|
141
|
+
break;
|
|
142
|
+
|
|
143
|
+
default:
|
|
144
|
+
console.log('Usage: open-claudia tool [list | show <name> | add <path> [flags] | run <name> [args] | remove <name> | path]');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { run };
|
package/core/dream.js
CHANGED
|
@@ -681,6 +681,7 @@ function writeDreamReport(data) {
|
|
|
681
681
|
sec.push("", "### Proposed (not applied — DREAM_SELF_APPLY=off)", "```json", JSON.stringify(data.introspection.proposed, null, 2), "```");
|
|
682
682
|
}
|
|
683
683
|
if (data.graphNote) sec.push("", data.graphNote);
|
|
684
|
+
if (data.toolNote) sec.push("", data.toolNote);
|
|
684
685
|
if (data.staleNote) sec.push("", data.staleNote);
|
|
685
686
|
if (data.backupRoot) sec.push("", `Backups: ${data.backupRoot}`);
|
|
686
687
|
const block = sec.join("\n") + "\n\n---\n\n";
|
|
@@ -825,6 +826,25 @@ async function runDream({ trigger = "manual" } = {}) {
|
|
|
825
826
|
}
|
|
826
827
|
} catch (e) { /* graph is best-effort */ }
|
|
827
828
|
|
|
829
|
+
// Tool hygiene: tend the directed tool-graph (decay reinforced follows-edges,
|
|
830
|
+
// prune faded/orphaned ones) and flag tools whose --pack link now dangles.
|
|
831
|
+
// Read-only toward the tools themselves — a tool is never auto-deleted (that's
|
|
832
|
+
// destructive); we just surface the breakage for the user to re-link or remove.
|
|
833
|
+
let toolNote = "";
|
|
834
|
+
try {
|
|
835
|
+
const toolsLib = require("./tools");
|
|
836
|
+
const tg = require("./tool-graph").tend(toolsLib);
|
|
837
|
+
const bits = [];
|
|
838
|
+
if (tg.edges || tg.decayed || tg.pruned) {
|
|
839
|
+
bits.push(`🔧 Tool graph: ${tg.edges} edges / ${tg.nodes} nodes (decayed ${tg.decayed}, pruned ${tg.pruned}).`);
|
|
840
|
+
}
|
|
841
|
+
const dangling = toolsLib.listTools().filter((t) => t.pack && !packs.readPack(t.pack));
|
|
842
|
+
if (dangling.length) {
|
|
843
|
+
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
|
+
toolNote = bits.join("\n");
|
|
846
|
+
} catch (e) { /* tool graph is best-effort (old node) */ }
|
|
847
|
+
|
|
828
848
|
const staleNote = staleTaskReport();
|
|
829
849
|
const dreamLines = consolidationLines.concat(introApplied);
|
|
830
850
|
|
|
@@ -832,7 +852,7 @@ async function runDream({ trigger = "manual" } = {}) {
|
|
|
832
852
|
model: DREAM_MODEL, effort: DREAM_EFFORT, trigger,
|
|
833
853
|
consolidation: { report: decision.report, lines: consolidationLines },
|
|
834
854
|
introspection: { report: introReport, lines: introApplied, proposed: introProposed },
|
|
835
|
-
graphNote, staleNote, backupRoot,
|
|
855
|
+
graphNote, toolNote, staleNote, backupRoot,
|
|
836
856
|
});
|
|
837
857
|
|
|
838
858
|
// Richer chat summary: consolidation + introspection narrative + what
|
|
@@ -846,12 +866,13 @@ async function runDream({ trigger = "manual" } = {}) {
|
|
|
846
866
|
if (n) parts.push(`📋 ${n} self-improvement change(s) staged for your OK (DREAM_SELF_APPLY=off) — see the log below.`);
|
|
847
867
|
}
|
|
848
868
|
if (graphNote) parts.push(graphNote);
|
|
869
|
+
if (toolNote) parts.push(toolNote);
|
|
849
870
|
if (staleNote) parts.push(staleNote);
|
|
850
871
|
if (dreamLines.length) parts.push(`🗄 Anything changed or merged away is backed up under ${backupRoot}`);
|
|
851
872
|
if (reportPath) parts.push(`📔 Full dream log: ${reportPath}`);
|
|
852
873
|
const message = parts.length ? parts.join("\n\n") : "";
|
|
853
874
|
|
|
854
|
-
return { applied: dreamLines, report, introReport, message, staleNote, graphNote, reportPath, trigger };
|
|
875
|
+
return { applied: dreamLines, report, introReport, message, staleNote, graphNote, toolNote, reportPath, trigger };
|
|
855
876
|
} finally {
|
|
856
877
|
_dreaming = false;
|
|
857
878
|
}
|
package/core/handlers.js
CHANGED
|
@@ -1217,7 +1217,8 @@ register({
|
|
|
1217
1217
|
`First check the already-injected packs and 'open-claudia pack list' for a pack this work belongs to; if one fits, fold the reusable how-to into that pack's Procedure section (exact commands in order, prerequisites, pitfalls) and append a dated Journal line — edit the PACK.md directly. ` +
|
|
1218
1218
|
`Only if no pack fits, create a new one: 'open-claudia pack' has no create subcommand, so write ~/.open-claudia/packs/<kebab-name>/PACK.md following the four-section format (Stance, Procedure, State, Journal) with name + a when-to-use description in the frontmatter. Give it an ACTIVITY-oriented name and description (what you DO — the verbs, tools, and artifacts) so it is findable from other projects by the work being done, not by a project name; and if this how-to came from a specific project, add a 'learned_on: <project-pack-dir>' frontmatter line so its cross-project reuse can be tracked. ` +
|
|
1219
1219
|
`Then mark that pack as a reusable skill so it is always surfaced in future conversations: run 'open-claudia pack skill <dir> on'. This flags it as a reusable ability and adds it to the always-on skill index (name + description); the Procedure still loads on demand. ` +
|
|
1220
|
-
`
|
|
1220
|
+
`CRUCIAL — also crystallise the EXECUTABLE part: if the work involved a script, API call, or any repeatable command sequence you wrote inline (a heredoc, a one-off .py/.sh, a curl chain), turn it into a reusable tool instead of leaving it to be re-typed. Write the script to a file (parameterise the bits that vary; read any credential from the env as $NAME rather than hardcoding), then register it with 'open-claudia tool add <path> --pack <dir> --desc "..." [--requires "key1,key2"] [--usage "..."]', linking it to the pack you just touched. Reference creds by their keyring names (see 'open-claudia keyring list'); never write a secret into the tool. The tool is the runnable form of the pack's Procedure — save both. ` +
|
|
1221
|
+
`No secrets or tokens in packs OR tools. When done, reply with one short line naming the pack you created or updated, any tool you saved, and what it covers. ` +
|
|
1221
1222
|
`If the recent work is genuinely not reusable, say so instead of forcing a skill.`;
|
|
1222
1223
|
await runClaude(prompt, currentState().currentSession.dir, env.messageId);
|
|
1223
1224
|
},
|
package/core/runner.js
CHANGED
|
@@ -27,6 +27,7 @@ const loopback = require("./loopback");
|
|
|
27
27
|
const skillsLib = require("./skills");
|
|
28
28
|
const packsLib = require("./packs");
|
|
29
29
|
const entitiesLib = require("./entities");
|
|
30
|
+
const toolsLib = require("./tools");
|
|
30
31
|
const packReview = require("./pack-review");
|
|
31
32
|
const { recallNodeFromPath } = require("./recall/read-signal");
|
|
32
33
|
const {
|
|
@@ -314,6 +315,15 @@ async function buildClaudeArgs(prompt, opts = {}) {
|
|
|
314
315
|
if (settings.permissionMode) args.push("--permission-mode", settings.permissionMode);
|
|
315
316
|
else args.push("--dangerously-skip-permissions");
|
|
316
317
|
if (settings.worktree) args.push("--worktree");
|
|
318
|
+
// Voice turns stream partial text so the spoken reply can start mid-generation
|
|
319
|
+
// (see the streaming-out handler in the runner). Strictly gated to the voice
|
|
320
|
+
// channel — zero behaviour change for Telegram/Kazee.
|
|
321
|
+
if (state.lastInputWasVoice) {
|
|
322
|
+
try {
|
|
323
|
+
const { currentTransport } = require("./context");
|
|
324
|
+
if (currentTransport() === "voice") args.push("--include-partial-messages");
|
|
325
|
+
} catch { /* context not ready — skip streaming flag */ }
|
|
326
|
+
}
|
|
317
327
|
// Dynamic state rides in the user prompt so the appended system prompt
|
|
318
328
|
// stays byte-stable across turns and the prompt-cache prefix survives.
|
|
319
329
|
args.push(await promptWithDynamicContext(prompt));
|
|
@@ -813,6 +823,50 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
813
823
|
state.statusMessageId = null;
|
|
814
824
|
state.streamBuffer = "";
|
|
815
825
|
let assistantText = "";
|
|
826
|
+
|
|
827
|
+
// Voice streaming-out: on voice turns we speak each finished sentence as it is
|
|
828
|
+
// generated (off the partial text_delta events) so the first audio plays while
|
|
829
|
+
// the rest of the reply is still being written — far lower time-to-first-sound
|
|
830
|
+
// than synthesizing one pass over the whole reply at the end. Reads the delta
|
|
831
|
+
// stream only; the text/transcript channel still reads whole-message events, so
|
|
832
|
+
// chat transports are completely unaffected.
|
|
833
|
+
let voiceStreaming = false;
|
|
834
|
+
try {
|
|
835
|
+
const { currentTransport } = require("./context");
|
|
836
|
+
voiceStreaming = !!state.lastInputWasVoice && currentTransport() === "voice";
|
|
837
|
+
} catch { voiceStreaming = false; }
|
|
838
|
+
let spokenBuf = ""; // text_delta accumulator awaiting a sentence boundary
|
|
839
|
+
let ttsChain = Promise.resolve(); // ordered send queue so clips play in order
|
|
840
|
+
let spokeAnyStreamed = false;
|
|
841
|
+
const SPOKEN_MIN_CHARS = 40; // don't fire TTS on tiny fragments ("Hi.")
|
|
842
|
+
function dispatchSpoken(text) {
|
|
843
|
+
const clean = redactSensitive(text);
|
|
844
|
+
if (!clean.trim()) return;
|
|
845
|
+
spokeAnyStreamed = true;
|
|
846
|
+
const synthP = synthSentenceMp3(clean); // start synth now (parallel)
|
|
847
|
+
ttsChain = ttsChain.then(async () => { // but send strictly in order
|
|
848
|
+
try { const clip = await synthP; if (clip) await sendVoice(clip); }
|
|
849
|
+
catch (e) { console.error("voice stream clip failed:", e.message); }
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
function pumpSpoken(flush) {
|
|
853
|
+
// Cut the smallest prefix that ends in a sentence terminator and is at least
|
|
854
|
+
// SPOKEN_MIN_CHARS long, dispatch it, repeat. On flush, send whatever remains.
|
|
855
|
+
while (true) {
|
|
856
|
+
const re = /[.!?]+(?=\s|$)/g;
|
|
857
|
+
let idx = -1, m;
|
|
858
|
+
while ((m = re.exec(spokenBuf)) !== null) {
|
|
859
|
+
const end = m.index + m[0].length;
|
|
860
|
+
if (end >= SPOKEN_MIN_CHARS) { idx = end; break; }
|
|
861
|
+
}
|
|
862
|
+
if (idx === -1) break;
|
|
863
|
+
const sentence = spokenBuf.slice(0, idx).trim();
|
|
864
|
+
spokenBuf = spokenBuf.slice(idx).replace(/^\s+/, "");
|
|
865
|
+
if (sentence) dispatchSpoken(sentence);
|
|
866
|
+
}
|
|
867
|
+
if (flush) { const tail = spokenBuf.trim(); spokenBuf = ""; if (tail) dispatchSpoken(tail); }
|
|
868
|
+
}
|
|
869
|
+
|
|
816
870
|
let toolUses = [];
|
|
817
871
|
let currentTool = null;
|
|
818
872
|
let currentToolDetail = "";
|
|
@@ -857,6 +911,12 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
857
911
|
else notifySkill(`entity:${entSlug}`, `👤 New entity noted: ${entSlug} — open-claudia entity show ${entSlug} to peek.`);
|
|
858
912
|
return;
|
|
859
913
|
}
|
|
914
|
+
const toolName2 = toolsLib.toolNameFromPath(filePath);
|
|
915
|
+
if (toolName2) {
|
|
916
|
+
if (toolsLib.findTool(toolName2)) notifySkill(`tool:${toolName2}`, `🔧 Updating tool: ${toolName2} — open-claudia tool show ${toolName2} to inspect.`);
|
|
917
|
+
else notifySkill(`tool:${toolName2}`, `🔧 New tool: ${toolName2} — open-claudia tool run ${toolName2} to use it.`);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
860
920
|
const dir = skillsLib.skillNameFromPath(filePath);
|
|
861
921
|
if (!dir) return;
|
|
862
922
|
// The tool_use event precedes the actual write, so existence now
|
|
@@ -874,6 +934,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
874
934
|
// Nodes the agent actually OPENED this turn (📖). This is the co-use signal
|
|
875
935
|
// the recall graph reinforces on — actually-read, not merely surfaced.
|
|
876
936
|
const openedThisTurn = new Set();
|
|
937
|
+
// Reusable tools the agent RAN this turn, in order. Feeds the directed
|
|
938
|
+
// tool-graph (core/tool-graph.js): consecutive runs become follows-edges so
|
|
939
|
+
// "you ran X — you usually run Y next" can be surfaced. Order matters here
|
|
940
|
+
// (unlike openedThisTurn, a Set), because a tool chain is a pipeline.
|
|
941
|
+
const toolRunsThisTurn = [];
|
|
877
942
|
const noteRecallFromShell = (command) => {
|
|
878
943
|
try {
|
|
879
944
|
const cmd = String(command || "");
|
|
@@ -895,6 +960,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
895
960
|
openedThisTurn.add(`entity:${slug}`);
|
|
896
961
|
notifySkill(`recall:entity:${slug}`, `📖 Recalled my notes on: ${name}`);
|
|
897
962
|
}
|
|
963
|
+
// `open-claudia tool run|exec <name>` — record the run for the directed
|
|
964
|
+
// tool-graph. Only the name is captured (args are noise for "what runs
|
|
965
|
+
// next"); the end-of-turn block reinforces consecutive pairs.
|
|
966
|
+
const toolRe = /\btool\s+(?:run|exec)\s+["']?([a-z0-9][\w.-]*)/gi;
|
|
967
|
+
while ((m = toolRe.exec(cmd))) toolRunsThisTurn.push(m[1]);
|
|
898
968
|
} catch (e) { /* announcements are best-effort */ }
|
|
899
969
|
};
|
|
900
970
|
|
|
@@ -1003,6 +1073,15 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1003
1073
|
const lastNewline = state.streamBuffer.lastIndexOf("\n");
|
|
1004
1074
|
state.streamBuffer = lastNewline >= 0 ? state.streamBuffer.slice(lastNewline + 1) : state.streamBuffer;
|
|
1005
1075
|
for (const evt of events) {
|
|
1076
|
+
// Voice streaming-out: speak finished sentences as the model writes them.
|
|
1077
|
+
// Only text_delta is spoken; thinking_delta and tool events are ignored.
|
|
1078
|
+
if (voiceStreaming && evt.type === "stream_event"
|
|
1079
|
+
&& evt.event?.type === "content_block_delta"
|
|
1080
|
+
&& evt.event.delta?.type === "text_delta"
|
|
1081
|
+
&& typeof evt.event.delta.text === "string") {
|
|
1082
|
+
spokenBuf += evt.event.delta.text;
|
|
1083
|
+
pumpSpoken(false);
|
|
1084
|
+
}
|
|
1006
1085
|
if (evt.type === "assistant" && evt.message?.usage) {
|
|
1007
1086
|
const callPrefix = usageParts(evt.message.usage, settings.backend || "claude").context;
|
|
1008
1087
|
if (callPrefix > peakContextTokens) peakContextTokens = callPrefix;
|
|
@@ -1195,25 +1274,37 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1195
1274
|
|
|
1196
1275
|
if (state.lastInputWasVoice) {
|
|
1197
1276
|
state.lastInputWasVoice = false;
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
let spokeAny = false;
|
|
1208
|
-
for (const sentence of sentences) {
|
|
1209
|
-
const clip = await synthSentenceMp3(sentence);
|
|
1210
|
-
if (clip) { spokeAny = true; await sendVoice(clip); }
|
|
1211
|
-
}
|
|
1212
|
-
if (!spokeAny) {
|
|
1277
|
+
if (voiceStreaming) {
|
|
1278
|
+
// Sentences were already being spoken as the model wrote them. Flush
|
|
1279
|
+
// the trailing partial sentence, wait for the ordered send queue to
|
|
1280
|
+
// drain, then close the turn so the client re-arms the mic.
|
|
1281
|
+
pumpSpoken(true);
|
|
1282
|
+
await ttsChain;
|
|
1283
|
+
if (!spokeAnyStreamed) {
|
|
1284
|
+
// Tool-only / empty turn produced no spoken text — say the final
|
|
1285
|
+
// text once so the user still hears a reply.
|
|
1213
1286
|
const voicePath = await textToVoice(finalText);
|
|
1214
1287
|
if (voicePath) await sendVoice(voicePath);
|
|
1215
1288
|
}
|
|
1216
1289
|
await sendVoiceEnd();
|
|
1290
|
+
} else {
|
|
1291
|
+
// Non-streamed fallback. Spoken replies belong to the hands-free voice
|
|
1292
|
+
// channel; on chat transports (Telegram/Kazee) an auto voice note on
|
|
1293
|
+
// every voice input is unwanted noise, so gate it to the voice channel.
|
|
1294
|
+
const { currentTransport } = require("./context");
|
|
1295
|
+
if (currentTransport() === "voice") {
|
|
1296
|
+
const sentences = splitSentences(finalText);
|
|
1297
|
+
let spokeAny = false;
|
|
1298
|
+
for (const sentence of sentences) {
|
|
1299
|
+
const clip = await synthSentenceMp3(sentence);
|
|
1300
|
+
if (clip) { spokeAny = true; await sendVoice(clip); }
|
|
1301
|
+
}
|
|
1302
|
+
if (!spokeAny) {
|
|
1303
|
+
const voicePath = await textToVoice(finalText);
|
|
1304
|
+
if (voicePath) await sendVoice(voicePath);
|
|
1305
|
+
}
|
|
1306
|
+
await sendVoiceEnd();
|
|
1307
|
+
}
|
|
1217
1308
|
}
|
|
1218
1309
|
}
|
|
1219
1310
|
} catch (e) {
|
|
@@ -1240,6 +1331,16 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1240
1331
|
} catch (e) { /* best-effort */ }
|
|
1241
1332
|
}
|
|
1242
1333
|
|
|
1334
|
+
// Directed tool-graph: a chain of reusable tools run in succession this turn
|
|
1335
|
+
// (auth → list → download) becomes follows-edges, so next time the first
|
|
1336
|
+
// runs we can surface "usually followed by …". Kept in a tool-specific graph
|
|
1337
|
+
// so it never bleeds into recall's spreading activation. Reinforce only on a
|
|
1338
|
+
// successful turn, and only when ≥2 ran (a single run has no succession).
|
|
1339
|
+
if (turnSucceeded && toolRunsThisTurn.length > 1) {
|
|
1340
|
+
try { require("./tool-graph").reinforceSequence(toolRunsThisTurn); }
|
|
1341
|
+
catch (e) { /* best-effort */ }
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1243
1344
|
// Close the learning loop: when an ABILITY pack is opened in the same turn
|
|
1244
1345
|
// as a project (context) pack, the ability was demonstrably applied while
|
|
1245
1346
|
// working on that project. recordCoUse grows the ability's applied_on, which
|
package/core/system-prompt.js
CHANGED
|
@@ -66,6 +66,53 @@ function buildSkillIndexBlock() {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Always-on tool index: the executable sibling of the skill index. Each entry
|
|
70
|
+
// is a real script the agent crystallised from a working procedure, surfaced by
|
|
71
|
+
// name+description every turn (progressive disclosure — full docs/source load on
|
|
72
|
+
// demand via `open-claudia tool show <name>`). Tools run pre-authed: the
|
|
73
|
+
// operational keyring is merged into their env, so a tool references a credential
|
|
74
|
+
// as $NAME and never hardcodes a secret.
|
|
75
|
+
function buildToolIndexBlock() {
|
|
76
|
+
let listing = "";
|
|
77
|
+
try {
|
|
78
|
+
const all = require("./tools").listTools();
|
|
79
|
+
if (all.length) {
|
|
80
|
+
// Directed tool-graph: surface the strongest "runs next" follower per tool
|
|
81
|
+
// so a known chain (auth → list → download) is visible inline. Optional —
|
|
82
|
+
// absent on old node where node:sqlite is missing.
|
|
83
|
+
let graph = null;
|
|
84
|
+
try { graph = require("./tool-graph"); } catch (e) {}
|
|
85
|
+
listing = "\nTools you already have (run with `open-claudia tool run <name> [args]`; read docs/source with `open-claudia tool show <name>`):\n\n" +
|
|
86
|
+
all.slice(0, 40)
|
|
87
|
+
.map((t) => {
|
|
88
|
+
const skill = t.pack ? ` [skill: ${t.pack}]` : "";
|
|
89
|
+
const needs = t.requires && t.requires.length ? ` (keyring: ${t.requires.join(", ")})` : "";
|
|
90
|
+
let next = "";
|
|
91
|
+
if (graph) {
|
|
92
|
+
try {
|
|
93
|
+
const after = graph.followers(t.name, 2).map((r) => r.name);
|
|
94
|
+
if (after.length) next = ` → usually followed by: ${after.join(", ")}`;
|
|
95
|
+
} catch (e) {}
|
|
96
|
+
}
|
|
97
|
+
return `- ${t.name} — ${t.description || "(no description)"}${needs}${skill}${next}`;
|
|
98
|
+
})
|
|
99
|
+
.join("\n") + "\n";
|
|
100
|
+
} else {
|
|
101
|
+
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
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
return `\n## Reusable tools (build skills as you work)
|
|
107
|
+
When you work out how to do something operational — hit an API, drive an interface, transform a file, run a multi-step flow — do NOT leave it as a throwaway heredoc. Crystallise it into a reusable tool so next time it's a single command, not a re-derivation. This is a core behaviour, not an optional extra: prefer saving a tool over re-typing a script you've written before.
|
|
108
|
+
|
|
109
|
+
- A tool is one executable file (any language; shebang picks the interpreter) with a parseable comment header. Save the script you just wrote with \`open-claudia tool add <path> --pack <skill-dir> --desc "..." [--requires "key1,key2"] [--usage "..."]\`. The \`--pack\` link ties it to the matching skill pack so the prose how-to and the runnable how-to stay together.
|
|
110
|
+
- Preauth: tools run with the operational keyring merged into their environment — and so does your OWN Bash, right now. Every operational credential is already present as an environment variable in your shell and in any tool you run; reference it as \`$inet_central_user\` etc. instead of asking for it or hardcoding it. Run \`open-claudia keyring list\` to see the exact names available. In a saved tool, declare what it needs via \`--requires\` so a missing key is reported before the run, and never write a secret into the file.
|
|
111
|
+
- Read a tool's docs and source on demand with \`open-claudia tool show <name>\` (don't guess its args). Full CLI: \`open-claudia tool list|show|add|run|remove\`.
|
|
112
|
+
- When a tool's behaviour changes or you improve it, update the saved tool rather than forking a new heredoc. Announce in one line when you create or change a tool, same as packs.
|
|
113
|
+
${listing}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
69
116
|
function buildSystemPrompt() {
|
|
70
117
|
const state = currentState();
|
|
71
118
|
const soul = loadSoul();
|
|
@@ -160,6 +207,7 @@ Open Claudia learned skills are stored as context packs under ${path.join(CONFIG
|
|
|
160
207
|
|
|
161
208
|
If the user asks for a skill by name, do not rely only on the backend harness's native "Available skills" list. First use any Active context pack injected into the current request as the requested Open Claudia skill. If no matching pack was injected, inspect with \`open-claudia pack list\` / \`open-claudia pack show <dir>\` and legacy \`/skills\` paths before saying the skill does not exist.
|
|
162
209
|
${buildSkillIndexBlock()}
|
|
210
|
+
${buildToolIndexBlock()}
|
|
163
211
|
|
|
164
212
|
## Stable Local Paths
|
|
165
213
|
- Bot code: ${path.join(BOT_DIR, "bot.js")}
|
|
@@ -232,7 +280,7 @@ Your durable knowledge lives in context packs: living per-topic documents (one p
|
|
|
232
280
|
|
|
233
281
|
Alongside packs you keep entity notes: one short file per named person, place, project, org, or system (Notes = current truth, Log = dated observations). Entities matching the incoming message are auto-injected like packs, and the same background reviewer maintains them. Inspect with \`open-claudia entity list\` / \`entity show <slug>\`; edit the files directly (announce in one line) when you learn something durable about someone or something. Same boundaries as packs.
|
|
234
282
|
|
|
235
|
-
"/learn" asks you to explicitly capture the most recent piece of work: fold
|
|
283
|
+
"/learn" asks you to explicitly capture the most recent piece of work: fold the prose how-to into the matching pack's Procedure section (create a pack only if none fits), AND crystallise any repeatable script into a reusable tool with \`open-claudia tool add ... --pack <dir>\` so the runnable form is saved alongside the prose. Legacy ~/.claude/skills still load if present, but new captures go to packs + tools.
|
|
236
284
|
|
|
237
285
|
A nightly "dream" pass consolidates memory on a stronger model: it merges duplicate packs, builds umbrella/parent pack trees, tightens descriptions and tags, dedupes entities, and may gently evolve your persona file. Anything merged away is backed up first, and every dream that changes something reports in chat. Trigger it manually with \`open-claudia dream\` (or \`--dry-run\` to preview the decision without applying).
|
|
238
286
|
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Directed tool-usage graph: "tool B often follows tool A". The executable
|
|
2
|
+
// counterpart to the recall graph (core/recall/graph.js), but deliberately
|
|
3
|
+
// SEPARATE and DIRECTED.
|
|
4
|
+
//
|
|
5
|
+
// Why separate: the recall graph spreads activation over packs+entities to pick
|
|
6
|
+
// which MEMORY to surface. Tools are a different corpus with different physics —
|
|
7
|
+
// the useful signal is the ORDER a chain runs in (auth → list → download), not
|
|
8
|
+
// symmetric "these are related". Mixing tool nodes into the recall graph would
|
|
9
|
+
// let a tool fire a pack headline (and vice-versa), which is noise. So tools get
|
|
10
|
+
// their own tiny graph.
|
|
11
|
+
//
|
|
12
|
+
// Why directed: A→B ("B follows A") carries real information that B→A does not.
|
|
13
|
+
// auth-then-fetch is a pipeline; fetch-then-auth is nonsense. Edges are stored
|
|
14
|
+
// and traversed directionally. Each edge carries a Hebbian `weight` bumped on
|
|
15
|
+
// co-use and decayed over time; unlike pack edges there is no structural floor,
|
|
16
|
+
// so a chain that stops being used eventually decays away and is pruned.
|
|
17
|
+
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
|
|
21
|
+
let DatabaseSync = null;
|
|
22
|
+
try { ({ DatabaseSync } = require("node:sqlite")); } catch (e) { /* old node — graph disabled */ }
|
|
23
|
+
|
|
24
|
+
const CONFIG_DIR = require("../config-dir");
|
|
25
|
+
const GRAPH_DB = process.env.TOOL_GRAPH_DB
|
|
26
|
+
? path.resolve(process.env.TOOL_GRAPH_DB)
|
|
27
|
+
: path.join(CONFIG_DIR, "tool-graph.db");
|
|
28
|
+
|
|
29
|
+
// Tunables (overridable via dream knobs later).
|
|
30
|
+
const DEFAULTS = {
|
|
31
|
+
halfLifeDays: 45, // Hebbian weight half-life for decay()
|
|
32
|
+
pruneBelow: 0.15, // edges decayed under this are dropped (no structural floor)
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let _db = null;
|
|
36
|
+
|
|
37
|
+
function available() { return !!DatabaseSync; }
|
|
38
|
+
|
|
39
|
+
function openDb() {
|
|
40
|
+
if (!DatabaseSync) return null;
|
|
41
|
+
if (_db) return _db;
|
|
42
|
+
try {
|
|
43
|
+
fs.mkdirSync(path.dirname(GRAPH_DB), { recursive: true, mode: 0o700 });
|
|
44
|
+
const db = new DatabaseSync(GRAPH_DB);
|
|
45
|
+
try { fs.chmodSync(GRAPH_DB, 0o600); } catch (e) {}
|
|
46
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
47
|
+
db.exec("PRAGMA busy_timeout=3000");
|
|
48
|
+
db.exec(`CREATE TABLE IF NOT EXISTS tool_edges (
|
|
49
|
+
src TEXT NOT NULL,
|
|
50
|
+
dst TEXT NOT NULL,
|
|
51
|
+
weight REAL NOT NULL DEFAULT 1,
|
|
52
|
+
last_reinforced TEXT,
|
|
53
|
+
created TEXT,
|
|
54
|
+
PRIMARY KEY (src, dst)
|
|
55
|
+
)`);
|
|
56
|
+
db.exec("CREATE INDEX IF NOT EXISTS tool_edges_src ON tool_edges (src)");
|
|
57
|
+
db.exec("CREATE INDEX IF NOT EXISTS tool_edges_dst ON tool_edges (dst)");
|
|
58
|
+
_db = db;
|
|
59
|
+
return db;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function now() { return new Date().toISOString(); }
|
|
66
|
+
|
|
67
|
+
// Directed upsert of a follows-edge src→dst. `bump` adds to the current weight
|
|
68
|
+
// (Hebbian) and stamps last_reinforced; with no bump it just ensures the edge
|
|
69
|
+
// exists at `weight` (default 1).
|
|
70
|
+
function addFollow(src, dst, opts = {}) {
|
|
71
|
+
const db = openDb();
|
|
72
|
+
if (!db || !src || !dst || src === dst) return false;
|
|
73
|
+
const bump = Number.isFinite(opts.bump) ? opts.bump : 0;
|
|
74
|
+
const weight = Number.isFinite(opts.weight) ? opts.weight : 1;
|
|
75
|
+
try {
|
|
76
|
+
const existing = db.prepare("SELECT weight FROM tool_edges WHERE src=? AND dst=?").get(src, dst);
|
|
77
|
+
if (existing) {
|
|
78
|
+
const next = bump ? existing.weight + bump : Math.max(existing.weight, weight);
|
|
79
|
+
if (bump) {
|
|
80
|
+
db.prepare("UPDATE tool_edges SET weight=?, last_reinforced=? WHERE src=? AND dst=?")
|
|
81
|
+
.run(next, now(), src, dst);
|
|
82
|
+
} else {
|
|
83
|
+
db.prepare("UPDATE tool_edges SET weight=? WHERE src=? AND dst=?").run(next, src, dst);
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
// No structural floor for tools: a brand-new edge is worth exactly its
|
|
87
|
+
// first co-occurrence (bump), or the explicit `weight` for a pure ensure.
|
|
88
|
+
const initial = bump ? bump : weight;
|
|
89
|
+
db.prepare("INSERT INTO tool_edges (src, dst, weight, last_reinforced, created) VALUES (?,?,?,?,?)")
|
|
90
|
+
.run(src, dst, initial, bump ? now() : null, now());
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Reinforce an ordered run of tools: for [a, b, c] strengthen a→b and b→c (only
|
|
99
|
+
// immediate succession — that's the cleanest "what runs next" signal). Repeated
|
|
100
|
+
// names in a row are collapsed so a tool re-run in a loop doesn't self-link.
|
|
101
|
+
function reinforceSequence(names, amount = 1) {
|
|
102
|
+
const seq = (names || []).filter(Boolean);
|
|
103
|
+
let last = null;
|
|
104
|
+
for (const name of seq) {
|
|
105
|
+
if (last && last !== name) addFollow(last, name, { bump: amount });
|
|
106
|
+
last = name;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function removeEdge(src, dst) {
|
|
111
|
+
const db = openDb();
|
|
112
|
+
if (!db) return false;
|
|
113
|
+
try { db.prepare("DELETE FROM tool_edges WHERE src=? AND dst=?").run(src, dst); return true; }
|
|
114
|
+
catch (e) { return false; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function allEdges() {
|
|
118
|
+
const db = openDb();
|
|
119
|
+
if (!db) return [];
|
|
120
|
+
try { return db.prepare("SELECT src, dst, weight, last_reinforced FROM tool_edges").all(); }
|
|
121
|
+
catch (e) { return []; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Tools that tend to follow `name`, strongest first. This is the surfaced
|
|
125
|
+
// signal: "you ran X — you usually run these next".
|
|
126
|
+
function followers(name, limit = 5) {
|
|
127
|
+
const db = openDb();
|
|
128
|
+
if (!db || !name) return [];
|
|
129
|
+
try {
|
|
130
|
+
return db.prepare("SELECT dst AS name, weight FROM tool_edges WHERE src=? ORDER BY weight DESC LIMIT ?")
|
|
131
|
+
.all(name, limit);
|
|
132
|
+
} catch (e) { return []; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Tools that tend to precede `name` (the inverse view).
|
|
136
|
+
function predecessors(name, limit = 5) {
|
|
137
|
+
const db = openDb();
|
|
138
|
+
if (!db || !name) return [];
|
|
139
|
+
try {
|
|
140
|
+
return db.prepare("SELECT src AS name, weight FROM tool_edges WHERE dst=? ORDER BY weight DESC LIMIT ?")
|
|
141
|
+
.all(name, limit);
|
|
142
|
+
} catch (e) { return []; }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Exponential time decay. Tools have no structural floor, so edges decay toward
|
|
146
|
+
// zero and are pruned once under `pruneBelow` — a chain that fell out of use
|
|
147
|
+
// disappears instead of lingering as stale advice.
|
|
148
|
+
function decay({ halfLifeDays = DEFAULTS.halfLifeDays, pruneBelow = DEFAULTS.pruneBelow } = {}) {
|
|
149
|
+
const db = openDb();
|
|
150
|
+
if (!db) return { decayed: 0, pruned: 0 };
|
|
151
|
+
const rows = allEdges();
|
|
152
|
+
const nowMs = Date.now();
|
|
153
|
+
const ln2 = Math.log(2);
|
|
154
|
+
let decayed = 0, pruned = 0;
|
|
155
|
+
for (const e of rows) {
|
|
156
|
+
if (!e.last_reinforced) continue;
|
|
157
|
+
const ageDays = (nowMs - Date.parse(e.last_reinforced)) / 86400000;
|
|
158
|
+
if (!(ageDays > 0)) continue;
|
|
159
|
+
const factor = Math.exp(-ln2 * ageDays / Math.max(1, halfLifeDays));
|
|
160
|
+
const next = e.weight * factor;
|
|
161
|
+
if (next < pruneBelow) {
|
|
162
|
+
if (removeEdge(e.src, e.dst)) pruned++;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (Math.abs(next - e.weight) > 1e-6) {
|
|
166
|
+
try {
|
|
167
|
+
db.prepare("UPDATE tool_edges SET weight=? WHERE src=? AND dst=?").run(next, e.src, e.dst);
|
|
168
|
+
decayed++;
|
|
169
|
+
} catch (e2) {}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { decayed, pruned };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Drop edges whose endpoints are no longer registered tools (a tool was removed
|
|
176
|
+
// or renamed). Keeps the graph from pointing at things that can't be run.
|
|
177
|
+
function pruneOrphans(toolsLib) {
|
|
178
|
+
const db = openDb();
|
|
179
|
+
if (!db) return 0;
|
|
180
|
+
let live;
|
|
181
|
+
try { live = new Set(toolsLib.listTools().map((t) => t.name)); }
|
|
182
|
+
catch (e) { return 0; }
|
|
183
|
+
if (!live.size) return 0;
|
|
184
|
+
let removed = 0;
|
|
185
|
+
for (const e of allEdges()) {
|
|
186
|
+
if (!live.has(e.src) || !live.has(e.dst)) {
|
|
187
|
+
if (removeEdge(e.src, e.dst)) removed++;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return removed;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Nightly maintenance (called by the dream): decay reinforced weights and drop
|
|
194
|
+
// orphaned/faded edges. Deterministic + safe — no model needed.
|
|
195
|
+
function tend(toolsLib, opts = {}) {
|
|
196
|
+
const { decayed, pruned } = decay(opts);
|
|
197
|
+
const orphaned = toolsLib ? pruneOrphans(toolsLib) : 0;
|
|
198
|
+
return { decayed, pruned: pruned + orphaned, ...stats() };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function stats() {
|
|
202
|
+
const rows = allEdges();
|
|
203
|
+
const nodes = new Set();
|
|
204
|
+
for (const e of rows) { nodes.add(e.src); nodes.add(e.dst); }
|
|
205
|
+
return { edges: rows.length, nodes: nodes.size };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Test seam.
|
|
209
|
+
function _resetForTest() { if (_db) { try { _db.close(); } catch (e) {} } _db = null; }
|
|
210
|
+
|
|
211
|
+
module.exports = {
|
|
212
|
+
DEFAULTS, GRAPH_DB,
|
|
213
|
+
available, openDb,
|
|
214
|
+
addFollow, reinforceSequence, removeEdge, allEdges,
|
|
215
|
+
followers, predecessors, decay, pruneOrphans, tend, stats,
|
|
216
|
+
_resetForTest,
|
|
217
|
+
};
|
package/core/tools.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Reusable tools: executable scripts the agent crystallises as it works, so a
|
|
2
|
+
// procedure it figured out once (hit an API, drive an interface, transform a
|
|
3
|
+
// file) becomes a re-runnable command next time instead of a throwaway heredoc.
|
|
4
|
+
//
|
|
5
|
+
// This is the executable sibling of context packs. A pack's Procedure section
|
|
6
|
+
// documents *how* in prose; a tool *is* the how — a real file you run. Tools
|
|
7
|
+
// are surfaced in the system prompt as an always-on index (like skill packs)
|
|
8
|
+
// and read on demand via `open-claudia tool show <name>` (progressive
|
|
9
|
+
// disclosure). Each tool can name an owning skill pack so the two stay linked.
|
|
10
|
+
//
|
|
11
|
+
// Preauth: tools run with the operational keyring merged into their env (see
|
|
12
|
+
// runEnv()), exactly like the agent's own subprocess. So a tool references a
|
|
13
|
+
// credential as $inet_central_user and never hardcodes a secret. The keyring is
|
|
14
|
+
// already a managed .env (plaintext + chmod 600 + log redaction); tools inherit
|
|
15
|
+
// that model rather than inventing a second one.
|
|
16
|
+
|
|
17
|
+
const fs = require("fs");
|
|
18
|
+
const path = require("path");
|
|
19
|
+
const CONFIG_DIR = require("../config-dir");
|
|
20
|
+
|
|
21
|
+
const TOOLS_DIR = process.env.TOOLS_DIR ? path.resolve(process.env.TOOLS_DIR) : path.join(CONFIG_DIR, "tools");
|
|
22
|
+
|
|
23
|
+
// Marker line that makes a script a registered tool, plus the header fields we
|
|
24
|
+
// parse from the leading comment block. Language-agnostic: we accept "#" (sh,
|
|
25
|
+
// python, ruby) and "//" (js, ts, go) comment prefixes.
|
|
26
|
+
const MARKER = "open-claudia-tool";
|
|
27
|
+
const HEADER_KEYS = ["description", "pack", "requires", "usage"];
|
|
28
|
+
|
|
29
|
+
function ensureDir() {
|
|
30
|
+
fs.mkdirSync(TOOLS_DIR, { recursive: true, mode: 0o700 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sanitizeName(name) {
|
|
34
|
+
return String(name || "").toLowerCase().trim()
|
|
35
|
+
.replace(/[^a-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 60);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Strip a leading comment prefix ("# " / "// ") from a header line. Returns the
|
|
39
|
+
// remainder, or null if the line is not a comment (header block has ended).
|
|
40
|
+
function uncomment(line) {
|
|
41
|
+
const m = String(line).match(/^\s*(#|\/\/)\s?(.*)$/);
|
|
42
|
+
return m ? m[2] : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Parse the leading comment header of a tool script. The block runs from the
|
|
46
|
+
// first comment line and ends at the first non-comment, non-shebang line. A
|
|
47
|
+
// script is only a tool if the block contains an "open-claudia-tool: <name>"
|
|
48
|
+
// line.
|
|
49
|
+
function parseHeader(content) {
|
|
50
|
+
const out = { name: "", description: "", pack: "", requires: [], usage: "" };
|
|
51
|
+
const lines = String(content || "").split("\n");
|
|
52
|
+
let started = false;
|
|
53
|
+
for (let i = 0; i < lines.length; i++) {
|
|
54
|
+
const raw = lines[i];
|
|
55
|
+
if (i === 0 && raw.startsWith("#!")) continue; // shebang
|
|
56
|
+
const body = uncomment(raw);
|
|
57
|
+
if (body === null) {
|
|
58
|
+
if (started) break; // header block ended
|
|
59
|
+
if (raw.trim() === "") continue; // tolerate a blank line before the block
|
|
60
|
+
break; // first real code line, no header → not a tool
|
|
61
|
+
}
|
|
62
|
+
started = true;
|
|
63
|
+
const kv = body.match(/^([a-zA-Z-]+)\s*:\s*(.*)$/);
|
|
64
|
+
if (!kv) continue;
|
|
65
|
+
const key = kv[1].toLowerCase();
|
|
66
|
+
const val = kv[2].trim();
|
|
67
|
+
if (key === MARKER) out.name = sanitizeName(val);
|
|
68
|
+
else if (key === "requires") out.requires = val.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
|
|
69
|
+
else if (HEADER_KEYS.includes(key)) out[key] = val;
|
|
70
|
+
}
|
|
71
|
+
return out.name ? out : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toolFile(name) {
|
|
75
|
+
return path.join(TOOLS_DIR, sanitizeName(name));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readTool(name) {
|
|
79
|
+
const file = toolFile(name);
|
|
80
|
+
let content;
|
|
81
|
+
try { content = fs.readFileSync(file, "utf-8"); } catch (e) { return null; }
|
|
82
|
+
const header = parseHeader(content);
|
|
83
|
+
if (!header) return null;
|
|
84
|
+
let stat = null;
|
|
85
|
+
try { stat = fs.statSync(file); } catch (e) {}
|
|
86
|
+
return {
|
|
87
|
+
name: header.name || sanitizeName(name),
|
|
88
|
+
description: header.description || "",
|
|
89
|
+
pack: header.pack || "",
|
|
90
|
+
requires: header.requires || [],
|
|
91
|
+
usage: header.usage || "",
|
|
92
|
+
file,
|
|
93
|
+
executable: stat ? !!(stat.mode & 0o111) : false,
|
|
94
|
+
updatedAt: stat ? stat.mtime.toISOString() : "",
|
|
95
|
+
content,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function listTools() {
|
|
100
|
+
let entries;
|
|
101
|
+
try { entries = fs.readdirSync(TOOLS_DIR); } catch (e) { return []; }
|
|
102
|
+
const tools = [];
|
|
103
|
+
for (const name of entries) {
|
|
104
|
+
if (name.startsWith(".")) continue;
|
|
105
|
+
try { if (!fs.statSync(path.join(TOOLS_DIR, name)).isFile()) continue; }
|
|
106
|
+
catch (e) { continue; }
|
|
107
|
+
const t = readTool(name);
|
|
108
|
+
if (t) tools.push(t);
|
|
109
|
+
}
|
|
110
|
+
tools.sort((a, b) => a.name.localeCompare(b.name));
|
|
111
|
+
return tools;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function findTool(name) {
|
|
115
|
+
const needle = sanitizeName(name);
|
|
116
|
+
if (!needle) return null;
|
|
117
|
+
return readTool(needle);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Build the header comment block for a script that doesn't already declare one.
|
|
121
|
+
// Uses the comment prefix that matches the script's shebang/extension.
|
|
122
|
+
function buildHeader(prefix, { name, description, pack, requires, usage }) {
|
|
123
|
+
const lines = [`${prefix} ${MARKER}: ${name}`];
|
|
124
|
+
if (description) lines.push(`${prefix} description: ${description}`);
|
|
125
|
+
if (pack) lines.push(`${prefix} pack: ${pack}`);
|
|
126
|
+
if (requires && requires.length) lines.push(`${prefix} requires: ${[].concat(requires).join(", ")}`);
|
|
127
|
+
if (usage) lines.push(`${prefix} usage: ${usage}`);
|
|
128
|
+
return lines.join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function commentPrefixFor(content, srcPath) {
|
|
132
|
+
const first = String(content || "").split("\n")[0] || "";
|
|
133
|
+
if (/\b(node|deno|bun)\b/.test(first) || /\.(jsx?|tsx?|go)$/i.test(srcPath || "")) return "//";
|
|
134
|
+
return "#";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Register a script as a tool. Copies it into TOOLS_DIR under a sanitized name,
|
|
138
|
+
// ensures it carries a header (synthesising one from opts if absent), and makes
|
|
139
|
+
// it executable. Returns the stored tool.
|
|
140
|
+
function addTool(srcPath, opts = {}) {
|
|
141
|
+
ensureDir();
|
|
142
|
+
let content;
|
|
143
|
+
try { content = fs.readFileSync(srcPath, "utf-8"); }
|
|
144
|
+
catch (e) { throw new Error(`cannot read ${srcPath}: ${e.message}`); }
|
|
145
|
+
|
|
146
|
+
let header = parseHeader(content);
|
|
147
|
+
const name = sanitizeName(opts.name || (header && header.name) || path.basename(srcPath).replace(/\.[^.]+$/, ""));
|
|
148
|
+
if (!name) throw new Error("tool needs a name (pass --name or add an 'open-claudia-tool:' header line)");
|
|
149
|
+
|
|
150
|
+
if (!header) {
|
|
151
|
+
// No header in the source — synthesise one so the tool is self-documenting.
|
|
152
|
+
const prefix = commentPrefixFor(content, srcPath);
|
|
153
|
+
const block = buildHeader(prefix, {
|
|
154
|
+
name,
|
|
155
|
+
description: opts.description || "",
|
|
156
|
+
pack: opts.pack || "",
|
|
157
|
+
requires: opts.requires || [],
|
|
158
|
+
usage: opts.usage || "",
|
|
159
|
+
});
|
|
160
|
+
const hasShebang = content.startsWith("#!");
|
|
161
|
+
if (hasShebang) {
|
|
162
|
+
const nl = content.indexOf("\n");
|
|
163
|
+
content = content.slice(0, nl + 1) + block + "\n" + content.slice(nl + 1);
|
|
164
|
+
} else {
|
|
165
|
+
content = block + "\n" + content;
|
|
166
|
+
}
|
|
167
|
+
header = parseHeader(content);
|
|
168
|
+
} else if (opts.pack && !header.pack) {
|
|
169
|
+
// Source had a header but no pack link and the caller supplied one — add it.
|
|
170
|
+
const prefix = commentPrefixFor(content, srcPath);
|
|
171
|
+
content = content.replace(
|
|
172
|
+
new RegExp(`((#|//)\\s*${MARKER}:.*\\n)`),
|
|
173
|
+
`$1${prefix} pack: ${opts.pack}\n`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const dest = toolFile(name);
|
|
178
|
+
fs.writeFileSync(dest, content, { mode: 0o700 });
|
|
179
|
+
try { fs.chmodSync(dest, 0o700); } catch (e) {}
|
|
180
|
+
return readTool(name);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function removeTool(name) {
|
|
184
|
+
const tool = findTool(name);
|
|
185
|
+
if (!tool) return null;
|
|
186
|
+
try { fs.rmSync(tool.file, { force: true }); } catch (e) { return null; }
|
|
187
|
+
return tool;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Env for running a tool: the bot's standard subprocess env (PATH + keyring
|
|
191
|
+
// creds merged) so tools are pre-authed the same way the agent is. Falls back to
|
|
192
|
+
// a plain keyring merge if config isn't importable (e.g. standalone test).
|
|
193
|
+
function runEnv() {
|
|
194
|
+
try { return require("./config").botSubprocessEnv(); }
|
|
195
|
+
catch (e) {
|
|
196
|
+
try {
|
|
197
|
+
const keyring = require("./keyring");
|
|
198
|
+
return { ...keyring.all(), ...process.env };
|
|
199
|
+
} catch (e2) { return { ...process.env }; }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Which of a tool's required keyring keys are actually present right now — used
|
|
204
|
+
// by `tool show` to warn before a run fails on a missing credential.
|
|
205
|
+
function missingRequires(tool) {
|
|
206
|
+
if (!tool || !tool.requires || !tool.requires.length) return [];
|
|
207
|
+
let present;
|
|
208
|
+
try { present = new Set(require("./keyring").keys()); }
|
|
209
|
+
catch (e) { present = new Set(Object.keys(process.env)); }
|
|
210
|
+
return tool.requires.filter((k) => !present.has(k) && process.env[k] === undefined);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Recognise a Write/Edit aimed at a tool file (for chat announcements).
|
|
214
|
+
function toolNameFromPath(filePath) {
|
|
215
|
+
if (!filePath) return null;
|
|
216
|
+
const resolved = path.resolve(String(filePath));
|
|
217
|
+
const rel = path.relative(TOOLS_DIR, resolved);
|
|
218
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
219
|
+
if (rel.split(path.sep).length !== 1) return null;
|
|
220
|
+
return rel;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = {
|
|
224
|
+
TOOLS_DIR, MARKER,
|
|
225
|
+
parseHeader, readTool, listTools, findTool, addTool, removeTool,
|
|
226
|
+
runEnv, missingRequires, toolNameFromPath, sanitizeName, ensureDir,
|
|
227
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.50",
|
|
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-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-read-signal.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-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-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"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"bot.js",
|
|
@@ -43,7 +43,9 @@
|
|
|
43
43
|
"test-ability-merge-guard.js",
|
|
44
44
|
"test-learning-e2e.js",
|
|
45
45
|
"test-pack-nesting.js",
|
|
46
|
-
"test-read-signal.js"
|
|
46
|
+
"test-read-signal.js",
|
|
47
|
+
"test-tools.js",
|
|
48
|
+
"test-tool-graph.js"
|
|
47
49
|
],
|
|
48
50
|
"keywords": [
|
|
49
51
|
"claude",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Directed tool-graph: "tool B follows tool A". Unlike the recall graph this is
|
|
2
|
+
// DIRECTED (A→B carries info B→A does not) and has NO structural floor, so an
|
|
3
|
+
// unused chain decays to zero and is pruned. This exercises core/tool-graph.js
|
|
4
|
+
// end to end against an isolated DB so nothing touches the real graph.
|
|
5
|
+
const assert = require("assert");
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
|
|
10
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tool-graph-test-"));
|
|
11
|
+
process.env.TOOL_GRAPH_DB = path.join(tmp, "tool-graph.db");
|
|
12
|
+
|
|
13
|
+
const graph = require("./core/tool-graph");
|
|
14
|
+
|
|
15
|
+
// node:sqlite arrived in node 22; on older runtimes the graph is a no-op. Skip
|
|
16
|
+
// cleanly so the suite stays green rather than failing on the environment.
|
|
17
|
+
if (!graph.available()) {
|
|
18
|
+
console.log("tool-graph OK (node:sqlite unavailable — skipped)");
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── reinforceSequence lays directed edges for IMMEDIATE succession only ──
|
|
23
|
+
graph.reinforceSequence(["auth", "list", "download"]);
|
|
24
|
+
let after = graph.followers("auth");
|
|
25
|
+
assert.strictEqual(after.length, 1, "auth has exactly one follower");
|
|
26
|
+
assert.strictEqual(after[0].name, "list", "list follows auth");
|
|
27
|
+
assert.strictEqual(after[0].weight, 1, "a single co-occurrence weighs 1 (no structural floor)");
|
|
28
|
+
|
|
29
|
+
// non-adjacent pairs are NOT linked — auth→download must not exist
|
|
30
|
+
assert.ok(!graph.followers("auth").some((r) => r.name === "download"), "auth→download skipped (not adjacent)");
|
|
31
|
+
|
|
32
|
+
// ── directionality: the edge is auth→list, never list→auth ──
|
|
33
|
+
assert.ok(graph.followers("list").some((r) => r.name === "download"), "download follows list");
|
|
34
|
+
assert.ok(!graph.followers("list").some((r) => r.name === "auth"), "auth does NOT follow list (directed)");
|
|
35
|
+
assert.strictEqual(graph.predecessors("download")[0].name, "list", "list precedes download");
|
|
36
|
+
assert.ok(graph.predecessors("list").some((r) => r.name === "auth"), "auth precedes list");
|
|
37
|
+
assert.ok(!graph.predecessors("list").some((r) => r.name === "download"), "download does NOT precede list (directed)");
|
|
38
|
+
|
|
39
|
+
// ── Hebbian bump: re-running the chain strengthens the same edges ──
|
|
40
|
+
graph.reinforceSequence(["auth", "list", "download"]);
|
|
41
|
+
assert.strictEqual(graph.followers("auth")[0].weight, 2, "second co-use bumps auth→list to 2");
|
|
42
|
+
|
|
43
|
+
// ── repeated names in a row collapse: a tool re-run in a loop must not self-link ──
|
|
44
|
+
graph.reinforceSequence(["loop", "loop", "loop", "end"]);
|
|
45
|
+
assert.ok(!graph.followers("loop").some((r) => r.name === "loop"), "no loop→loop self-edge");
|
|
46
|
+
assert.strictEqual(graph.followers("loop")[0].name, "end", "loop→end is the only edge from a repeated run");
|
|
47
|
+
|
|
48
|
+
// ── addFollow guards: src===dst is rejected ──
|
|
49
|
+
assert.strictEqual(graph.addFollow("x", "x"), false, "self-edge refused");
|
|
50
|
+
|
|
51
|
+
// ── a pure 'ensure' edge (no bump) has no last_reinforced and so survives decay ──
|
|
52
|
+
graph.addFollow("ensure-src", "ensure-dst"); // weight 1, last_reinforced=null
|
|
53
|
+
const ensured = graph.followers("ensure-src");
|
|
54
|
+
assert.strictEqual(ensured[0].weight, 1, "ensure edge weighs 1");
|
|
55
|
+
|
|
56
|
+
// ── decay: backdate a reinforced edge far into the past, then it prunes (no floor) ──
|
|
57
|
+
const db = graph.openDb();
|
|
58
|
+
const longAgo = new Date(Date.now() - 365 * 86400000).toISOString();
|
|
59
|
+
db.prepare("UPDATE tool_edges SET last_reinforced=? WHERE src=? AND dst=?").run(longAgo, "auth", "list");
|
|
60
|
+
let res = graph.decay({ halfLifeDays: 1, pruneBelow: 0.15 });
|
|
61
|
+
assert.ok(res.pruned >= 1, "a year-old edge under the floor is pruned");
|
|
62
|
+
assert.ok(!graph.followers("auth").some((r) => r.name === "list"), "pruned edge is gone");
|
|
63
|
+
|
|
64
|
+
// the ensure edge (last_reinforced=null) was skipped by decay, not pruned
|
|
65
|
+
assert.ok(graph.followers("ensure-src").some((r) => r.name === "ensure-dst"), "un-reinforced edge survives decay");
|
|
66
|
+
|
|
67
|
+
// ── decay that reduces but keeps an edge above the floor ──
|
|
68
|
+
graph.addFollow("warm-a", "warm-b", { bump: 10 });
|
|
69
|
+
const recent = new Date(Date.now() - 30 * 86400000).toISOString();
|
|
70
|
+
db.prepare("UPDATE tool_edges SET last_reinforced=? WHERE src=? AND dst=?").run(recent, "warm-a", "warm-b");
|
|
71
|
+
graph.decay({ halfLifeDays: 30, pruneBelow: 0.15 }); // one half-life → ~5
|
|
72
|
+
const warm = graph.followers("warm-a")[0];
|
|
73
|
+
assert.ok(warm && warm.name === "warm-b", "warm edge survives a single half-life");
|
|
74
|
+
assert.ok(warm.weight < 10 && warm.weight > 0.15, `warm edge decayed but kept (${warm.weight})`);
|
|
75
|
+
|
|
76
|
+
// ── removeEdge ──
|
|
77
|
+
assert.strictEqual(graph.removeEdge("warm-a", "warm-b"), true, "removeEdge succeeds");
|
|
78
|
+
assert.ok(!graph.followers("warm-a").length, "removed edge gone");
|
|
79
|
+
|
|
80
|
+
// ── pruneOrphans drops edges whose endpoints are no longer registered tools ──
|
|
81
|
+
graph.reinforceSequence(["live", "dead"]); // 'dead' will not be in the live set
|
|
82
|
+
const fakeLib = { listTools: () => [{ name: "live" }, { name: "end" }, { name: "loop" }, { name: "ensure-src" }, { name: "ensure-dst" }] };
|
|
83
|
+
const removed = graph.pruneOrphans(fakeLib);
|
|
84
|
+
assert.ok(removed >= 1, "edge pointing at an unregistered tool ('dead') is pruned");
|
|
85
|
+
assert.ok(!graph.followers("live").length, "live→dead removed because dead is not a live tool");
|
|
86
|
+
|
|
87
|
+
// ── tend() is the nightly entry: decays + prunes orphans + returns stats ──
|
|
88
|
+
const t = graph.tend(fakeLib);
|
|
89
|
+
assert.ok(typeof t.edges === "number" && typeof t.nodes === "number", "tend returns stats");
|
|
90
|
+
assert.ok(typeof t.pruned === "number" && typeof t.decayed === "number", "tend returns decay/prune counts");
|
|
91
|
+
|
|
92
|
+
// ── stats / allEdges sanity ──
|
|
93
|
+
const s = graph.stats();
|
|
94
|
+
assert.strictEqual(s.edges, graph.allEdges().length, "stats edge count matches allEdges");
|
|
95
|
+
|
|
96
|
+
graph._resetForTest();
|
|
97
|
+
console.log("tool-graph OK");
|
package/test-tools.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Reusable tools: a script the agent crystallises must register with a parseable
|
|
2
|
+
// header, run with the keyring merged into its env (preauth), and be findable by
|
|
3
|
+
// the always-on tool index. This exercises the core/tools.js library end to end
|
|
4
|
+
// in an isolated TOOLS_DIR so nothing touches the real ~/.open-claudia/tools.
|
|
5
|
+
const assert = require("assert");
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
|
|
10
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tools-test-"));
|
|
11
|
+
process.env.TOOLS_DIR = path.join(tmp, "tools");
|
|
12
|
+
|
|
13
|
+
const tools = require("./core/tools");
|
|
14
|
+
|
|
15
|
+
// ── add a bare script (no header): a header is synthesised from opts, shebang
|
|
16
|
+
// is preserved on line 1, and the file is made executable ──
|
|
17
|
+
const srcA = path.join(tmp, "hello.sh");
|
|
18
|
+
fs.writeFileSync(srcA, "#!/usr/bin/env bash\necho \"hi $1\"\n");
|
|
19
|
+
const a = tools.addTool(srcA, {
|
|
20
|
+
name: "hello",
|
|
21
|
+
description: "say hi",
|
|
22
|
+
pack: "greetings",
|
|
23
|
+
requires: ["api_key", "api_secret"],
|
|
24
|
+
usage: "hello <name>",
|
|
25
|
+
});
|
|
26
|
+
assert.strictEqual(a.name, "hello", "synthesised tool keeps requested name");
|
|
27
|
+
assert.strictEqual(a.description, "say hi", "description parsed back from synthesised header");
|
|
28
|
+
assert.strictEqual(a.pack, "greetings", "pack link parsed back");
|
|
29
|
+
assert.deepStrictEqual(a.requires, ["api_key", "api_secret"], "requires parsed back as a list");
|
|
30
|
+
assert.strictEqual(a.usage, "hello <name>", "usage parsed back");
|
|
31
|
+
assert.ok(a.executable, "stored tool is chmod +x");
|
|
32
|
+
assert.ok(a.content.startsWith("#!/usr/bin/env bash\n"), "shebang stays on line 1");
|
|
33
|
+
assert.ok(/# open-claudia-tool: hello/.test(a.content), "marker line injected with '#' prefix for a shell script");
|
|
34
|
+
|
|
35
|
+
// ── findTool / listTools see it ──
|
|
36
|
+
assert.strictEqual(tools.findTool("hello").name, "hello", "findTool resolves by name");
|
|
37
|
+
assert.strictEqual(tools.findTool("HELLO").name, "hello", "findTool is case-insensitive (sanitized)");
|
|
38
|
+
assert.ok(tools.listTools().some((t) => t.name === "hello"), "listTools includes the new tool");
|
|
39
|
+
|
|
40
|
+
// ── a JS source with a shebang gets a '//' comment header, not '#' ──
|
|
41
|
+
const srcB = path.join(tmp, "fetch.js");
|
|
42
|
+
fs.writeFileSync(srcB, "#!/usr/bin/env node\nconsole.log('x');\n");
|
|
43
|
+
const b = tools.addTool(srcB, { name: "fetch", description: "fetch a thing" });
|
|
44
|
+
assert.ok(/\/\/ open-claudia-tool: fetch/.test(b.content), "JS script gets '//' marker prefix");
|
|
45
|
+
assert.strictEqual(b.requires.length, 0, "no requires when none supplied");
|
|
46
|
+
|
|
47
|
+
// ── a source that already declares its own header is respected; a --pack passed
|
|
48
|
+
// in is grafted on without clobbering the existing marker ──
|
|
49
|
+
const srcC = path.join(tmp, "self-doc.sh");
|
|
50
|
+
fs.writeFileSync(
|
|
51
|
+
srcC,
|
|
52
|
+
"#!/usr/bin/env bash\n# open-claudia-tool: selfdoc\n# description: pre-documented\necho hi\n"
|
|
53
|
+
);
|
|
54
|
+
const c = tools.addTool(srcC, { pack: "linked-pack" });
|
|
55
|
+
assert.strictEqual(c.name, "selfdoc", "existing marker name wins");
|
|
56
|
+
assert.strictEqual(c.description, "pre-documented", "existing description preserved");
|
|
57
|
+
assert.strictEqual(c.pack, "linked-pack", "--pack grafted onto a self-documented script");
|
|
58
|
+
|
|
59
|
+
// ── missingRequires: keys absent from keyring AND env are reported; present env
|
|
60
|
+
// keys are not ──
|
|
61
|
+
process.env.api_key = "present";
|
|
62
|
+
delete process.env.api_secret;
|
|
63
|
+
const missing = tools.missingRequires(tools.findTool("hello"));
|
|
64
|
+
assert.ok(!missing.includes("api_key"), "a key present in env is not 'missing'");
|
|
65
|
+
assert.ok(missing.includes("api_secret"), "a key absent everywhere is 'missing'");
|
|
66
|
+
delete process.env.api_key;
|
|
67
|
+
|
|
68
|
+
// ── runEnv returns an env object (keyring merged or graceful fallback) ──
|
|
69
|
+
const env = tools.runEnv();
|
|
70
|
+
assert.ok(env && typeof env === "object" && env.PATH, "runEnv yields an env object carrying PATH");
|
|
71
|
+
|
|
72
|
+
// ── toolNameFromPath only recognises a direct child of TOOLS_DIR ──
|
|
73
|
+
assert.strictEqual(
|
|
74
|
+
tools.toolNameFromPath(path.join(process.env.TOOLS_DIR, "hello")),
|
|
75
|
+
"hello",
|
|
76
|
+
"a file directly in TOOLS_DIR resolves to its tool name"
|
|
77
|
+
);
|
|
78
|
+
assert.strictEqual(tools.toolNameFromPath("/etc/passwd"), null, "a path outside TOOLS_DIR → null");
|
|
79
|
+
assert.strictEqual(
|
|
80
|
+
tools.toolNameFromPath(path.join(process.env.TOOLS_DIR, "sub", "deep")),
|
|
81
|
+
null,
|
|
82
|
+
"a nested path under TOOLS_DIR → null"
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// ── parseHeader returns null for a script with no marker line ──
|
|
86
|
+
assert.strictEqual(tools.parseHeader("#!/bin/sh\necho hi\n"), null, "no marker → not a tool");
|
|
87
|
+
|
|
88
|
+
// ── remove ──
|
|
89
|
+
assert.ok(tools.removeTool("hello"), "removeTool returns the removed tool");
|
|
90
|
+
assert.strictEqual(tools.findTool("hello"), null, "removed tool is gone");
|
|
91
|
+
|
|
92
|
+
console.log("tools OK");
|