@inetafrica/open-claudia 2.6.4 → 2.6.6

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.6.6
4
+ - **Recall: recent-turns window as judge-gated context.** v2.6.5 fixed quotes, but a bare follow-up like "Push" with no quote still recalled nothing — recall only ever saw the current message. The last ~6 user/assistant turns on this channel (read from the project transcript, current turn excluded) now feed recall the same way the quote does: keyword-matched as origin "context", kept only when the haiku judge confirms relevance, dropped on fail-open. A stale thread can never force-inject packs on keywords alone, but terse follow-ups finally inherit the topic of the conversation.
5
+
6
+ ## v2.6.5
7
+ - **Recall: replied-to quote becomes judge-gated context.** v2.6.4 stripped reply-quotes from matching entirely, which threw away real signal — replying "Push" to a release message recalled nothing. The quoted text now drives keyword matching as origin-"context" candidates that only survive when the haiku relevance judge confirms them (the judge sees the quote to resolve references like "it"/"the app"); on judge failure context-derived candidates are dropped while the user's-own-words baseline is kept, so a quote can never force-inject on keywords alone.
8
+
3
9
  ## v2.6.4
4
10
  - **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
11
  - **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.
@@ -10,6 +10,9 @@ const { spawnSubagent } = require("./subagent");
10
10
  const FILTER_MODEL = process.env.RECALL_FILTER_MODEL || "haiku";
11
11
  const FILTER_TIMEOUT_MS = Number(process.env.RECALL_FILTER_TIMEOUT_MS || 20000);
12
12
  const MESSAGE_CLIP_CHARS = 1500;
13
+ // Quote + a window of recent turns: roomier than the message clip so the
14
+ // newest history lines at the tail don't get sliced off.
15
+ const CONTEXT_CLIP_CHARS = 3500;
13
16
 
