@inetafrica/open-claudia 2.6.57 → 2.6.59

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.
@@ -30,6 +30,7 @@ let pending = null; // { resolve, reject, timer } for the in-flight walk
30
30
  let chain = Promise.resolve(); // serialises walks (one message in flight)
31
31
  let msgCount = 0; // messages sent to the current process
32
32
  let charCount = 0; // prompt chars sent to the current process
33
+ let costTotal = 0; // cumulative total_cost_usd reported by the session
33
34
 
34
35
  function isEnabled() {
35
36
  return String(process.env.RECALL_WARM_WALKER || "on").toLowerCase() !== "off";
@@ -60,6 +61,7 @@ function spawnChild(cfg) {
60
61
  child = proc;
61
62
  msgCount = 0;
62
63
  charCount = 0;
64
+ costTotal = 0;
63
65
  let buf = "";
64
66
  let asstText = "";
65
67
 
@@ -88,7 +90,14 @@ function spawnChild(cfg) {
88
90
  } else if (evt.type === "result") {
89
91
  const text = (typeof evt.result === "string" && evt.result) ? evt.result : asstText;
90
92
  asstText = "";
91
- if (text && !evt.is_error) settle("resolve", redactSensitive(String(text).trim()));
93
+ // total_cost_usd is cumulative for the stream-json session; the delta
94
+ // from the previous result is this walk's cost.
95
+ let costUsd = null;
96
+ if (typeof evt.total_cost_usd === "number") {
97
+ costUsd = Math.max(0, evt.total_cost_usd - costTotal);
98
+ costTotal = evt.total_cost_usd;
99
+ }
100
+ if (text && !evt.is_error) settle("resolve", { text: redactSensitive(String(text).trim()), costUsd });
92
101
  else settle("reject", new Error("warm walker: empty/error result"));
93
102
  }
94
103
  }
@@ -136,6 +145,8 @@ function doWalk(promptText, opts) {
136
145
 
137
146
  // Serialise: one message in flight at a time. A failed walk must not poison the
138
147
  // queue, so the chain swallows outcomes while callers still see their result.
148
+ // Resolves { text, costUsd } — costUsd is the per-walk delta of the session's
149
+ // cumulative total_cost_usd (null when the CLI doesn't report it).
139
150
  function walkWarm(promptText, opts = {}) {
140
151
  const run = () => doWalk(promptText, opts);
141
152
  const p = chain.then(run, run);
package/core/runner.js CHANGED
@@ -1027,15 +1027,17 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1027
1027
  // `open-claudia pack show <dir>` / `entity show <slug>` — so the banner
1028
1028
  // reflects what was read, not what was pushed. (consumeLastInjected is
1029
1029
  // drained here to keep the per-turn buffer from leaking into the next turn.)
1030
+ let turnRecallTier = "";
1030
1031
  try {
1031
1032
  const injected = require("./system-prompt").consumeLastInjected();
1032
1033
  if (injected && injected.recall) {
1033
1034
  const r = injected.recall;
1035
+ turnRecallTier = String(r.tier || "");
1034
1036
  const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1035
1037
  const fmt = (arr, icon) => arr.map((x) => (x.why ? `${icon} <b>${esc(x.name)}</b> — ${esc(x.why)}` : `${icon} <b>${esc(x.name)}</b>`));
1036
1038
  // 🧠 packs/entities recall — gated by /recall.
1037
1039
  if (settings.showRecall) {
1038
- const lines = [...fmt(r.packs || [], "📦"), ...fmt(r.entities || [], "👤")];
1040
+ const lines = [...fmt(r.packs || [], "📦"), ...fmt(r.entities || [], "👤"), ...fmt(r.episodes || [], "📓")];
1039
1041
  if (lines.length) send(`🧠 <b>Recall this turn</b> (${esc(r.engine)})\n${lines.join("\n")}`, telegramHtmlOpts()).catch(() => {});
1040
1042
  else if (r.gated) send(`🧠 <b>Recall</b> (${esc(r.engine)}): skipped by pre-gate — trivial turn.`, telegramHtmlOpts()).catch(() => {});
1041
1043
  }
@@ -1517,10 +1519,12 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1517
1519
  // Post-turn pack review: fire-and-forget on a cheap model; never
1518
1520
  // blocks queue drain or the next turn.
1519
1521
  if ((code === 0 || code === null) && assistantText.trim()) {
1522
+ const WRITE_TOOLS = new Set(["Edit", "Write", "NotebookEdit", "Bash", "Shell", "Skill", "Task", "Agent"]);
1520
1523
  packReview.reviewTurn({
1521
1524
  userText: prompt,
1522
1525
  assistantText,
1523
1526
  channelId,
1527
+ signals: { tier: turnRecallTier, wrote: toolUses.some((t) => WRITE_TOOLS.has(t)) },
1524
1528
  announce: (text) => chatContext.run(store, () => send(text)),
1525
1529
  });
1526
1530
  }
@@ -743,22 +743,26 @@ async function promptWithDynamicContext(prompt, opts = {}) {
743
743
  });
744
744
  const { packBlock, entityBlock } = result;
745
745
  const toolBlock = result.toolBlock || "";
746
+ const episodeBlock = result.episodeBlock || "";
746
747
  const why = result.why || {};
747
748
  lastInjected.recall = {
748
749
  engine: engine.name || recall.activeEngineName(settings),
749
750
  gated: !!result.gated,
751
+ tier: result.tier || "",
750
752
  packs: (result.packMatches || []).map((m) => ({ name: m.name || m.dir, why: why[`pack:${m.dir}`] || "" })),
751
753
  entities: (result.entityMatches || []).map((m) => ({ name: m.name || m.slug, why: why[`entity:${m.slug}`] || "" })),
752
754
  // Tools surfaced as first-class discoverer nodes this turn (name + why).
753
755
  // Drives the /tooltrace "surfaced" banner; the toolBlock below is the
754
756
  // context the agent actually sees.
755
757
  tools: (result.toolMatches || []).map((m) => ({ name: m.name, why: m.why || why[`tool:${m.name}`] || "" })),
758
+ // Past-conversation episodes the walker kept (drives the /recall banner).
759
+ episodes: (result.episodeMatches || []).map((m) => ({ name: m.name, why: m.why || "" })),
756
760
  };
757
761
  const budgetNote = budget.omitted > 0
758
762
  ? `\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.`
759
763
  : "";
760
764
  const transcriptPointer = opts.includeTranscriptPointer ? `${transcriptPointerNote(state)}\n\n` : "";
761
- return `${transcriptPointer}${buildDynamicContextBlock()}${packBlock}${entityBlock}${toolBlock}${budgetNote}${voiceReplyGuidance()}\n\nCurrent user request:\n${prompt}`;
765
+ return `${transcriptPointer}${buildDynamicContextBlock()}${packBlock}${entityBlock}${toolBlock}${episodeBlock}${budgetNote}${voiceReplyGuidance()}\n\nCurrent user request:\n${prompt}`;
762
766
  } catch (e) {
763
767
  return prompt;
764
768
  }
@@ -145,6 +145,26 @@ function rebuild(transcriptsDir = defaultTranscriptsDir()) {
145
145
  return indexAll(transcriptsDir);
146
146
  }
147
147
 
148
+ // Nightly upkeep: catch up new appends, then drop index rows for transcript
149
+ // files that no longer exist on disk — dead sessions stop matching and the
150
+ // FTS table stays proportional to what is actually recallable.
151
+ function tend(transcriptsDir = defaultTranscriptsDir()) {
152
+ const db = open(transcriptsDir);
153
+ if (!db) return { available: false, added: 0, prunedFiles: 0 };
154
+ const { added } = indexAll(transcriptsDir);
155
+ let prunedFiles = 0;
156
+ try {
157
+ const rows = db.prepare("SELECT path FROM files").all();
158
+ for (const r of rows) {
159
+ if (fs.existsSync(r.path)) continue;
160
+ db.prepare("DELETE FROM entries WHERE file = ?").run(r.path);
161
+ db.prepare("DELETE FROM files WHERE path = ?").run(r.path);
162
+ prunedFiles++;
163
+ }
164
+ } catch (e) {}
165
+ return { available: true, added, prunedFiles };
166
+ }
167
+
148
168
  // FTS5 MATCH chokes on unbalanced quotes/operators in natural queries, so
149
169
  // by default each term is quoted (implicit AND). Pass raw=true for full
150
170
  // FTS5 query syntax (NEAR, OR, prefix*).
@@ -184,4 +204,4 @@ function stats(transcriptsDir = defaultTranscriptsDir()) {
184
204
  }
185
205
  }
186
206
 
187
- module.exports = { available, open, indexFile, indexAll, rebuild, search, stats, sanitizeQuery, defaultTranscriptsDir };
207
+ module.exports = { available, open, indexFile, indexAll, rebuild, tend, search, stats, sanitizeQuery, defaultTranscriptsDir };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.57",
3
+ "version": "2.6.59",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "setup": "node setup.js",
11
11
  "start": "node bot.js",
12
- "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js"
12
+ "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-evolution.js"
13
13
  },
14
14
  "files": [
15
15
  "bot.js",
@@ -1,6 +1,9 @@
1
1
  // Discoverer engine: pre-gate, seed → (graph) → fail-open injection. The LLM
2
2
  // walker is disabled here (RECALL_DISCOVERER_WALKER=off) so the engine takes
3
- // its deterministic fail-open path: keep user-origin seeds, drop guesses.
3
+ // its deterministic fail-open path. Contract: the seeds tier keeps ALL
4
+ // user-origin keyword seeds (packs, entities, tools); the full tier without a
5
+ // judge keeps only the top-3 user-origin pack/entity seeds — an unjudged tool
6
+ // or episode is a guess, not a memory.
4
7
  const assert = require("assert");
5
8
  const fs = require("fs");
6
9
  const os = require("os");
@@ -24,13 +27,21 @@ toolsLib.addTool((() => {
24
27
  return s;
25
28
  })(), { name: "mikrotik-audit", description: "audit the mikrotik router fleet inventory" });
26
29
 
27
- // --- pre-gate ---
30
+ // --- tiered pre-gate ---
28
31
  assert.strictEqual(disc.needsRecall("", 0), false, "empty → no recall");
29
32
  assert.strictEqual(disc.needsRecall("thanks", 0), false, "pleasantry → no recall");
30
33
  assert.strictEqual(disc.needsRecall("ok", 0), false, "terse ack → no recall");
31
34
  assert.strictEqual(disc.needsRecall("yo", 0), false, "two words, no seeds → no recall");
32
35
  assert.strictEqual(disc.needsRecall("how do I deploy the mobile app", 0), true, "substantive → recall");
33
- assert.strictEqual(disc.needsRecall("k", 5), true, "seeds present always recall");
36
+ // The old gate kept ANY turn with seeds (measured: gated 0.4% of turns, so
37
+ // "thanks!" paid a full walk). New contract: pleasantries gate regardless.
38
+ assert.strictEqual(disc.needsRecall("k", 5), false, "terse ack gates even when seeds match");
39
+ assert.strictEqual(disc.recallTier("thanks!", 8), "skip", "pleasantry + seeds → skip");
40
+ assert.strictEqual(disc.recallTier("👍", 3), "skip", "emoji-only → skip");
41
+ assert.strictEqual(disc.recallTier("restart bot", 3), "seeds", "short command → seeds-only tier");
42
+ assert.strictEqual(disc.recallTier("show fup logs", 2), "seeds", "≤4 words → seeds-only tier");
43
+ assert.strictEqual(disc.recallTier("yes we need to see the 100", 2), "full", "substantive yes-prefixed turn → full");
44
+ assert.strictEqual(disc.recallTier("how do I deploy the mobile app", 4), "full", "substantive → full");
34
45
 
35
46
  // --- shared stub corpus + helpers ---
36
47
  const packs = {
@@ -93,16 +104,28 @@ const helpers = { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEnti
93
104
  });
94
105
  assert.strictEqual(typeof out2.packBlock, "string");
95
106
 
96
- // tool seeding: a tool matching the user text surfaces as a kept tool match and
97
- // renders into toolBlock; a turn that matches no tool yields an empty toolBlock
107
+ // tool seeding (seeds tier): a ≤4-word command matching a tool keeps it as a
108
+ // user-origin seed and renders the toolBlock; no tool match empty toolBlock
98
109
  const outTool = await disc.run({
99
- userText: "audit the mikrotik router fleet", contextText: "",
100
- fullContext: "audit the mikrotik router fleet", packLimit: 6, budget: {}, helpers,
110
+ userText: "audit mikrotik router fleet", contextText: "",
111
+ fullContext: "audit mikrotik router fleet", packLimit: 6, budget: {}, helpers,
101
112
  });
102
- assert.ok(outTool.toolMatches.some((m) => m.name === "mikrotik-audit"), "matching tool is kept (fail-open user seed)");
113
+ assert.strictEqual(outTool.tier, "seeds", "4-word command lands on the seeds tier");
114
+ assert.ok(outTool.toolMatches.some((m) => m.name === "mikrotik-audit"), "matching tool is kept (seeds-tier user seed)");
103
115
  assert.ok(/mikrotik-audit/.test(outTool.toolBlock), "kept tool renders into the toolBlock");
104
116
  assert.ok(/Tools that may help here/.test(outTool.toolBlock), "toolBlock carries its heading");
105
117
 
118
+ // full-tier fail-open: with no judge, an unjudged TOOL is a guess — dropped.
119
+ // (Measured before the fix: a failed walk kept 15 unjudged seeds including
120
+ // WiFi tools for a dream question.) Pack/entity user seeds still inject.
121
+ const outToolFull = await disc.run({
122
+ userText: "audit the mikrotik router fleet inventory please", contextText: "",
123
+ fullContext: "audit the mikrotik router fleet inventory please", packLimit: 6, budget: {}, helpers,
124
+ });
125
+ assert.strictEqual(outToolFull.tier, "full", "substantive ask lands on the full tier");
126
+ assert.strictEqual(outToolFull.toolMatches.length, 0, "fail-open drops unjudged tools at full tier");
127
+ assert.strictEqual(outToolFull.toolBlock, "", "no unjudged toolBlock at full tier");
128
+
106
129
  const outNoTool = await disc.run({
107
130
  userText: "help with the mobile app", contextText: "", fullContext: "help with the mobile app",
108
131
  packLimit: 6, budget: {}, helpers,
@@ -110,5 +133,26 @@ const helpers = { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEnti
110
133
  assert.strictEqual(outNoTool.toolBlock, "", "no tool match → empty toolBlock");
111
134
  assert.strictEqual(outNoTool.toolMatches.length, 0, "no tool match → no toolMatches");
112
135
 
136
+ // seeds tier: a short command-like turn skips graph+walker but still injects
137
+ // keyword seeds directly (classic baseline, ~ms path).
138
+ builtPacks = null;
139
+ const seedsTier = await disc.run({
140
+ userText: "mobile app status", contextText: "", fullContext: "mobile app status",
141
+ packLimit: 6, budget: {}, helpers,
142
+ });
143
+ assert.strictEqual(seedsTier.gated, false, "seeds tier is not gated");
144
+ assert.strictEqual(seedsTier.packBlock, "PACKBLOCK", "seeds tier injects keyword seeds");
145
+ assert.ok(seedsTier.packMatches.some((m) => m.dir === "kazee-mobile"), "seeds tier keeps the user-origin seed");
146
+ assert.strictEqual(seedsTier.episodeBlock, "", "seeds tier never injects episodes");
147
+
148
+ // episodes: with the walker off, transcript hits are candidates but the
149
+ // fail-open path must DROP them — an unjudged snippet never injects.
150
+ const epi = await disc.run({
151
+ userText: "help me deploy the mobile app updater again", contextText: "",
152
+ fullContext: "help me deploy the mobile app updater again", packLimit: 6, budget: {}, helpers,
153
+ });
154
+ assert.strictEqual(epi.episodeBlock, "", "fail-open drops unjudged episodes");
155
+ assert.deepStrictEqual(epi.episodeMatches, [], "fail-open keeps no episodeMatches");
156
+
113
157
  console.log("recall discoverer OK");
114
158
  })().catch((e) => { console.error(e); process.exit(1); });