@inetafrica/open-claudia 2.6.4 → 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.
@@ -20,21 +20,35 @@ const FILTER_SYSTEM_PROMPT = [
20
20
  "Reply with ONLY a JSON array of candidate ids — no prose, no markdown fences.",
21
21
  ].join("\n");
22
22
 
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) {
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 = {}) {
26
29
  if (!enabled() || candidates.length === 0) return null;
27
30
  const msg = String(message || "").slice(0, MESSAGE_CLIP_CHARS);
31
+ const context = String(opts.context || "").slice(0, MESSAGE_CLIP_CHARS);
28
32
  const lines = candidates.map((c) => `- ${c.id}: ${c.name}${c.description ? ` — ${c.description}` : ""}`);
29
33
  const prompt = [
30
34
  "A user sent this message to their assistant:",
31
35
  "<<<",
32
36
  msg,
33
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
+ : []),
34
47
  "",
35
48
  "These stored memory notes were keyword-matched as possibly relevant.",
36
49
  "Keep only the ones the message is GENUINELY about — the topic, project, or person itself.",
37
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.",
38
52
  "",
39
53
  "Candidates:",
40
54
  ...lines,
@@ -395,15 +395,32 @@ 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, 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();
404
405
  }
405
406
 
406
- async function filterMatches(matchText, packMatches, entityMatches) {
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
+ });
407
424
  if (packMatches.length === 0 && entityMatches.length === 0) {
408
425
  return { packMatches, entityMatches };
409
426
  }
@@ -419,26 +436,52 @@ async function filterMatches(matchText, packMatches, entityMatches) {
419
436
  const e = entitiesLib.readEntity(m.slug);
420
437
  candidates.push({ id: `entity:${m.slug}`, name: m.name, description: e?.description || "" });
421
438
  }
422
- const kept = await require("./recall-filter").judgeRelevance(matchText, candidates);
423
- if (!kept) return { packMatches, entityMatches }; // fail-open
439
+ const kept = await require("./recall-filter").judgeRelevance(userText, candidates, { context: contextText });
440
+ if (!kept) return failOpen();
424
441
  return {
425
442
  packMatches: packMatches.filter((m) => kept.has(`pack:${m.dir}`)),
426
443
  entityMatches: entityMatches.filter((m) => kept.has(`entity:${m.slug}`)),
427
444
  };
428
445
  } catch (e) {
429
- return { packMatches, entityMatches };
446
+ return failOpen();
430
447
  }
431
448
  }
432
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
+
433
460
  async function promptWithDynamicContext(prompt) {
434
461
  lastInjected = { packs: [], entities: [] };
435
462
  try {
436
- const matchText = recallMatchText(prompt);
463
+ const { userText, contextText } = recallMatchParts(prompt);
464
+ const packsLib = require("./packs");
465
+ const entitiesLib = require("./entities");
437
466
  let packMatches = [];
438
467
  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));
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);
442
485
  return `${buildDynamicContextBlock()}${buildPackBlock(packMatches)}${buildEntityBlock(entityMatches)}\n\nCurrent user request:\n${prompt}`;
443
486
  } catch (e) {
444
487
  return prompt;
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.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": {