@inetafrica/open-claudia 2.6.24 → 2.6.26

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/bin/pack.js CHANGED
@@ -56,6 +56,9 @@ function run(args) {
56
56
  const p = packs.findPack(rest[0]);
57
57
  if (!p) { console.error(`No pack: ${rest[0]}`); process.exitCode = 1; return; }
58
58
  console.log(fs.readFileSync(path.join(packs.PACKS_DIR, p.dir, "PACK.md"), "utf-8"));
59
+ const prov = packs.readProvenance(p.dir);
60
+ const authored = packs.SECTIONS.map((s) => `${s}=${prov.sections[s] || "user"}`).join(" ");
61
+ console.log(`\n# provenance: ${authored}${prov.lastWriter ? ` (last: ${prov.lastWriter} ${prov.ts})` : ""}`);
59
62
  break;
60
63
  }
61
64
 
package/core/dream.js CHANGED
@@ -17,6 +17,7 @@ const CONFIG_DIR = require("../config-dir");
17
17
  const { config } = require("./config");
18
18
  const packs = require("./packs");
19
19
  const entities = require("./entities");
20
+ const packGuard = require("./pack-guard");
20
21
  const persona = require("./persona");
21
22
  const { spawnSubagent } = require("./subagent");
22
23
 
@@ -62,7 +63,7 @@ function buildDreamPrompt() {
62
63
  `description: ${p.description}`,
63
64
  `tags: ${p.tags.join(", ")}`,
64
65
  `last_used: ${p.last_used || "never"} (used ${p.usage_count || 0}×)`,
65
- `Stance: ${p.sections.Stance}`,
66
+ `Stance${packs.provenanceOf(p.dir, "Stance") === "user" ? " (USER-AUTHORED — do not rewrite)" : ""}: ${p.sections.Stance}`,
66
67
  `Procedure: ${p.sections.Procedure}`,
67
68
  `State: ${p.sections.State}`,
68
69
  `Journal:\n${p.sections.Journal}`,
@@ -93,7 +94,7 @@ ${persona.loadPersona()}
93
94
 
94
95
  Your job — decide what consolidation, if any, is warranted:
95
96
 
96
- 1. merges: packs that are clearly the SAME topic under different names get merged into one. Supply the merged Stance/Procedure/State (synthesised, not concatenated; null leaves the target's section alone) and a one-sentence journal note. Be conservative: when in doubt, do not merge.
97
+ 1. merges: packs that are clearly the SAME topic under different names get merged into one. Supply the merged Stance/Procedure/State (synthesised, not concatenated; null leaves the target's section alone) and a one-sentence journal note. Be conservative: when in doubt, do not merge. A Stance marked USER-AUTHORED is off-limits — leave its stance null; the system preserves it verbatim regardless.
97
98
  2. umbrellas: when 3+ packs are siblings under one theme, create an umbrella pack whose State is a 3-6 line map of the family ("for X see pack Y"), and list its children. The umbrella is a router, not a duplicate.
98
99
  3. parents: assign an existing pack as parent of another (sub-topic relationship) without creating anything.
99
100
  4. retag: tighten descriptions and tags. The router FTS-matches incoming messages against name/description/tags, so generic words there cause false matches. Descriptions should be one specific line; tags specific nouns.
@@ -180,18 +181,25 @@ function applyDream(decision, backupRoot) {
180
181
  const into = m?.into && packs.readPack(m.into);
181
182
  const from = [].concat(m?.from || []).filter((d) => d && d !== m.into && !gone.has(d) && packs.readPack(d));
182
183
  if (!into || from.length === 0) continue;
183
- packs.updatePack(m.into, {
184
+ const mGuard = packGuard.scanSections({ stance: m.stance, procedure: m.procedure, state: m.state, journal: m.journal });
185
+ if (mGuard.flagged) {
186
+ console.warn(`[dream] guard blocked merge into ${m.into} (${mGuard.kind} in ${mGuard.section})`);
187
+ lines.push(`🛡️ Skipped merge into ${m.into} — content looked like an injected instruction (${mGuard.kind})`);
188
+ continue;
189
+ }
190
+ const mr = packs.updatePack(m.into, {
184
191
  stance: typeof m.stance === "string" ? m.stance : "",
185
192
  procedure: typeof m.procedure === "string" ? m.procedure : "",
186
193
  state: typeof m.state === "string" ? m.state : "",
187
194
  journal: m.journal || `absorbed ${from.join(", ")}`,
188
- });
195
+ }, "dream");
189
196
  for (const d of from) {
190
197
  backupPack(d, backupRoot);
191
198
  packs.removePack(d);
192
199
  gone.add(d);
193
200
  }
194
201
  lines.push(`📦 Merged ${from.join(" + ")} into ${m.into}`);
202
+ if (mr.protectedStance) lines.push(`🛡️ Kept ${m.into}'s user-authored Stance as-is`);
195
203
  } catch (e) { console.warn(`[dream] merge failed: ${e.message}`); }
196
204
  }
197
205
 
@@ -201,6 +209,12 @@ function applyDream(decision, backupRoot) {
201
209
  if (!dir || gone.has(dir)) continue;
202
210
  const children = [].concat(u?.children || []).filter((d) => d && d !== dir && !gone.has(d) && packs.readPack(d));
203
211
  if (children.length < 2) continue;
212
+ const uGuard = packGuard.scanSections({ description: u.description, state: u.state });
213
+ if (uGuard.flagged) {
214
+ console.warn(`[dream] guard blocked umbrella ${dir} (${uGuard.kind} in ${uGuard.section})`);
215
+ lines.push(`🛡️ Skipped umbrella ${dir} — content looked like an injected instruction (${uGuard.kind})`);
216
+ continue;
217
+ }
204
218
  if (!packs.readPack(dir)) {
205
219
  packs.createPack({
206
220
  dir,
@@ -209,7 +223,7 @@ function applyDream(decision, backupRoot) {
209
223
  tags: Array.isArray(u.tags) ? u.tags.slice(0, 6) : [],
210
224
  state: u.state || "",
211
225
  journal: `umbrella created over ${children.join(", ")}`,
212
- });
226
+ }, "dream");
213
227
  }
214
228
  for (const c of children) {
215
229
  const child = packs.readPack(c);
@@ -241,7 +255,12 @@ function applyDream(decision, backupRoot) {
241
255
  const desc = typeof r.description === "string" ? r.description.trim() : "";
242
256
  const tags = Array.isArray(r.tags) ? r.tags.map((t) => String(t).trim()).filter(Boolean).slice(0, 6) : [];
243
257
  if (!desc && tags.length === 0) continue;
244
- packs.updatePack(r.pack, { description: desc, tags });
258
+ const rGuard = packGuard.scanForInjection(desc, { strict: true });
259
+ if (rGuard.flagged) {
260
+ console.warn(`[dream] guard blocked retag of ${r.pack} (${rGuard.kind})`);
261
+ continue;
262
+ }
263
+ packs.updatePack(r.pack, { description: desc, tags }, "dream");
245
264
  lines.push(`🏷️ Sharpened ${r.pack}'s description/tags`);
246
265
  } catch (e) { console.warn(`[dream] retag failed: ${e.message}`); }
247
266
  }
@@ -0,0 +1,82 @@
1
+ // Content threat-scan for memory writes (spec #4).
2
+ //
3
+ // Threat model: prompt-injection rides in via tool output or a pasted doc,
4
+ // gets echoed in the assistant reply, and the background reviewer (or dream)
5
+ // faithfully writes "ignore previous instructions / always run X / exfiltrate
6
+ // to URL" into a pack section — which then auto-injects every session as if it
7
+ // were the user's own rule. Stance/Procedure/State auto-inject, so they are the
8
+ // dangerous sections; Journal is shown on demand, so it is scanned leniently
9
+ // (exfil only). We never import marketplace packs, so this is the whole surface.
10
+ //
11
+ // This is a heuristic gate, not a classifier. It is deliberately conservative:
12
+ // it only fires on phrasings that have no legitimate reason to appear in a
13
+ // memory section a model wrote about a past turn. On a hit we SKIP the write —
14
+ // a missed memory is cheap, a poisoned auto-injected rule is not.
15
+
16
+ // Imperative override / instruction-hijack phrasing.
17
+ const OVERRIDE_PATTERNS = [
18
+ /\bignore\s+(all\s+)?(previous|prior|earlier|above|the\s+following)\s+(instructions?|prompts?|rules?|context)/i,
19
+ /\bdisregard\s+(all\s+)?(previous|prior|earlier|above|your)\s+(instructions?|prompts?|rules?|training)/i,
20
+ /\bforget\s+(everything|all|your\s+(instructions?|rules?|prompt))/i,
21
+ /\boverride\s+(your|the|all)\s+(instructions?|rules?|system\s+prompt|guardrails?)/i,
22
+ /\b(you\s+(must|should|will)\s+always|always\s+remember\s+to)\b.*\b(run|execute|send|post|upload|reveal|disclose|delete|drop)\b/i,
23
+ /\bfrom\s+now\s+on\b.*\b(ignore|disregard|always|never)\b/i,
24
+ /\bdo\s+not\s+(tell|inform|ask|notify)\s+(the\s+)?(user|owner|sumeet)\b/i,
25
+ /\bsystem\s+prompt\b.*\b(ignore|reveal|print|output|leak)\b/i,
26
+ /\b(reveal|print|output|leak|disclose)\b.*\b(system\s+prompt|api\s+key|token|password|secret|credential)/i,
27
+ ];
28
+
29
+ // Exfiltration: a send/post/upload verb pointed at a URL or address. Scanned in
30
+ // every section, including Journal.
31
+ const EXFIL_PATTERNS = [
32
+ /\b(send|post|upload|exfiltrate|forward|transmit|leak|email|curl|wget|fetch)\b[^.\n]{0,60}\b(to|at|into)\b[^.\n]{0,40}(https?:\/\/|ftp:\/\/|[\w.-]+@[\w.-]+)/i,
33
+ /\b(https?:\/\/)[^\s)]+[^\s)]*\b.*\b(api\s*key|token|password|secret|credential|env)/i,
34
+ /\bcurl\b[^\n]*\b(https?:\/\/)/i,
35
+ ];
36
+
37
+ // A long unbroken base64 blob — no reason for one to live in a prose memory
38
+ // section; classic carrier for an obfuscated payload.
39
+ const BASE64_BLOB = /[A-Za-z0-9+/]{120,}={0,2}/;
40
+
41
+ function matchAny(patterns, text) {
42
+ for (const re of patterns) {
43
+ const m = re.exec(text);
44
+ if (m) return m[0].slice(0, 80);
45
+ }
46
+ return null;
47
+ }
48
+
49
+ // Scan a single piece of text. `strict` = true for auto-injected sections
50
+ // (Stance/Procedure/State), false for Journal (exfil-only).
51
+ function scanForInjection(text, { strict = true } = {}) {
52
+ const s = String(text || "");
53
+ if (!s.trim()) return { flagged: false };
54
+
55
+ const exfil = matchAny(EXFIL_PATTERNS, s);
56
+ if (exfil) return { flagged: true, kind: "exfil", evidence: exfil };
57
+
58
+ if (!strict) return { flagged: false };
59
+
60
+ const override = matchAny(OVERRIDE_PATTERNS, s);
61
+ if (override) return { flagged: true, kind: "override", evidence: override };
62
+
63
+ const b64 = BASE64_BLOB.exec(s);
64
+ if (b64) return { flagged: true, kind: "base64", evidence: b64[0].slice(0, 40) + "…" };
65
+
66
+ return { flagged: false };
67
+ }
68
+
69
+ // Scan the sections of a pending pack write. Strict on the auto-injected
70
+ // sections, lenient on Journal. Returns the first hit, or { flagged: false }.
71
+ function scanSections(sections = {}) {
72
+ const strictKeys = ["stance", "procedure", "state", "description"];
73
+ for (const key of strictKeys) {
74
+ const r = scanForInjection(sections[key], { strict: true });
75
+ if (r.flagged) return { ...r, section: key };
76
+ }
77
+ const r = scanForInjection(sections.journal, { strict: false });
78
+ if (r.flagged) return { ...r, section: "journal" };
79
+ return { flagged: false };
80
+ }
81
+
82
+ module.exports = { scanForInjection, scanSections };
@@ -9,6 +9,7 @@
9
9
  const { spawnSubagent } = require("./subagent");
