@inetafrica/open-claudia 2.6.3 → 2.6.5

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.6.4
4
+ - **Recall matcher: kill false-positive pack/entity injections.** Three compounding fixes after Sharon/Euvencia/higgsfield got recalled on turns that had nothing to do with them:
5
+ - **Match only the user's own words.** The FTS routers used the full composed prompt — so a Telegram reply-quote fed the entire quoted message (~40 terms) into matching, and any entity named in the quote scored straight past the threshold. The reply-quote block and any šŸ“– recall-announcement echoes are now stripped before matching (`recallMatchText`); the verbatim prompt still goes to the model untouched.
6
+ - **Haiku relevance gate (`core/recall-filter.js`).** When keywords produce candidates, a cheap model judges which the message is genuinely about — "zoom" no longer drags in the video-gen pack via word collision. Fail-open: error/timeout/garbage keeps the keyword baseline. Costs one haiku call (~3-6s) only on turns that matched something; `RECALL_FILTER=off` to disable, `RECALL_FILTER_MODEL`/`RECALL_FILTER_TIMEOUT_MS` to tune.
7
+ - Quoting a šŸ“– announcement no longer re-injects every entity it names (the self-reinforcing feedback loop).
8
+
9
+ ## v2.6.3
10
+ - Inject local time + off-hours awareness into the runtime state block, so suggestions respect working hours.
11
+
12
+ ## v2.6.2
13
+ - Announce freshly recalled packs/entities in chat (šŸ“– line), mirroring the write-side notes.
14
+
3
15
  ## v2.6.1
4
16
  - **Fix: replying to a bot message now carries the quoted context.** Since v2.0.0 the router skipped reply context when the replied-to message came from the bot itself (assuming it was already in conversation context) — which silently dropped the link whenever that message was old or compacted away. Reply context is now always injected, labelled with provenance ("your earlier assistant message" vs "this message").
5
17
 
