@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 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 the directed
943
- // tool-graph (core/tool-graph.js): consecutive runs become follows-edges so
944
- // "you ran X — you usually run Y next" can be surfaced. Order matters here
945
- // (unlike openedThisTurn, a Set), because a tool chain is a pipeline.
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 the run for the directed
969
- // tool-graph. Only the name is captured (args are noise for "what runs
970
- // next"); the end-of-turn block reinforces consecutive pairs.
971
- const toolRe = /\btool\s+(?:run|exec)\s+["']?([a-z0-9][\w.-]*)/gi;
972
- while ((m = toolRe.exec(cmd))) toolRunsThisTurn.push(m[1]);
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
@@ -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
  }
@@ -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
- 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));
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
- function pruneOrphans(toolsLib) {
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 live;
181
- try { live = new Set(toolsLib.listTools().map((t) => t.name)); }
330
+ let liveTools;
331
+ try { liveTools = new Set(toolsLib.listTools().map((t) => t.name)); }
182
332
  catch (e) { return 0; }
183
- if (!live.size) return 0;
333
+ if (!liveTools.size) return 0;
184
334
  let removed = 0;
185
335
  for (const e of allEdges()) {
186
- if (!live.has(e.src) || !live.has(e.dst)) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.51",
3
+ "version": "2.6.53",
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": {
@@ -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");