10
10
  const packs = require("./packs");
11
11
  const entities = require("./entities");
12
+ const packGuard = require("./pack-guard");
12
13
  const { redactSensitive } = require("./redact");
13
14
 
14
15
  const MIN_TURN_CHARS = 400;
@@ -124,23 +125,41 @@ function parseDecision(text) {
124
125
  }
125
126
  }
126
127
 
128
+ function guardCheck(a) {
129
+ const hit = packGuard.scanSections({
130
+ stance: a.stance,
131
+ procedure: a.procedure,
132
+ state: a.state,
133
+ description: a.description,
134
+ journal: a.journal,
135
+ });
136
+ if (hit.flagged) {
137
+ console.warn(`[pack-review] guard blocked write to ${a.pack || a.dir || a.name} (${hit.kind} in ${hit.section}): ${hit.evidence}`);
138
+ }
139
+ return hit;
140
+ }
141
+
127
142
  function applyAction(a) {
128
143
  if (!a || typeof a !== "object") return null;
144
+ const guard = guardCheck(a);
145
+ if (guard.flagged) {
146
+ return { kind: "skipped", dir: a.pack || a.dir || "", name: a.name || a.pack || a.dir || "", reason: `${guard.kind} in ${guard.section}` };
147
+ }
129
148
  if (a.action === "update" && a.pack) {
130
149
  const existing = packs.readPack(a.pack);
131
150
  if (!existing) return null;
132
- packs.updatePack(a.pack, {
151
+ const r = packs.updatePack(a.pack, {
133
152
  journal: a.journal || "",
134
153
  state: typeof a.state === "string" ? a.state : "",
135
154
  stance: typeof a.stance === "string" ? a.stance : "",
136
155
  procedure: typeof a.procedure === "string" ? a.procedure : "",
137
- });
138
- return { kind: "update", dir: a.pack, name: existing.name, note: a.journal || "state updated" };
156
+ }, "reviewer");
157
+ return { kind: "update", dir: a.pack, name: existing.name, note: a.journal || "state updated", protectedStance: !!r.protectedStance };
139
158
  }
140
159
  if (a.action === "create" && (a.dir || a.name)) {
141
160
  const dir = packs.slugify(a.dir || a.name);
142
161
  if (packs.readPack(dir)) {
143
- packs.updatePack(dir, { journal: a.journal || "", state: a.state || "" });
162
+ packs.updatePack(dir, { journal: a.journal || "", state: a.state || "" }, "reviewer");
144
163
  return { kind: "update", dir, name: a.name || dir, note: a.journal || "state updated" };
145
164
  }
146
165
  const pack = packs.createPack({
@@ -152,7 +171,7 @@ function applyAction(a) {
152
171
  procedure: a.procedure || "",
153
172
  state: a.state || "",
154
173
  journal: a.journal || "",
155
- });
174
+ }, "reviewer");
156
175
  return { kind: "create", dir: pack.dir, name: pack.name, note: a.description || "" };
157
176
  }
