@inetafrica/open-claudia 2.6.47 → 2.6.49
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/channels/voice/adapter.js +21 -4
- package/channels/voice/manage.js +285 -0
- package/core/dream.js +23 -2
- package/core/handlers.js +2 -1
- package/core/io.js +11 -1
- package/core/media.js +53 -1
- package/core/runner.js +43 -4
- 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/core/media.js
CHANGED
|
@@ -79,4 +79,56 @@ async function textToVoice(text) {
|
|
|
79
79
|
return sayToVoice(clean);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
// Split a reply into sentence-sized chunks for streamed TTS. Tiny fragments are
|
|
83
|
+
// merged into the next chunk so we don't fire a TTS call per "Hi." and end up
|
|
84
|
+
// with choppy playback. Returns cleaned, non-empty chunks.
|
|
85
|
+
function splitSentences(text, minLen = 30) {
|
|
86
|
+
const clean = cleanForTTS(text);
|
|
87
|
+
if (!clean) return [];
|
|
88
|
+
const raw = clean.match(/[^.!?]+[.!?]+|\S[^.!?]*$/g) || [clean];
|
|
89
|
+
const out = [];
|
|
90
|
+
let buf = "";
|
|
91
|
+
for (const piece of raw) {
|
|
92
|
+
buf = (buf ? `${buf} ${piece.trim()}` : piece.trim()).trim();
|
|
93
|
+
if (buf.length >= minLen) { out.push(buf); buf = ""; }
|
|
94
|
+
}
|
|
95
|
+
if (buf) {
|
|
96
|
+
if (out.length) out[out.length - 1] = `${out[out.length - 1]} ${buf}`.trim();
|
|
97
|
+
else out.push(buf);
|
|
98
|
+
}
|
|
99
|
+
return out.filter(Boolean);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Synthesize one already-short chunk to a directly-playable mp3 (no transcode).
|
|
103
|
+
// Used by the voice channel's streamed replies. Falls back to the ogg path
|
|
104
|
+
// (textToVoice) on no-key/error so callers always get a playable file or null.
|
|
105
|
+
async function synthSentenceMp3(text) {
|
|
106
|
+
const clean = cleanForTTS(text);
|
|
107
|
+
if (!clean) return null;
|
|
108
|
+
if (!ELEVENLABS_API_KEY) return sayToVoice(clean); // ogg fallback
|
|
109
|
+
try {
|
|
110
|
+
const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${ELEVENLABS_VOICE_ID}`, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { "xi-api-key": ELEVENLABS_API_KEY, "Content-Type": "application/json" },
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
text: clean,
|
|
115
|
+
model_id: ELEVENLABS_MODEL,
|
|
116
|
+
voice_settings: { stability: 0.5, similarity_boost: 0.85, style: 0.5, use_speaker_boost: true },
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
const body = await res.text().catch(() => "");
|
|
121
|
+
console.error(`ElevenLabs TTS failed: ${res.status} ${body}`.slice(0, 300));
|
|
122
|
+
return sayToVoice(clean);
|
|
123
|
+
}
|
|
124
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
125
|
+
const mp3Path = path.join(TEMP_DIR, `tts-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.mp3`);
|
|
126
|
+
fs.writeFileSync(mp3Path, buf);
|
|
127
|
+
return mp3Path;
|
|
128
|
+
} catch (e) {
|
|
129
|
+
console.error("ElevenLabs TTS error:", e.message);
|
|
130
|
+
return sayToVoice(clean);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { transcribeAudio, textToVoice, splitSentences, synthSentenceMp3, TTS_CMD };
|
package/core/runner.js
CHANGED
|
@@ -15,8 +15,8 @@ const { currentState, saveState, recordSession, userOwnsClaudeSession, resetSess
|
|
|
15
15
|
const { chatContext, currentChannelId, currentAdapter } = require("./context");
|
|
16
16
|
const { buildSystemPrompt, promptWithDynamicContext } = require("./system-prompt");
|
|
17
17
|
const { redactSensitive } = require("./redact");
|
|
18
|
-
const { send, editMessage, sendVoice, splitMessage } = require("./io");
|
|
19
|
-
const { textToVoice } = require("./media");
|
|
18
|
+
const { send, editMessage, sendVoice, sendVoiceEnd, splitMessage } = require("./io");
|
|
19
|
+
const { textToVoice, splitSentences, synthSentenceMp3 } = require("./media");
|
|
20
20
|
const { killProcessTree } = require("./process-tree");
|
|
21
21
|
const {
|
|
22
22
|
appendProjectTranscript, transcriptProjectInfo,
|
|
@@ -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 {
|
|
@@ -857,6 +858,12 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
857
858
|
else notifySkill(`entity:${entSlug}`, `👤 New entity noted: ${entSlug} — open-claudia entity show ${entSlug} to peek.`);
|
|
858
859
|
return;
|
|
859
860
|
}
|
|
861
|
+
const toolName2 = toolsLib.toolNameFromPath(filePath);
|
|
862
|
+
if (toolName2) {
|
|
863
|
+
if (toolsLib.findTool(toolName2)) notifySkill(`tool:${toolName2}`, `🔧 Updating tool: ${toolName2} — open-claudia tool show ${toolName2} to inspect.`);
|
|
864
|
+
else notifySkill(`tool:${toolName2}`, `🔧 New tool: ${toolName2} — open-claudia tool run ${toolName2} to use it.`);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
860
867
|
const dir = skillsLib.skillNameFromPath(filePath);
|
|
861
868
|
if (!dir) return;
|
|
862
869
|
// The tool_use event precedes the actual write, so existence now
|
|
@@ -874,6 +881,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
874
881
|
// Nodes the agent actually OPENED this turn (📖). This is the co-use signal
|
|
875
882
|
// the recall graph reinforces on — actually-read, not merely surfaced.
|
|
876
883
|
const openedThisTurn = new Set();
|
|
884
|
+
// Reusable tools the agent RAN this turn, in order. Feeds the directed
|
|
885
|
+
// tool-graph (core/tool-graph.js): consecutive runs become follows-edges so
|
|
886
|
+
// "you ran X — you usually run Y next" can be surfaced. Order matters here
|
|
887
|
+
// (unlike openedThisTurn, a Set), because a tool chain is a pipeline.
|
|
888
|
+
const toolRunsThisTurn = [];
|
|
877
889
|
const noteRecallFromShell = (command) => {
|
|
878
890
|
try {
|
|
879
891
|
const cmd = String(command || "");
|
|
@@ -895,6 +907,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
895
907
|
openedThisTurn.add(`entity:${slug}`);
|
|
896
908
|
notifySkill(`recall:entity:${slug}`, `📖 Recalled my notes on: ${name}`);
|
|
897
909
|
}
|
|
910
|
+
// `open-claudia tool run|exec <name>` — record the run for the directed
|
|
911
|
+
// tool-graph. Only the name is captured (args are noise for "what runs
|
|
912
|
+
// next"); the end-of-turn block reinforces consecutive pairs.
|
|
913
|
+
const toolRe = /\btool\s+(?:run|exec)\s+["']?([a-z0-9][\w.-]*)/gi;
|
|
914
|
+
while ((m = toolRe.exec(cmd))) toolRunsThisTurn.push(m[1]);
|
|
898
915
|
} catch (e) { /* announcements are best-effort */ }
|
|
899
916
|
};
|
|
900
917
|
|
|
@@ -1200,8 +1217,20 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1200
1217
|
// input is unwanted noise, so gate it to the voice channel.
|
|
1201
1218
|
const { currentTransport } = require("./context");
|
|
1202
1219
|
if (currentTransport() === "voice") {
|
|
1203
|
-
|
|
1204
|
-
|
|
1220
|
+
// Stream the spoken reply sentence-by-sentence so the first audio
|
|
1221
|
+
// plays while the rest still synthesizes — far lower time-to-first-
|
|
1222
|
+
// sound than waiting for one TTS pass over the whole reply.
|
|
1223
|
+
const sentences = splitSentences(finalText);
|
|
1224
|
+
let spokeAny = false;
|
|
1225
|
+
for (const sentence of sentences) {
|
|
1226
|
+
const clip = await synthSentenceMp3(sentence);
|
|
1227
|
+
if (clip) { spokeAny = true; await sendVoice(clip); }
|
|
1228
|
+
}
|
|
1229
|
+
if (!spokeAny) {
|
|
1230
|
+
const voicePath = await textToVoice(finalText);
|
|
1231
|
+
if (voicePath) await sendVoice(voicePath);
|
|
1232
|
+
}
|
|
1233
|
+
await sendVoiceEnd();
|
|
1205
1234
|
}
|
|
1206
1235
|
}
|
|
1207
1236
|
} catch (e) {
|
|
@@ -1228,6 +1257,16 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1228
1257
|
} catch (e) { /* best-effort */ }
|
|
1229
1258
|
}
|
|
1230
1259
|
|
|
1260
|
+
// Directed tool-graph: a chain of reusable tools run in succession this turn
|
|
1261
|
+
// (auth → list → download) becomes follows-edges, so next time the first
|
|
1262
|
+
// runs we can surface "usually followed by …". Kept in a tool-specific graph
|
|
1263
|
+
// so it never bleeds into recall's spreading activation. Reinforce only on a
|
|
1264
|
+
// successful turn, and only when ≥2 ran (a single run has no succession).
|
|
1265
|
+
if (turnSucceeded && toolRunsThisTurn.length > 1) {
|
|
1266
|
+
try { require("./tool-graph").reinforceSequence(toolRunsThisTurn); }
|
|
1267
|
+
catch (e) { /* best-effort */ }
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1231
1270
|
// Close the learning loop: when an ABILITY pack is opened in the same turn
|
|
1232
1271
|
// as a project (context) pack, the ability was demonstrably applied while
|
|
1233
1272
|
// 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
|
+
};
|