@@ -0,0 +1,75 @@
1
+ // LLM relevance gate over keyword recall candidates. The FTS matchers in
2
+ // packs.js/entities.js are high-recall but dumb — "zoom" pulls in the
3
+ // video-gen pack, "kazee" pulls in every colleague. When keywords produce
4
+ // candidates, a cheap model judges which are genuinely about the message.
5
+ // Fail-open: any error, timeout, or unparseable reply keeps all candidates,
6
+ // so recall never gets worse than the keyword baseline.
7
+
8
+ const { spawnSubagent } = require("./subagent");
9
+
10
+ const FILTER_MODEL = process.env.RECALL_FILTER_MODEL || "haiku";
11
+ const FILTER_TIMEOUT_MS = Number(process.env.RECALL_FILTER_TIMEOUT_MS || 20000);
12
+ const MESSAGE_CLIP_CHARS = 1500;
13
+
14
+ function enabled() {
15
+ return (process.env.RECALL_FILTER || "on").toLowerCase() !== "off";
16
+ }
17
+
18
+ const FILTER_SYSTEM_PROMPT = [
19
+ "You are a relevance judge for an assistant's memory recall.",
20
+ "Reply with ONLY a JSON array of candidate ids — no prose, no markdown fences.",
21
+ ].join("\n");
22
+
23
+ // candidates: [{ id, name, description }]. opts.context: surrounding
24
+ // conversation text (e.g. the message the user replied to) used to
25
+ // disambiguate pronouns like "the app" — context only, not a topic
26
+ // source in its own right. Returns a Set of kept ids, or null to
27
+ // signal fail-open (caller decides what that keeps).
28
+ async function judgeRelevance(message, candidates, opts = {}) {
29
+ if (!enabled() || candidates.length === 0) return null;
30
+ const msg = String(message || "").slice(0, MESSAGE_CLIP_CHARS);
31
+ const context = String(opts.context || "").slice(0, MESSAGE_CLIP_CHARS);
32
+ const lines = candidates.map((c) => `- ${c.id}: ${c.name}${c.description ? ` — ${c.description}` : ""}`);
33
+ const prompt = [
34
+ "A user sent this message to their assistant:",
35
+ "<<<",
36
+ msg,
37
+ ">>>",
38
+ ...(context
39
+ ? [
40
+ "",
41
+ "It was a reply to this earlier message (context for resolving references like \"the app\" or \"it\" — judge relevance against what the user's message is about in light of it):",
42
+ "<<<",
43
+ context,
44
+ ">>>",
45
+ ]
46
+ : []),
47
+ "",
48
+ "These stored memory notes were keyword-matched as possibly relevant.",
49
+ "Keep only the ones the message is GENUINELY about — the topic, project, or person itself.",
50
+ "Sharing an incidental word with the note (e.g. \"zoom\" matching a video tool, a person's employer matching a project name) is NOT relevance.",
51
+ "If the conversation context shows the user means a different project than a candidate (e.g. they are discussing app A, and a candidate is the unrelated app B that merely shares words like \"mobile\" or \"gradient\"), drop that candidate.",
52
+ "",
53
+ "Candidates:",
54
+ ...lines,
55
+ "",
56
+ `Reply with ONLY a JSON array of the relevant ids, e.g. ["${candidates[0].id}"]. Use [] if none are relevant.`,
57
+ ].join("\n");
58
+ try {
59
+ const { text } = await spawnSubagent(prompt, {
60
+ model: FILTER_MODEL,
61
+ systemPrompt: FILTER_SYSTEM_PROMPT,
62
+ timeoutMs: FILTER_TIMEOUT_MS,
63
+ });
64
+ const match = String(text || "").match(/\[[\s\S]*?\]/);
65
+ if (!match) return null;
66
+ const ids = JSON.parse(match[0]);
67
+ if (!Array.isArray(ids)) return null;
68
+ const known = new Set(candidates.map((c) => c.id));
69
+ return new Set(ids.filter((id) => typeof id === "string" && known.has(id)));
70
+ } catch (e) {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ module.exports = { judgeRelevance };
package/core/runner.js CHANGED
@@ -112,7 +112,7 @@ function shouldAutoCompact(state = currentState(), opts = {}) {
112
112
  return true;
113
113
  }
114
114
 
115
- function buildClaudeArgs(prompt, opts = {}) {
115
+ async function buildClaudeArgs(prompt, opts = {}) {
116
116
  const state = currentState();
117
117
  const { settings } = state;
118
118
  if (settings.backend === "cursor") return buildCursorArgs(prompt, opts);
@@ -139,7 +139,7 @@ function buildClaudeArgs(prompt, opts = {}) {
139
139
  if (settings.worktree) args.push("--worktree");
140
140
  // Dynamic state rides in the user prompt so the appended system prompt
141
141
  // stays byte-stable across turns and the prompt-cache prefix survives.
142
- args.push(promptWithDynamicContext(prompt));
142
+ args.push(await promptWithDynamicContext(prompt));
143
143
  return args;
144
144
  }
145
145
 
@@ -439,6 +439,7 @@ async function runClaudeCapture(prompt, cwd, opts = {}) {
439
439
  const authPreflight = preflightClaudeAuthMessage();
440
440
  if (authPreflight) throw new Error(authPreflight);
441
441
 
442
+ const args = await buildClaudeArgs(prompt, { ...opts, skipAutoCompact: true });
442
443
  return new Promise((resolve, reject) => {
443
444
  let assistantText = "";
444
445
  let stderrBuffer = "";
@@ -451,7 +452,6 @@ async function runClaudeCapture(prompt, cwd, opts = {}) {
451
452
  continueSession: !!opts.continueSession,
452
453
  resumeSessionId: opts.resumeSessionId || null,
453
454
  }, state, getActiveSessionId);
454
- const args = buildClaudeArgs(prompt, { ...opts, skipAutoCompact: true });
455
455
  const proc = spawn(getActiveBinary(), args, {
456
456
  cwd,
457
457
  env: subprocessEnv(),
@@ -646,7 +646,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
646
646
  } catch (e) { /* announcements are best-effort */ }
647
647
  };
648
648
 
649
- const args = buildClaudeArgs(prompt, opts);
649
+ const args = await buildClaudeArgs(prompt, opts);
650
650
  // Recall announcements: mirror the write-side "🧠 jotted down" notes with
651
651
  // a read-side line whenever packs/entities freshly enter context this turn.
652
652
  try {
@@ -966,10 +966,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
966
966
  }
967
967
 
968
968
  async function runClaudeSilent(prompt, cwd, label) {
969
+ const dynamicPrompt = await promptWithDynamicContext(prompt);
969
970
  return new Promise((resolve) => {
970
971
  const args = ["-p", "--output-format", "text", "--verbose",
971
972
  "--append-system-prompt", buildSystemPrompt(),
972
- "--dangerously-skip-permissions", promptWithDynamicContext(prompt)];
973
+ "--dangerously-skip-permissions", dynamicPrompt];
973
974
  const proc = spawn(CLAUDE_PATH, args, {
974
975
  cwd, env: claudeSubprocessEnv(),
975
976
  stdio: ["ignore", "pipe", "pipe"],
@@ -316,10 +316,9 @@ function formatPackForContext(pack, packsLib) {
316
316
  return clip(parts.join("\n\n"), PACK_INJECT_MAX_CHARS);
317
317
  }
318
318
 
319
- function buildPackBlock(prompt) {
319
+ function buildPackBlock(matches) {
320
320
  try {
321
321
  const packsLib = require("./packs");
322
- const matches = packsLib.matchPacks(prompt, { limit: 3 });
323
322
  if (matches.length === 0) return "";
324
323
  const state = currentState();
325
324
  const adapter = currentAdapter();
@@ -363,10 +362,9 @@ function formatEntityForContext(ent) {
363
362
  return clip(parts.join("\n\n"), ENTITY_INJECT_MAX_CHARS);
364
363
  }
365
364
 
366
- function buildEntityBlock(prompt) {
365
+ function buildEntityBlock(matches) {
367
366
  try {
368
367
  const entitiesLib = require("./entities");
369
- const matches = entitiesLib.matchEntities(prompt, { limit: 4 });
370
368
  if (matches.length === 0) return "";
371
369
  const state = currentState();
372
370
  const adapter = currentAdapter();
@@ -393,10 +391,98 @@ function buildEntityBlock(prompt) {
393
391
  }
394
392
  }
395
393
 
396
- function promptWithDynamicContext(prompt) {
394
+ // The composed prompt can carry text the user didn't write this turn:
395
+ // router.js wraps Telegram reply-quotes in "Replying to …:\n---\n…\n---",
396
+ // and quoting a šŸ“– recall announcement echoes every entity named in it.
397
+ // Matching recall over that text re-injects whatever was quoted (names
398
+ // score 2 — instant threshold). So we split: the user's own words drive
399
+ // recall as before, and the quoted text is kept separately as context.
400
+ // Context-derived candidates only survive if the LLM relevance judge
401
+ // confirms them — without a working judge they are dropped, so a quote
402
+ // can never force-inject on keywords alone.
403
+ function stripRecallAnnouncements(text) {
404
+ return String(text || "").split("\n").filter((l) => !l.includes("šŸ“–")).join("\n").trim();
405
+ }
406
+
407
+ function recallMatchParts(prompt) {
408
+ const text = String(prompt || "");
409
+ const m = text.match(/^Replying to [^\n]*:\n---\n([\s\S]*?)\n---\n+/);
410
+ return {
411
+ userText: stripRecallAnnouncements(m ? text.slice(m[0].length) : text),
412
+ contextText: stripRecallAnnouncements(m ? m[1] : ""),
413
+ };
414
+ }
415
+
416
+ // packMatches/entityMatches carry an `origin` of "user" or "context".
417
+ // Fail-open keeps user-text candidates (the keyword baseline) but drops
418
+ // context-only ones — those are only trustworthy with a working judge.
419
+ async function filterMatches(userText, contextText, packMatches, entityMatches) {
420
+ const failOpen = () => ({
421
+ packMatches: packMatches.filter((m) => m.origin === "user"),
422
+ entityMatches: entityMatches.filter((m) => m.origin === "user"),
423
+ });
424
+ if (packMatches.length === 0 && entityMatches.length === 0) {
425
+ return { packMatches, entityMatches };
426
+ }
427
+ try {
428
+ const packsLib = require("./packs");
429
+ const entitiesLib = require("./entities");
430
+ const candidates = [];
431
+ for (const m of packMatches) {
432
+ const p = packsLib.readPack(m.dir);
433
+ candidates.push({ id: `pack:${m.dir}`, name: m.name, description: p?.description || "" });
434
+ }
435
+ for (const m of entityMatches) {
436
+ const e = entitiesLib.readEntity(m.slug);
437
+ candidates.push({ id: `entity:${m.slug}`, name: m.name, description: e?.description || "" });
438
+ }
439
+ const kept = await require("./recall-filter").judgeRelevance(userText, candidates, { context: contextText });
440
+ if (!kept) return failOpen();
441
+ return {
442
+ packMatches: packMatches.filter((m) => kept.has(`pack:${m.dir}`)),
443
+ entityMatches: entityMatches.filter((m) => kept.has(`entity:${m.slug}`)),
444
+ };
445
+ } catch (e) {
446
+ return failOpen();
447
+ }
448
+ }
449
+
450
+ function mergeMatches(primary, secondary, keyOf) {
451
+ const out = primary.map((m) => ({ ...m, origin: "user" }));
452
+ const seen = new Set(primary.map(keyOf));
453
+ for (const m of secondary) {
454
+ if (seen.has(keyOf(m))) continue;
455
+ out.push({ ...m, origin: "context" });
456
+ }
457
+ return out;
458
+ }
459
+
460
+ async function promptWithDynamicContext(prompt) {
397
461
  lastInjected = { packs: [], entities: [] };
398
462
  try {
399
- return `${buildDynamicContextBlock()}${buildPackBlock(prompt)}${buildEntityBlock(prompt)}\n\nCurrent user request:\n${prompt}`;
463
+ const { userText, contextText } = recallMatchParts(prompt);
464
+ const packsLib = require("./packs");
465
+ const entitiesLib = require("./entities");
466
+ let packMatches = [];
467
+ let entityMatches = [];
468
+ try {
469
+ packMatches = mergeMatches(
470
+ packsLib.matchPacks(userText, { limit: 3 }),
471
+ contextText ? packsLib.matchPacks(contextText, { limit: 3 }) : [],
472
+ (m) => m.dir,
473
+ );
474
+ } catch (e) {}
475
+ try {
476
+ entityMatches = mergeMatches(
477
+ entitiesLib.matchEntities(userText, { limit: 4 }),
478
+ contextText ? entitiesLib.matchEntities(contextText, { limit: 4 }) : [],
479
+ (m) => m.slug,
480
+ );
481
+ } catch (e) {}
482
+ ({ packMatches, entityMatches } = await filterMatches(userText, contextText, packMatches, entityMatches));
483
+ packMatches = packMatches.slice(0, 3);
484
+ entityMatches = entityMatches.slice(0, 4);
485
+ return `${buildDynamicContextBlock()}${buildPackBlock(packMatches)}${buildEntityBlock(entityMatches)}\n\nCurrent user request:\n${prompt}`;
400
486
  } catch (e) {
401
487
  return prompt;
402
488
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.3",
3
+ "version": "2.6.5",
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": {