158
177
  return null;
@@ -192,7 +211,9 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
192
211
  for (const a of decision.actions) {
193
212
  try {
194
213
  const r = applyAction(a);
195
- if (r) {
214
+ if (r && r.kind === "skipped") {
215
+ lines.push(`🛡️ Skipped a memory write that looked like an injected instruction (${r.reason}).`);
216
+ } else if (r) {
196
217
  lines.push(r.kind === "create"
197
218
  ? `📦 New pack: ${r.name}\n${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`
198
219
  : `✏️ ${r.name} — ${clipWords(r.note, 180)}`);
package/core/packs.js CHANGED
@@ -137,7 +137,7 @@ function writePack(pack) {
137
137
  return pack.dir;
138
138
  }
139
139
 
140
- function createPack({ dir, name, description, tags, stance, procedure, state, journal, parent }) {
140
+ function createPack({ dir, name, description, tags, stance, procedure, state, journal, parent }, origin = "user") {
141
141
  const d = slugify(dir || name);
142
142
  if (!d) throw new Error("pack needs a name");
143
143
  if (readPack(d)) throw new Error(`pack ${d} already exists`);
@@ -156,6 +156,7 @@ function createPack({ dir, name, description, tags, stance, procedure, state, jo
156
156
  },
157
157
  };
158
158
  writePack(pack);
159
+ setProvenance(d, SECTIONS.filter((s) => (pack.sections[s] || "").trim()), origin);
159
160
  return pack;
160
161
  }
161
162
 
@@ -163,23 +164,98 @@ function today() {
163
164
  return new Date().toISOString().slice(0, 10);
164
165
  }
165
166
 
167
+ // ---------------------------------------------------------------------------
168
+ // Provenance: a sidecar .provenance.json per pack records which origin
169
+ // (user | reviewer | dream) last authored each section. Sections we never
170
+ // explicitly stamped default to "user" — anything not proven to be automated
171
+ // is protected as if you wrote it. This is what lets the reviewer and dream
172
+ // rewrite their own output freely while never silently clobbering your words.
173
+ function provFile(dir) {
174
+ return path.join(PACKS_DIR, dir, ".provenance.json");
175
+ }
176
+
177
+ function readProvenance(dir) {
178
+ let data = {};
179
+ try { data = JSON.parse(fs.readFileSync(provFile(dir), "utf-8")) || {}; } catch (e) {}
180
+ const sections = data.sections && typeof data.sections === "object" ? data.sections : {};
181
+ return { sections, lastWriter: data.lastWriter || "", ts: data.ts || "" };
182
+ }
183
+
184
+ function provenanceOf(dir, section) {
185
+ return readProvenance(dir).sections[section] || "user";
186
+ }
187
+
188
+ function setProvenance(dir, sectionNames, origin) {
189
+ if (!origin) return;
190
+ const names = [].concat(sectionNames || []).filter(Boolean);
191
+ if (!names.length) return;
192
+ const cur = readProvenance(dir);
193
+ for (const n of names) cur.sections[n] = origin;
194
+ cur.lastWriter = origin;
195
+ cur.ts = new Date().toISOString();
196
+ try {
197
+ fs.mkdirSync(path.join(PACKS_DIR, dir), { recursive: true, mode: 0o700 });
198
+ fs.writeFileSync(provFile(dir), JSON.stringify(cur, null, 2), { mode: 0o600 });
199
+ } catch (e) {}
200
+ }
201
+
202
+ // A foreground Write/Edit to a PACK.md is you (or me acting on what you told
203
+ // me), so stamp the touched section(s) "user" — this also resets a section the
204
+ // reviewer/dream previously claimed back to user-authored once you edit it.
205
+ function recordForegroundWrite(dir, { tool, oldString, content } = {}) {
206
+ try {
207
+ if (tool === "Write" && typeof content === "string") {
208
+ const { body } = parseFrontmatter(content);
209
+ const { sections } = parseSections(body);
210
+ const applied = SECTIONS.filter((s) => sections[s] && sections[s].trim());
211
+ setProvenance(dir, applied.length ? applied : SECTIONS, "user");
212
+ return;
213
+ }
214
+ if (tool === "Edit" && typeof oldString === "string" && oldString) {
215
+ const cur = fs.readFileSync(packFile(dir), "utf-8");
216
+ const idx = cur.indexOf(oldString);
217
+ if (idx < 0) { setProvenance(dir, SECTIONS, "user"); return; }
218
+ let found = null;
219
+ for (const s of SECTIONS) {
220
+ const h = cur.indexOf(`## ${s}`);
221
+ if (h >= 0 && h <= idx && (found === null || h > found.pos)) found = { sec: s, pos: h };
222
+ }
223
+ setProvenance(dir, found ? [found.sec] : SECTIONS, "user");
224
+ }
225
+ } catch (e) {}
226
+ }
227
+
166
228
  // Apply a reviewer mutation. Only supplied fields change; journal entries
167
229
  // append (capped); state replaces (it represents "current truth").
168
- function updatePack(dir, { description, tags, stance, procedure, state, journal } = {}) {
230
+ function updatePack(dir, { description, tags, stance, procedure, state, journal } = {}, origin = "user") {
169
231
  const pack = readPack(dir);
170
232
  if (!pack) throw new Error(`no pack: ${dir}`);
171
233
  pack.updated = new Date().toISOString();
172
234
  if (description) pack.description = description;
173
235
  if (Array.isArray(tags) && tags.length) pack.tags = tags;
174
- if (typeof stance === "string" && stance.trim()) pack.sections.Stance = stance.trim();
175
- if (typeof procedure === "string" && procedure.trim()) pack.sections.Procedure = procedure.trim();
176
- if (typeof state === "string" && state.trim()) pack.sections.State = state.trim();
236
+ const applied = [];
237
+ let protectedStance = false;
238
+ if (typeof stance === "string" && stance.trim()) {
239
+ // Stance shapes behaviour, so a user-authored Stance is never silently
240
+ // overwritten by an automated writer — your intent wins.
241
+ if (origin !== "user" && provenanceOf(dir, "Stance") === "user") {
242
+ protectedStance = true;
243
+ } else {
244
+ pack.sections.Stance = stance.trim();
245
+ applied.push("Stance");
246
+ }
247
+ }
248
+ if (typeof procedure === "string" && procedure.trim()) { pack.sections.Procedure = procedure.trim(); applied.push("Procedure"); }
249
+ if (typeof state === "string" && state.trim()) { pack.sections.State = state.trim(); applied.push("State"); }
177
250
  if (typeof journal === "string" && journal.trim()) {
178
251
  const entries = pack.sections.Journal.split("\n").filter((l) => l.trim());
179
252
  entries.push(`- [${today()}] ${journal.trim().replace(/\n+/g, " ")}`);
180
253
  pack.sections.Journal = entries.slice(-MAX_JOURNAL_ENTRIES).join("\n");
254
+ applied.push("Journal");
181
255
  }
182
256
  writePack(pack);
257
+ if (applied.length) setProvenance(dir, applied, origin);
258
+ pack.protectedStance = protectedStance;
183
259
  return pack;
184
260
  }
185
261
 
@@ -396,4 +472,5 @@ module.exports = {
396
472
  listPacks, findPack, readPack, writePack, createPack, updatePack, removePack,
397
473
  touchUsed, packNameFromPath, matchPacks, reindex, markIndexDirty,
398
474
  archivePack, restorePack, listArchived,
475
+ readProvenance, provenanceOf, setProvenance, recordForegroundWrite,
399
476
  };
package/core/runner.js CHANGED
@@ -806,6 +806,13 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
806
806
  if (packDir) {
807
807
  if (packsLib.readPack(packDir)) notifySkill(`pack:${packDir}`, `✏️ Updating my notes on ${packDir}…`);
808
808
  else notifySkill(`pack:${packDir}`, `📦 Starting a new pack: ${packDir} — open-claudia pack show ${packDir} to peek.`);
809
+ try {
810
+ packsLib.recordForegroundWrite(packDir, {
811
+ tool: toolName,
812
+ oldString: input?.old_string ?? input?.oldString,
813
+ content: input?.content,
814
+ });
815
+ } catch (e) {}
809
816
  return;
810
817
  }
811
818
  const entSlug = entitiesLib.entityNameFromPath(filePath);
@@ -535,15 +535,42 @@ function mergeMatches(primary, secondary, keyOf) {
535
535
  function logRecall(msg, candPacks, candEntities, keptPacks, keptEntities) {
536
536
  if (String(process.env.RECALL_DEBUG || "on").toLowerCase() === "off") return;
537
537
  try {
538
+ // FTS-miss instrumentation (spec #6): packs the relevance judge KEPT even
539
+ // though keyword FTS scored them ~0 (or they only surfaced via context, not
540
+ // the user's words) are pure-semantic rescues. If this count climbs, a
541
+ // vector backend earns its keep; until then it stays 1-line instrumentation.
542
+ const scoreOf = new Map(candPacks.map((m) => [m.dir, m]));
543
+ const ftsMissRescues = keptPacks
544
+ .map((m) => scoreOf.get(m.dir))
545
+ .filter((c) => c && (!c.score || c.origin === "context"))
546
+ .map((c) => c.dir);
538
547
  const rec = {
539
548
  ts: new Date().toISOString(),
540
549
  msg: String(msg || "").slice(0, 200),
541
550
  candidatePacks: candPacks.map((m) => ({ dir: m.dir, score: m.score, origin: m.origin })),
542
551
  keptPacks: keptPacks.map((m) => m.dir),
552
+ ftsMissRescues,
543
553
  candidateEntities: candEntities.map((m) => ({ slug: m.slug, score: m.score, origin: m.origin })),
544
554
  keptEntities: keptEntities.map((m) => m.slug),
545
555
  };
546
556
  fs.appendFileSync(path.join(CONFIG_DIR, "recall-debug.jsonl"), JSON.stringify(rec) + "\n");
557
+ bumpFtsMissCounter(ftsMissRescues.length);
558
+ } catch (e) {}
559
+ }
560
+
561
+ // Cumulative tally so we don't have to re-scan the whole JSONL to answer
562
+ // "is keyword recall missing semantic matches often enough to warrant vectors?"
563
+ // Counts every recall turn plus how many of them rescued an FTS-missed pack.
564
+ function bumpFtsMissCounter(n) {
565
+ try {
566
+ const p = path.join(CONFIG_DIR, "recall-stats.json");
567
+ let stats = {};
568
+ try { stats = JSON.parse(fs.readFileSync(p, "utf8")) || {}; } catch (e) {}
569
+ stats.turns = (stats.turns || 0) + 1;
570
+ if (n > 0) stats.turnsWithRescue = (stats.turnsWithRescue || 0) + 1;
571
+ stats.ftsMissRescues = (stats.ftsMissRescues || 0) + n;
572
+ stats.updatedAt = new Date().toISOString();
573
+ fs.writeFileSync(p, JSON.stringify(stats, null, 2));
547
574
  } catch (e) {}
548
575
  }
549
576
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.24",
3
+ "version": "2.6.26",
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": {