14
17
  function enabled() {
15
18
  return (process.env.RECALL_FILTER || "on").toLowerCase() !== "off";
@@ -20,21 +23,36 @@ const FILTER_SYSTEM_PROMPT = [
20
23
  "Reply with ONLY a JSON array of candidate ids — no prose, no markdown fences.",
21
24
  ].join("\n");
22
25
 
23
- // candidates: [{ id, name, description }]. Returns a Set of kept ids,
24
- // or null to signal fail-open (keep everything).
25
- async function judgeRelevance(message, candidates) {
26
+ // candidates: [{ id, name, description }]. opts.context: surrounding
27
+ // conversation text (the message the user replied to and/or the last
28
+ // few chat turns) used to disambiguate pronouns and terse follow-ups
29
+ // like "push" or "the app" — context only, not a topic source in its
30
+ // own right. Returns a Set of kept ids, or null to signal fail-open
31
+ // (caller decides what that keeps).
32
+ async function judgeRelevance(message, candidates, opts = {}) {
26
33
  if (!enabled() || candidates.length === 0) return null;
27
34
  const msg = String(message || "").slice(0, MESSAGE_CLIP_CHARS);
35
+ const context = String(opts.context || "").slice(0, CONTEXT_CLIP_CHARS);
28
36
  const lines = candidates.map((c) => `- ${c.id}: ${c.name}${c.description ? ` — ${c.description}` : ""}`);
29
37
  const prompt = [
30
38
  "A user sent this message to their assistant:",
31
39
  "<<<",
32
40
  msg,
33
41
  ">>>",
42
+ ...(context
43
+ ? [
44
+ "",
45
+ "Surrounding conversation (the message it replied to and/or the most recent turns in this chat — context for resolving references like \"the app\", \"it\", or terse follow-ups like \"push\"; judge relevance against what the user's message is about in light of it):",
46
+ "<<<",
47
+ context,
48
+ ">>>",
49
+ ]
50
+ : []),
34
51
  "",
35
52
  "These stored memory notes were keyword-matched as possibly relevant.",
36
53
  "Keep only the ones the message is GENUINELY about — the topic, project, or person itself.",
37
54
  "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.",
55
+ "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.",
38
56
  "",
39
57
  "Candidates:",
40
58
  ...lines,
@@ -395,15 +395,33 @@ function buildEntityBlock(matches) {
395
395
  // router.js wraps Telegram reply-quotes in "Replying to …:\n---\n…\n---",
396
396
  // and quoting a 📖 recall announcement echoes every entity named in it.
397
397
  // Matching recall over that text re-injects whatever was quoted (names
398
- // score 2 — instant threshold), so recall only sees the user's own words.
399
- function recallMatchText(prompt) {
400
- let text = String(prompt || "");
401
- text = text.replace(/^Replying to [^\n]*:\n---\n[\s\S]*?\n---\n+/, "");
402
- text = text.split("\n").filter((l) => !l.includes("📖")).join("\n");
403
- return text.trim();
398
+ // score 2 — instant threshold). So we split: the user's own words drive
399
+ // recall as before, while the quoted text and the last few transcript
400
+ // turns on this channel are kept separately as context. Context-derived
401
+ // candidates only survive if the LLM relevance judge confirms them —
402
+ // without a working judge they are dropped, so neither a quote nor a
403
+ // stale thread can force-inject on keywords alone.
404
+ function stripRecallAnnouncements(text) {
405
+ return String(text || "").split("\n").filter((l) => !l.includes("📖")).join("\n").trim();
404
406
  }
405
407
 
406
- async function filterMatches(matchText, packMatches, entityMatches) {
408
+ function recallMatchParts(prompt) {
409
+ const text = String(prompt || "");
410
+ const m = text.match(/^Replying to [^\n]*:\n---\n([\s\S]*?)\n---\n+/);
411
+ return {
412
+ userText: stripRecallAnnouncements(m ? text.slice(m[0].length) : text),
413
+ contextText: stripRecallAnnouncements(m ? m[1] : ""),
414
+ };
415
+ }
416
+
417
+ // packMatches/entityMatches carry an `origin` of "user" or "context".
418
+ // Fail-open keeps user-text candidates (the keyword baseline) but drops
419
+ // context-only ones — those are only trustworthy with a working judge.
420
+ async function filterMatches(userText, contextText, packMatches, entityMatches) {
421
+ const failOpen = () => ({
422
+ packMatches: packMatches.filter((m) => m.origin === "user"),
423
+ entityMatches: entityMatches.filter((m) => m.origin === "user"),
424
+ });
407
425
  if (packMatches.length === 0 && entityMatches.length === 0) {
408
426
  return { packMatches, entityMatches };
409
427
  }
@@ -419,26 +437,57 @@ async function filterMatches(matchText, packMatches, entityMatches) {
419
437
  const e = entitiesLib.readEntity(m.slug);
420
438
  candidates.push({ id: `entity:${m.slug}`, name: m.name, description: e?.description || "" });
421
439
  }
422
- const kept = await require("./recall-filter").judgeRelevance(matchText, candidates);
423
- if (!kept) return { packMatches, entityMatches }; // fail-open
440
+ const kept = await require("./recall-filter").judgeRelevance(userText, candidates, { context: contextText });
441
+ if (!kept) return failOpen();
424
442
  return {
425
443
  packMatches: packMatches.filter((m) => kept.has(`pack:${m.dir}`)),
426
444
  entityMatches: entityMatches.filter((m) => kept.has(`entity:${m.slug}`)),
427
445
  };
428
446
  } catch (e) {
429
- return { packMatches, entityMatches };
447
+ return failOpen();
448
+ }
449
+ }
450
+
451
+ function mergeMatches(primary, secondary, keyOf) {
452
+ const out = primary.map((m) => ({ ...m, origin: "user" }));
453
+ const seen = new Set(primary.map(keyOf));
454
+ for (const m of secondary) {
455
+ if (seen.has(keyOf(m))) continue;
456
+ out.push({ ...m, origin: "context" });
430
457
  }
458
+ return out;
431
459
  }
432
460
 
433
461
  async function promptWithDynamicContext(prompt) {
434
462
  lastInjected = { packs: [], entities: [] };
435
463
  try {
436
- const matchText = recallMatchText(prompt);
464
+ const { userText, contextText } = recallMatchParts(prompt);
465
+ let historyText = "";
466
+ try {
467
+ historyText = stripRecallAnnouncements(require("./transcripts").recentTranscriptText({ limit: 6 }));
468
+ } catch (e) {}
469
+ const fullContext = [contextText, historyText].filter(Boolean).join("\n\n");
470
+ const packsLib = require("./packs");
471
+ const entitiesLib = require("./entities");
437
472
  let packMatches = [];
438
473
  let entityMatches = [];
439
- try { packMatches = require("./packs").matchPacks(matchText, { limit: 3 }); } catch (e) {}
440
- try { entityMatches = require("./entities").matchEntities(matchText, { limit: 4 }); } catch (e) {}
441
- ({ packMatches, entityMatches } = await filterMatches(matchText, packMatches, entityMatches));
474
+ try {
475
+ packMatches = mergeMatches(
476
+ packsLib.matchPacks(userText, { limit: 3 }),
477
+ fullContext ? packsLib.matchPacks(fullContext, { limit: 3 }) : [],
478
+ (m) => m.dir,
479
+ );
480
+ } catch (e) {}
481
+ try {
482
+ entityMatches = mergeMatches(
483
+ entitiesLib.matchEntities(userText, { limit: 4 }),
484
+ fullContext ? entitiesLib.matchEntities(fullContext, { limit: 4 }) : [],
485
+ (m) => m.slug,
486
+ );
487
+ } catch (e) {}
488
+ ({ packMatches, entityMatches } = await filterMatches(userText, fullContext, packMatches, entityMatches));
489
+ packMatches = packMatches.slice(0, 3);
490
+ entityMatches = entityMatches.slice(0, 4);
442
491
  return `${buildDynamicContextBlock()}${buildPackBlock(packMatches)}${buildEntityBlock(entityMatches)}\n\nCurrent user request:\n${prompt}`;
443
492
  } catch (e) {
444
493
  return prompt;
@@ -58,6 +58,38 @@ function appendProjectTranscript(role, text, metadata = {}, state = currentState
58
58
  }
59
59
  }
60
60
 
61
+ // Last few user/assistant turns on THIS channel, for recall context.
62
+ // The current turn's user message is already appended by the time the
63
+ // prompt is built, so the trailing user entry is dropped — the live
64
+ // message drives recall directly as origin "user".
65
+ function recentTranscriptText({ limit = 6, perEntryChars = 280 } = {}, state = currentState()) {
66
+ const info = transcriptProjectInfo(state);
67
+ if (!info) return "";
68
+ try {
69
+ if (!fs.existsSync(info.transcriptPath)) return "";
70
+ const raw = fs.readFileSync(info.transcriptPath, "utf-8").trim();
71
+ if (!raw) return "";
72
+ const channel = String(currentChannelId() || "");
73
+ const entries = [];
74
+ for (const line of raw.split("\n").slice(-80)) {
75
+ try {
76
+ const obj = JSON.parse(line);
77
+ if (obj.role !== "user" && obj.role !== "assistant") continue;
78
+ if (channel && obj.chat?.id && String(obj.chat.id) !== channel) continue;
79
+ if (typeof obj.text !== "string" || !obj.text.trim()) continue;
80
+ entries.push(obj);
81
+ } catch (e) {}
82
+ }
83
+ if (entries.length > 0 && entries[entries.length - 1].role === "user") entries.pop();
84
+ return entries
85
+ .slice(-limit)
86
+ .map((e) => `${e.role}: ${e.text.replace(/\s+/g, " ").trim().slice(0, perEntryChars)}`)
87
+ .join("\n");
88
+ } catch (e) {
89
+ return "";
90
+ }
91
+ }
92
+
61
93
  function promptWithTranscriptPointer(prompt, state = currentState()) {
62
94
  if (!state.currentSession) return prompt;
63
95
  return projectTranscripts.withPointer(prompt, state.currentSession.dir, state.currentSession.name, state.userId);
@@ -72,6 +104,7 @@ module.exports = {
72
104
  transcriptProjectInfo,
73
105
  transcriptPointerNote,
74
106
  appendProjectTranscript,
107
+ recentTranscriptText,
75
108
  promptWithTranscriptPointer,
76
109
  stripTranscriptPointerForStorage,
77
110
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.4",
3
+ "version": "2.6.6",
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": {