@inetafrica/open-claudia 2.6.51 → 2.6.53
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/tool.js +8 -0
- package/core/dream.js +1 -1
- package/core/runner.js +29 -10
- package/core/system-prompt.js +38 -1
- package/core/tool-graph.js +181 -16
- package/package.json +1 -1
- package/test-tool-graph.js +59 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.6.53
|
|
4
|
+
- **Pack↔tool contextual edges — surface how a tool is used in a topic.** Completes the v2.6.49 tool story. The directed tool-graph answers "what runs *next*"; this adds the orthogonal question "how is a tool *used in this context*". A new `pack_tool_edges` table (`core/tool-graph.js`, sharing the existing SQLite store, decay and pruning) records, for each turn, the reusable tools that ran while a context pack was open — keyed by `(pack, tool, command-shape)` so distinct invocations of the same tool coexist and the strongest surface. Capture is automatic and deterministic (no hand-authoring): `runner.js` already tracks the tools run and the packs opened each turn, so at end-of-turn it records a pack↔tool edge per (opened-pack × run-tool), Hebbian-reinforced. Commands are stored as **shapes, not literals** — a new `shapeCommand()` strips ids, paths, numbers and quoted text to placeholders (`spaces docs --task-id <id> <path>`) so the memory is a reusable pattern, not a stale one-off; `parseToolRuns()` extracts `{name, shape}` from a shell command (split across chained invocations) and feeds both graphs. Surfacing: when a pack is injected into context, `formatPackForContext` appends a **"Tools used in this pack"** block with the 1–2 strongest invocations, so the agent sees the concrete command instead of re-deriving it; `tool show` gains the inverse "Used in packs" view; an optional `intent` column lets a one-line *why* be enriched later without a migration. The nightly dream tends these edges alongside the follows-edges (decay + prune orphaned tools/packs). Degrades to a graceful no-op where `node:sqlite` is unavailable, exactly like the existing tool-graph. Extends `test-tool-graph.js`.
|
|
5
|
+
|
|
3
6
|
## v2.6.49
|
|
4
7
|
- **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
8
|
|
package/bin/tool.js
CHANGED
|
@@ -77,6 +77,14 @@ function run(args) {
|
|
|
77
77
|
const before = graph.predecessors(t.name);
|
|
78
78
|
if (after.length) console.log(`Often followed by: ${fmt(after)}`);
|
|
79
79
|
if (before.length) console.log(`Often preceded by: ${fmt(before)}`);
|
|
80
|
+
// Pack↔tool: contexts this tool has been used in, with the shape used.
|
|
81
|
+
const inPacks = graph.toolPacks(t.name, 5);
|
|
82
|
+
if (inPacks.length) {
|
|
83
|
+
console.log("Used in packs:");
|
|
84
|
+
for (const u of inPacks) {
|
|
85
|
+
console.log(` • ${u.pack}: open-claudia tool run ${u.command || t.name}${u.intent ? ` — ${u.intent}` : ""}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
80
88
|
} catch (e) { /* graph optional (old node) */ }
|
|
81
89
|
console.log(`\n----- source -----\n${t.content}`);
|
|
82
90
|
break;
|
package/core/dream.js
CHANGED
|
@@ -833,7 +833,7 @@ async function runDream({ trigger = "manual" } = {}) {
|
|
|
833
833
|
let toolNote = "";
|
|
834
834
|
try {
|
|
835
835
|
const toolsLib = require("./tools");
|
|
836
|
-
const tg = require("./tool-graph").tend(toolsLib);
|
|
836
|
+
const tg = require("./tool-graph").tend(toolsLib, { packsLib: packs });
|
|
837
837
|
const bits = [];
|
|
838
838
|
if (tg.edges || tg.decayed || tg.pruned) {
|
|
839
839
|
bits.push(`🔧 Tool graph: ${tg.edges} edges / ${tg.nodes} nodes (decayed ${tg.decayed}, pruned ${tg.pruned}).`);
|
package/core/runner.js
CHANGED
|
@@ -939,10 +939,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
939
939
|
// Nodes the agent actually OPENED this turn (📖). This is the co-use signal
|
|
940
940
|
// the recall graph reinforces on — actually-read, not merely surfaced.
|
|
941
941
|
const openedThisTurn = new Set();
|
|
942
|
-
// Reusable tools the agent RAN this turn, in order. Feeds
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
// (unlike openedThisTurn, a Set), because
|
|
942
|
+
// Reusable tools the agent RAN this turn, in order, as { name, shape }. Feeds
|
|
943
|
+
// two graphs (core/tool-graph.js): names → directed follows-edges ("you ran X
|
|
944
|
+
// — you usually run Y next"); shapes → pack↔tool edges ("in this topic, tool X
|
|
945
|
+
// is used as `…`"). Order matters here (unlike openedThisTurn, a Set), because
|
|
946
|
+
// a tool chain is a pipeline.
|
|
946
947
|
const toolRunsThisTurn = [];
|
|
947
948
|
const noteRecallFromShell = (command) => {
|
|
948
949
|
try {
|
|
@@ -965,11 +966,13 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
965
966
|
openedThisTurn.add(`entity:${slug}`);
|
|
966
967
|
notifySkill(`recall:entity:${slug}`, `📖 Recalled my notes on: ${name}`);
|
|
967
968
|
}
|
|
968
|
-
// `open-claudia tool run|exec <name>` — record
|
|
969
|
-
//
|
|
970
|
-
// next
|
|
971
|
-
|
|
972
|
-
|
|
969
|
+
// `open-claudia tool run|exec <name> <args>` — record each run as
|
|
970
|
+
// { name, shape }. The name feeds the directed follows-graph (what runs
|
|
971
|
+
// next); the sanitized command shape feeds pack↔tool capture (how a tool
|
|
972
|
+
// is used in a topic). Parsing lives in tool-graph so it stays testable.
|
|
973
|
+
try {
|
|
974
|
+
for (const run of require("./tool-graph").parseToolRuns(cmd)) toolRunsThisTurn.push(run);
|
|
975
|
+
} catch (e) { /* graph optional (old node) */ }
|
|
973
976
|
} catch (e) { /* announcements are best-effort */ }
|
|
974
977
|
};
|
|
975
978
|
|
|
@@ -1362,10 +1365,26 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1362
1365
|
// so it never bleeds into recall's spreading activation. Reinforce only on a
|
|
1363
1366
|
// successful turn, and only when ≥2 ran (a single run has no succession).
|
|
1364
1367
|
if (turnSucceeded && toolRunsThisTurn.length > 1) {
|
|
1365
|
-
try { require("./tool-graph").reinforceSequence(toolRunsThisTurn); }
|
|
1368
|
+
try { require("./tool-graph").reinforceSequence(toolRunsThisTurn.map((t) => t.name)); }
|
|
1366
1369
|
catch (e) { /* best-effort */ }
|
|
1367
1370
|
}
|
|
1368
1371
|
|
|
1372
|
+
// Pack↔tool contextual edges: a reusable tool run while a context pack was
|
|
1373
|
+
// open this turn records "this tool was used in this topic, as `<shape>`",
|
|
1374
|
+
// so next time the pack is active the discoverer can surface the concrete
|
|
1375
|
+
// invocation instead of leaving the agent to re-derive it. Fires even for a
|
|
1376
|
+
// single run (one tool-in-a-pack is still a usage) — unlike the follows-graph
|
|
1377
|
+
// above which needs a succession. Best-effort; never blocks the turn.
|
|
1378
|
+
if (turnSucceeded && toolRunsThisTurn.length && openedThisTurn.size) {
|
|
1379
|
+
try {
|
|
1380
|
+
const tg = require("./tool-graph");
|
|
1381
|
+
const packDirs = [...openedThisTurn].filter((n) => n.startsWith("pack:")).map((n) => n.slice(5));
|
|
1382
|
+
for (const dir of packDirs) {
|
|
1383
|
+
for (const run of toolRunsThisTurn) tg.recordPackTool(dir, run.name, { command: run.shape, bump: 1 });
|
|
1384
|
+
}
|
|
1385
|
+
} catch (e) { /* best-effort */ }
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1369
1388
|
// Close the learning loop: when an ABILITY pack is opened in the same turn
|
|
1370
1389
|
// as a project (context) pack, the ability was demonstrably applied while
|
|
1371
1390
|
// working on that project. recordCoUse grows the ability's applied_on, which
|
package/core/system-prompt.js
CHANGED
|
@@ -483,6 +483,20 @@ function formatPackForContext(pack, packsLib, opts = {}) {
|
|
|
483
483
|
const journal = (pack.sections.Journal || "").trim().split("\n").filter(Boolean).slice(-10).join("\n");
|
|
484
484
|
if (journal) parts.push(`#### Journal (recent)\n${journal}`);
|
|
485
485
|
}
|
|
486
|
+
// Pack↔tool usages: concrete reusable-tool invocations recorded while this
|
|
487
|
+
// pack was active, so the agent sees HOW a tool was used in this topic (not
|
|
488
|
+
// just that it exists). Strongest 2; best-effort + optional (absent on old
|
|
489
|
+
// node without node:sqlite). Compact enough to show in every mode.
|
|
490
|
+
try {
|
|
491
|
+
const usages = require("./tool-graph").packTools(pack.dir, 2);
|
|
492
|
+
if (usages && usages.length) {
|
|
493
|
+
const lines = usages.map((u) => {
|
|
494
|
+
const cmd = u.command ? `\`open-claudia tool run ${u.command}\`` : `\`open-claudia tool run ${u.tool}\``;
|
|
495
|
+
return `- ${cmd}${u.intent ? ` — ${u.intent}` : ""}`;
|
|
496
|
+
}).join("\n");
|
|
497
|
+
parts.push(`#### Tools used in this pack\n${lines}`);
|
|
498
|
+
}
|
|
499
|
+
} catch (e) { /* tool-graph optional */ }
|
|
486
500
|
return clip(parts.join("\n\n"), PACK_INJECT_MAX_CHARS);
|
|
487
501
|
}
|
|
488
502
|
|
|
@@ -699,6 +713,29 @@ function bumpFtsMissCounter(n) {
|
|
|
699
713
|
} catch (e) {}
|
|
700
714
|
}
|
|
701
715
|
|
|
716
|
+
// Spoken-reply shaping. Injected into the USER prompt (not the cached system
|
|
717
|
+
// prompt) ONLY on voice-input turns of the voice channel — i.e. exactly when the
|
|
718
|
+
// reply will be read aloud by TTS (same gate as the streaming/auto-speak path in
|
|
719
|
+
// the runner). Text channels (Telegram/Kazee) and typed turns never see it.
|
|
720
|
+
const VOICE_REPLY_GUIDANCE = [
|
|
721
|
+
"",
|
|
722
|
+
"",
|
|
723
|
+
"## Voice reply mode",
|
|
724
|
+
"This reply will be spoken aloud to the user by a text-to-speech voice. Write it to be HEARD, not read:",
|
|
725
|
+
"- Talk naturally and conversationally, like a person speaking. Keep it short — a couple of clear sentences beat a long structured answer.",
|
|
726
|
+
"- Do NOT read out code, file paths, command syntax, IDs, URLs, or variable/function names. Describe them in plain words, summarise, or skip them (e.g. say \"the recall file\" instead of spelling a path).",
|
|
727
|
+
"- No markdown or formatting — no bullets, headings, backticks, or asterisks; they are noise when spoken.",
|
|
728
|
+
"- If the answer genuinely needs code or exact detail, say so briefly and offer to send it as text rather than reading it aloud.",
|
|
729
|
+
].join("\n");
|
|
730
|
+
|
|
731
|
+
function voiceReplyGuidance() {
|
|
732
|
+
try {
|
|
733
|
+
const { currentTransport } = require("./context");
|
|
734
|
+
if (currentTransport() === "voice" && currentState().lastInputWasVoice) return VOICE_REPLY_GUIDANCE;
|
|
735
|
+
} catch (e) {}
|
|
736
|
+
return "";
|
|
737
|
+
}
|
|
738
|
+
|
|
702
739
|
async function promptWithDynamicContext(prompt, opts = {}) {
|
|
703
740
|
lastInjected = { packs: [], entities: [], recall: null };
|
|
704
741
|
try {
|
|
@@ -735,7 +772,7 @@ async function promptWithDynamicContext(prompt, opts = {}) {
|
|
|
735
772
|
? `\n\n## Memory budget\n${budget.omitted} matched memory item${budget.omitted === 1 ? " was" : "s were"} omitted to keep this turn under the recall budget (${budget.maxChars} chars). Use \`open-claudia pack show <dir>\`, \`entity show <slug>\`, or transcript search if deeper context is needed.`
|
|
736
773
|
: "";
|
|
737
774
|
const transcriptPointer = opts.includeTranscriptPointer ? `${transcriptPointerNote(state)}\n\n` : "";
|
|
738
|
-
return `${transcriptPointer}${buildDynamicContextBlock()}${packBlock}${entityBlock}${budgetNote}\n\nCurrent user request:\n${prompt}`;
|
|
775
|
+
return `${transcriptPointer}${buildDynamicContextBlock()}${packBlock}${entityBlock}${budgetNote}${voiceReplyGuidance()}\n\nCurrent user request:\n${prompt}`;
|
|
739
776
|
} catch (e) {
|
|
740
777
|
return prompt;
|
|
741
778
|
}
|
package/core/tool-graph.js
CHANGED
|
@@ -55,6 +55,24 @@ function openDb() {
|
|
|
55
55
|
)`);
|
|
56
56
|
db.exec("CREATE INDEX IF NOT EXISTS tool_edges_src ON tool_edges (src)");
|
|
57
57
|
db.exec("CREATE INDEX IF NOT EXISTS tool_edges_dst ON tool_edges (dst)");
|
|
58
|
+
// Pack↔tool contextual edges: "while pack P was open, tool T was run as
|
|
59
|
+
// <command shape>". A different physics from the tool→tool follows-edges
|
|
60
|
+
// above (this is "how a tool is used in a topic", not "what runs next"), but
|
|
61
|
+
// it shares the same DB, decay and pruning machinery. One row per distinct
|
|
62
|
+
// (pack, tool, command-shape) so several ways of using a tool in a topic can
|
|
63
|
+
// coexist and the strongest surface.
|
|
64
|
+
db.exec(`CREATE TABLE IF NOT EXISTS pack_tool_edges (
|
|
65
|
+
pack TEXT NOT NULL,
|
|
66
|
+
tool TEXT NOT NULL,
|
|
67
|
+
command TEXT NOT NULL DEFAULT '',
|
|
68
|
+
intent TEXT NOT NULL DEFAULT '',
|
|
69
|
+
weight REAL NOT NULL DEFAULT 1,
|
|
70
|
+
last_reinforced TEXT,
|
|
71
|
+
created TEXT,
|
|
72
|
+
PRIMARY KEY (pack, tool, command)
|
|
73
|
+
)`);
|
|
74
|
+
db.exec("CREATE INDEX IF NOT EXISTS pack_tool_edges_pack ON pack_tool_edges (pack)");
|
|
75
|
+
db.exec("CREATE INDEX IF NOT EXISTS pack_tool_edges_tool ON pack_tool_edges (tool)");
|
|
58
76
|
_db = db;
|
|
59
77
|
return db;
|
|
60
78
|
} catch (e) {
|
|
@@ -142,21 +160,136 @@ function predecessors(name, limit = 5) {
|
|
|
142
160
|
} catch (e) { return []; }
|
|
143
161
|
}
|
|
144
162
|
|
|
163
|
+
// ── Pack↔tool contextual edges ──────────────────────────────────────────────
|
|
164
|
+
// "While context pack P was open, reusable tool T was run as `<command shape>`."
|
|
165
|
+
// Captured automatically at end of turn (see runner.js) so the discoverer can
|
|
166
|
+
// later surface the concrete invocation for a pack — the agent sees HOW a tool
|
|
167
|
+
// is used in a topic, not merely that the tool exists. Reinforced Hebbian-style
|
|
168
|
+
// and decayed/pruned by tend() exactly like the follows-edges.
|
|
169
|
+
|
|
170
|
+
// Reduce a literal invocation to a reusable SHAPE: strip ids, paths, numbers and
|
|
171
|
+
// quoted text to placeholders so the stored command is a pattern, not a one-off
|
|
172
|
+
// with stale literals. Keeps subcommands and flag names — the structure worth
|
|
173
|
+
// remembering. Input may include a leading `[open-claudia ]tool run|exec`
|
|
174
|
+
// wrapper, which is dropped so the shape starts at the tool name.
|
|
175
|
+
function shapeCommand(raw) {
|
|
176
|
+
let s = String(raw || "").trim();
|
|
177
|
+
if (!s) return "";
|
|
178
|
+
s = s.replace(/^\s*(?:open-claudia\s+)?tool\s+(?:run|exec)\s+/i, "");
|
|
179
|
+
s = s.replace(/(["']).*?\1/g, "<text>"); // quoted strings
|
|
180
|
+
const toks = s.split(/\s+/).filter(Boolean).map((t) => {
|
|
181
|
+
if (/^--?[a-z][\w-]*$/i.test(t)) return t; // keep flag names
|
|
182
|
+
if (/^[0-9a-f]{12,}$/i.test(t)) return "<id>"; // long hex ids
|
|
183
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(t)) return "<id>"; // uuid
|
|
184
|
+
if (t.includes("/") || t.includes("\\")) return "<path>";
|
|
185
|
+
if (/^\d[\d.,:t-]*$/i.test(t)) return "<n>"; // numbers / dates
|
|
186
|
+
if (t.length >= 24) return "<arg>"; // long opaque token
|
|
187
|
+
return t;
|
|
188
|
+
});
|
|
189
|
+
return toks.join(" ").slice(0, 80).trim();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Parse `tool run|exec <name> <args...>` invocations out of a shell command,
|
|
193
|
+
// returning [{ name, shape }] in order. Splits on shell separators so one
|
|
194
|
+
// invocation's args don't bleed into the next. Feeds both the follows-graph
|
|
195
|
+
// (names) and pack↔tool capture (shapes).
|
|
196
|
+
function parseToolRuns(command) {
|
|
197
|
+
const out = [];
|
|
198
|
+
const cmd = String(command || "");
|
|
199
|
+
if (!/tool\s+(?:run|exec)\s+/i.test(cmd)) return out;
|
|
200
|
+
for (const seg of cmd.split(/&&|\|\||[;\n]/)) {
|
|
201
|
+
const m = seg.match(/\btool\s+(?:run|exec)\s+["']?([a-z0-9][\w.-]*)["']?\s*(.*)$/i);
|
|
202
|
+
if (!m) continue;
|
|
203
|
+
out.push({ name: m[1], shape: shapeCommand(`${m[1]} ${m[2] || ""}`) });
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Upsert/reinforce a pack↔tool contextual usage. `command` is the (shaped)
|
|
209
|
+
// invocation, `intent` an optional one-line why. `bump` adds Hebbian weight;
|
|
210
|
+
// absent it just ensures the edge exists. A later non-empty intent overwrites an
|
|
211
|
+
// earlier blank one so the reviewer can enrich a deterministically-captured edge.
|
|
212
|
+
function recordPackTool(pack, tool, opts = {}) {
|
|
213
|
+
const db = openDb();
|
|
214
|
+
if (!db || !pack || !tool) return false;
|
|
215
|
+
const command = String(opts.command || "").slice(0, 120);
|
|
216
|
+
const bump = Number.isFinite(opts.bump) ? opts.bump : 0;
|
|
217
|
+
const weight = Number.isFinite(opts.weight) ? opts.weight : 1;
|
|
218
|
+
try {
|
|
219
|
+
const row = db.prepare("SELECT weight, intent, last_reinforced FROM pack_tool_edges WHERE pack=? AND tool=? AND command=?").get(pack, tool, command);
|
|
220
|
+
if (row) {
|
|
221
|
+
const next = bump ? row.weight + bump : Math.max(row.weight, weight);
|
|
222
|
+
const intent = (opts.intent != null && opts.intent !== "") ? String(opts.intent).slice(0, 200) : row.intent;
|
|
223
|
+
db.prepare("UPDATE pack_tool_edges SET weight=?, intent=?, last_reinforced=? WHERE pack=? AND tool=? AND command=?")
|
|
224
|
+
.run(next, intent, bump ? now() : row.last_reinforced, pack, tool, command);
|
|
225
|
+
} else {
|
|
226
|
+
const initial = bump ? bump : weight;
|
|
227
|
+
db.prepare("INSERT INTO pack_tool_edges (pack, tool, command, intent, weight, last_reinforced, created) VALUES (?,?,?,?,?,?,?)")
|
|
228
|
+
.run(pack, tool, command, String(opts.intent || "").slice(0, 200), initial, bump ? now() : null, now());
|
|
229
|
+
}
|
|
230
|
+
return true;
|
|
231
|
+
} catch (e) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Strongest contextual tool usages recorded under a pack, for surfacing in that
|
|
237
|
+
// pack's injected context. Ordered by reinforced weight, newest first as a
|
|
238
|
+
// tiebreak. One row per distinct command shape.
|
|
239
|
+
function packTools(pack, limit = 2) {
|
|
240
|
+
const db = openDb();
|
|
241
|
+
if (!db || !pack) return [];
|
|
242
|
+
try {
|
|
243
|
+
return db.prepare(
|
|
244
|
+
"SELECT tool, command, intent, weight FROM pack_tool_edges WHERE pack=? ORDER BY weight DESC, last_reinforced DESC LIMIT ?"
|
|
245
|
+
).all(pack, limit);
|
|
246
|
+
} catch (e) { return []; }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Inverse view: which packs a tool has been used under (for `tool show`).
|
|
250
|
+
function toolPacks(tool, limit = 5) {
|
|
251
|
+
const db = openDb();
|
|
252
|
+
if (!db || !tool) return [];
|
|
253
|
+
try {
|
|
254
|
+
return db.prepare(
|
|
255
|
+
"SELECT pack, command, intent, weight FROM pack_tool_edges WHERE tool=? ORDER BY weight DESC, last_reinforced DESC LIMIT ?"
|
|
256
|
+
).all(tool, limit);
|
|
257
|
+
} catch (e) { return []; }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function removePackTool(pack, tool, command) {
|
|
261
|
+
const db = openDb();
|
|
262
|
+
if (!db) return false;
|
|
263
|
+
try { db.prepare("DELETE FROM pack_tool_edges WHERE pack=? AND tool=? AND command=?").run(pack, tool, command || ""); return true; }
|
|
264
|
+
catch (e) { return false; }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function allPackToolEdges() {
|
|
268
|
+
const db = openDb();
|
|
269
|
+
if (!db) return [];
|
|
270
|
+
try { return db.prepare("SELECT pack, tool, command, intent, weight, last_reinforced FROM pack_tool_edges").all(); }
|
|
271
|
+
catch (e) { return []; }
|
|
272
|
+
}
|
|
273
|
+
|
|
145
274
|
// Exponential time decay. Tools have no structural floor, so edges decay toward
|
|
146
275
|
// zero and are pruned once under `pruneBelow` — a chain that fell out of use
|
|
147
|
-
// disappears instead of lingering as stale advice.
|
|
276
|
+
// disappears instead of lingering as stale advice. Applies to both the tool→tool
|
|
277
|
+
// follows-edges and the pack↔tool contextual edges (same physics).
|
|
148
278
|
function decay({ halfLifeDays = DEFAULTS.halfLifeDays, pruneBelow = DEFAULTS.pruneBelow } = {}) {
|
|
149
279
|
const db = openDb();
|
|
150
280
|
if (!db) return { decayed: 0, pruned: 0 };
|
|
151
|
-
const rows = allEdges();
|
|
152
281
|
const nowMs = Date.now();
|
|
153
282
|
const ln2 = Math.log(2);
|
|
154
283
|
let decayed = 0, pruned = 0;
|
|
155
|
-
|
|
156
|
-
if (!
|
|
157
|
-
const ageDays = (nowMs - Date.parse(
|
|
158
|
-
if (!(ageDays > 0))
|
|
159
|
-
|
|
284
|
+
const decayFactor = (lastReinforced) => {
|
|
285
|
+
if (!lastReinforced) return null;
|
|
286
|
+
const ageDays = (nowMs - Date.parse(lastReinforced)) / 86400000;
|
|
287
|
+
if (!(ageDays > 0)) return null;
|
|
288
|
+
return Math.exp(-ln2 * ageDays / Math.max(1, halfLifeDays));
|
|
289
|
+
};
|
|
290
|
+
for (const e of allEdges()) {
|
|
291
|
+
const factor = decayFactor(e.last_reinforced);
|
|
292
|
+
if (factor === null) continue;
|
|
160
293
|
const next = e.weight * factor;
|
|
161
294
|
if (next < pruneBelow) {
|
|
162
295
|
if (removeEdge(e.src, e.dst)) pruned++;
|
|
@@ -169,32 +302,62 @@ function decay({ halfLifeDays = DEFAULTS.halfLifeDays, pruneBelow = DEFAULTS.pru
|
|
|
169
302
|
} catch (e2) {}
|
|
170
303
|
}
|
|
171
304
|
}
|
|
305
|
+
for (const e of allPackToolEdges()) {
|
|
306
|
+
const factor = decayFactor(e.last_reinforced);
|
|
307
|
+
if (factor === null) continue;
|
|
308
|
+
const next = e.weight * factor;
|
|
309
|
+
if (next < pruneBelow) {
|
|
310
|
+
if (removePackTool(e.pack, e.tool, e.command)) pruned++;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (Math.abs(next - e.weight) > 1e-6) {
|
|
314
|
+
try {
|
|
315
|
+
db.prepare("UPDATE pack_tool_edges SET weight=? WHERE pack=? AND tool=? AND command=?").run(next, e.pack, e.tool, e.command);
|
|
316
|
+
decayed++;
|
|
317
|
+
} catch (e2) {}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
172
320
|
return { decayed, pruned };
|
|
173
321
|
}
|
|
174
322
|
|
|
175
323
|
// 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
|
-
|
|
324
|
+
// or renamed). Keeps the graph from pointing at things that can't be run. Also
|
|
325
|
+
// drops pack↔tool edges whose tool is gone, and — when a packs lib is supplied —
|
|
326
|
+
// whose pack is gone, so a removed pack doesn't leave dangling usages.
|
|
327
|
+
function pruneOrphans(toolsLib, packsLib) {
|
|
178
328
|
const db = openDb();
|
|
179
329
|
if (!db) return 0;
|
|
180
|
-
let
|
|
181
|
-
try {
|
|
330
|
+
let liveTools;
|
|
331
|
+
try { liveTools = new Set(toolsLib.listTools().map((t) => t.name)); }
|
|
182
332
|
catch (e) { return 0; }
|
|
183
|
-
if (!
|
|
333
|
+
if (!liveTools.size) return 0;
|
|
184
334
|
let removed = 0;
|
|
185
335
|
for (const e of allEdges()) {
|
|
186
|
-
if (!
|
|
336
|
+
if (!liveTools.has(e.src) || !liveTools.has(e.dst)) {
|
|
187
337
|
if (removeEdge(e.src, e.dst)) removed++;
|
|
188
338
|
}
|
|
189
339
|
}
|
|
340
|
+
let livePacks = null;
|
|
341
|
+
if (packsLib) {
|
|
342
|
+
try { livePacks = new Set(packsLib.listPacks().map((p) => p.dir)); }
|
|
343
|
+
catch (e) { livePacks = null; }
|
|
344
|
+
}
|
|
345
|
+
for (const e of allPackToolEdges()) {
|
|
346
|
+
const toolGone = !liveTools.has(e.tool);
|
|
347
|
+
const packGone = livePacks ? !livePacks.has(e.pack) : false;
|
|
348
|
+
if (toolGone || packGone) {
|
|
349
|
+
if (removePackTool(e.pack, e.tool, e.command)) removed++;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
190
352
|
return removed;
|
|
191
353
|
}
|
|
192
354
|
|
|
193
355
|
// Nightly maintenance (called by the dream): decay reinforced weights and drop
|
|
194
|
-
// orphaned/faded edges. Deterministic + safe — no model needed.
|
|
356
|
+
// orphaned/faded edges. Deterministic + safe — no model needed. Pass
|
|
357
|
+
// { packsLib } to also prune pack↔tool edges whose pack was removed.
|
|
195
358
|
function tend(toolsLib, opts = {}) {
|
|
196
359
|
const { decayed, pruned } = decay(opts);
|
|
197
|
-
const orphaned = toolsLib ? pruneOrphans(toolsLib) : 0;
|
|
360
|
+
const orphaned = toolsLib ? pruneOrphans(toolsLib, opts.packsLib) : 0;
|
|
198
361
|
return { decayed, pruned: pruned + orphaned, ...stats() };
|
|
199
362
|
}
|
|
200
363
|
|
|
@@ -202,7 +365,7 @@ function stats() {
|
|
|
202
365
|
const rows = allEdges();
|
|
203
366
|
const nodes = new Set();
|
|
204
367
|
for (const e of rows) { nodes.add(e.src); nodes.add(e.dst); }
|
|
205
|
-
return { edges: rows.length, nodes: nodes.size };
|
|
368
|
+
return { edges: rows.length, nodes: nodes.size, packToolEdges: allPackToolEdges().length };
|
|
206
369
|
}
|
|
207
370
|
|
|
208
371
|
// Test seam.
|
|
@@ -213,5 +376,7 @@ module.exports = {
|
|
|
213
376
|
available, openDb,
|
|
214
377
|
addFollow, reinforceSequence, removeEdge, allEdges,
|
|
215
378
|
followers, predecessors, decay, pruneOrphans, tend, stats,
|
|
379
|
+
shapeCommand, parseToolRuns, recordPackTool, packTools, toolPacks,
|
|
380
|
+
removePackTool, allPackToolEdges,
|
|
216
381
|
_resetForTest,
|
|
217
382
|
};
|
package/package.json
CHANGED
package/test-tool-graph.js
CHANGED
|
@@ -93,5 +93,64 @@ assert.ok(typeof t.pruned === "number" && typeof t.decayed === "number", "tend r
|
|
|
93
93
|
const s = graph.stats();
|
|
94
94
|
assert.strictEqual(s.edges, graph.allEdges().length, "stats edge count matches allEdges");
|
|
95
95
|
|
|
96
|
+
// ══ Pack↔tool contextual edges ══════════════════════════════════════════════
|
|
97
|
+
|
|
98
|
+
// ── shapeCommand: strips literals to placeholders, keeps subcommands + flags ──
|
|
99
|
+
assert.strictEqual(
|
|
100
|
+
graph.shapeCommand("open-claudia tool run spaces docs --task-id 69abf1e25f50dd1fb617d6d0 /tmp/out"),
|
|
101
|
+
"spaces docs --task-id <id> <path>", "id + path reduced to shapes, wrapper stripped");
|
|
102
|
+
assert.strictEqual(
|
|
103
|
+
graph.shapeCommand('spaces comment --task-id 69abf1e25f50dd1fb617d6d0 "draft 1"'),
|
|
104
|
+
"spaces comment --task-id <id> <text>", "quoted text reduced, flag kept");
|
|
105
|
+
assert.ok(graph.shapeCommand("foo 12345").includes("<n>"), "bare number reduced to <n>");
|
|
106
|
+
|
|
107
|
+
// ── parseToolRuns: extracts {name, shape}, splits chained invocations ──
|
|
108
|
+
const runs = graph.parseToolRuns("open-claudia tool run spaces docs --task-id abcdef123456 /tmp/x && open-claudia tool run other list");
|
|
109
|
+
assert.strictEqual(runs.length, 2, "two chained tool runs parsed");
|
|
110
|
+
assert.strictEqual(runs[0].name, "spaces", "first run is spaces");
|
|
111
|
+
assert.strictEqual(runs[0].shape, "spaces docs --task-id <id> <path>", "first run shaped");
|
|
112
|
+
assert.strictEqual(runs[1].name, "other", "second run is other (args did not bleed across &&)");
|
|
113
|
+
assert.strictEqual(graph.parseToolRuns("ls -la").length, 0, "non-tool command yields no runs");
|
|
114
|
+
|
|
115
|
+
// ── recordPackTool: insert, then bump accumulates; packTools surfaces strongest ──
|
|
116
|
+
graph.recordPackTool("san-marco-agreements", "spaces", { command: "spaces docs --task-id <id> <path>", bump: 1 });
|
|
117
|
+
graph.recordPackTool("san-marco-agreements", "spaces", { command: "spaces docs --task-id <id> <path>", bump: 1 });
|
|
118
|
+
graph.recordPackTool("san-marco-agreements", "spaces", { command: "spaces upload --task-id <id> --comment <text> <path>", bump: 1 });
|
|
119
|
+
const pt = graph.packTools("san-marco-agreements", 2);
|
|
120
|
+
assert.strictEqual(pt.length, 2, "two distinct command shapes recorded");
|
|
121
|
+
assert.strictEqual(pt[0].command, "spaces docs --task-id <id> <path>", "most-reinforced shape surfaces first");
|
|
122
|
+
assert.strictEqual(pt[0].weight, 2, "two bumps accumulate to weight 2");
|
|
123
|
+
|
|
124
|
+
// ── intent enrichment: a later non-empty intent fills a blank one ──
|
|
125
|
+
graph.recordPackTool("san-marco-agreements", "spaces", { command: "spaces docs --task-id <id> <path>", intent: "pull contract drafts" });
|
|
126
|
+
assert.strictEqual(graph.packTools("san-marco-agreements", 1)[0].intent, "pull contract drafts", "blank intent enriched later");
|
|
127
|
+
|
|
128
|
+
// ── toolPacks: inverse view (which packs use this tool) ──
|
|
129
|
+
assert.ok(graph.toolPacks("spaces").some((u) => u.pack === "san-marco-agreements"), "toolPacks lists the pack");
|
|
130
|
+
|
|
131
|
+
// ── stats counts pack↔tool edges ──
|
|
132
|
+
assert.strictEqual(graph.stats().packToolEdges, graph.allPackToolEdges().length, "stats packToolEdges matches allPackToolEdges");
|
|
133
|
+
assert.ok(graph.stats().packToolEdges >= 2, "at least two pack↔tool edges present");
|
|
134
|
+
|
|
135
|
+
// ── decay: a year-old pack↔tool edge under the floor is pruned ──
|
|
136
|
+
const ptDb = graph.openDb();
|
|
137
|
+
ptDb.prepare("UPDATE pack_tool_edges SET last_reinforced=? WHERE pack=? AND tool=?")
|
|
138
|
+
.run(new Date(Date.now() - 365 * 86400000).toISOString(), "san-marco-agreements", "spaces");
|
|
139
|
+
const decayRes = graph.decay({ halfLifeDays: 1, pruneBelow: 0.15 });
|
|
140
|
+
assert.ok(decayRes.pruned >= 1, "stale pack↔tool edge pruned by decay");
|
|
141
|
+
assert.strictEqual(graph.packTools("san-marco-agreements").length, 0, "pruned pack↔tool edges gone");
|
|
142
|
+
|
|
143
|
+
// ── pruneOrphans: drops edges for a removed tool and (with packsLib) a removed pack ──
|
|
144
|
+
graph.recordPackTool("live-pack", "live-tool", { command: "live-tool go", bump: 1 });
|
|
145
|
+
graph.recordPackTool("live-pack", "ghost-tool", { command: "ghost-tool go", bump: 1 });
|
|
146
|
+
graph.recordPackTool("ghost-pack", "live-tool", { command: "live-tool go", bump: 1 });
|
|
147
|
+
const toolsLib = { listTools: () => [{ name: "live-tool" }] };
|
|
148
|
+
const packsLib = { listPacks: () => [{ dir: "live-pack" }] };
|
|
149
|
+
const orphans = graph.pruneOrphans(toolsLib, packsLib);
|
|
150
|
+
assert.ok(orphans >= 2, "edges for a missing tool and a missing pack are pruned");
|
|
151
|
+
assert.ok(graph.packTools("live-pack").some((u) => u.tool === "live-tool"), "live pack + live tool edge survives");
|
|
152
|
+
assert.ok(!graph.packTools("live-pack").some((u) => u.tool === "ghost-tool"), "missing-tool edge gone");
|
|
153
|
+
assert.strictEqual(graph.packTools("ghost-pack").length, 0, "missing-pack edge gone");
|
|
154
|
+
|
|
96
155
|
graph._resetForTest();
|
|
97
156
|
console.log("tool-graph OK");
|