@inetafrica/open-claudia 2.6.2 → 2.6.4

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,61 @@
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 }]. Returns a Set of kept ids,
24
+ // or null to signal fail-open (keep everything).
25
+ async function judgeRelevance(message, candidates) {
26
+ if (!enabled() || candidates.length === 0) return null;
27
+ const msg = String(message || "").slice(0, MESSAGE_CLIP_CHARS);
28
+ const lines = candidates.map((c) => `- ${c.id}: ${c.name}${c.description ? ` — ${c.description}` : ""}`);
29
+ const prompt = [
30
+ "A user sent this message to their assistant:",
31
+ "<<<",
32
+ msg,
33
+ ">>>",
34
+ "",
35
+ "These stored memory notes were keyword-matched as possibly relevant.",
36
+ "Keep only the ones the message is GENUINELY about — the topic, project, or person itself.",
37
+ "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.",
38
+ "",
39
+ "Candidates:",
40
+ ...lines,
41
+ "",
42
+ `Reply with ONLY a JSON array of the relevant ids, e.g. ["${candidates[0].id}"]. Use [] if none are relevant.`,
43
+ ].join("\n");
44
+ try {
45
+ const { text } = await spawnSubagent(prompt, {
46
+ model: FILTER_MODEL,
47
+ systemPrompt: FILTER_SYSTEM_PROMPT,
48
+ timeoutMs: FILTER_TIMEOUT_MS,
49
+ });
50
+ const match = String(text || "").match(/\[[\s\S]*?\]/);
51
+ if (!match) return null;
52
+ const ids = JSON.parse(match[0]);
53
+ if (!Array.isArray(ids)) return null;
54
+ const known = new Set(candidates.map((c) => c.id));
55
+ return new Set(ids.filter((id) => typeof id === "string" && known.has(id)));
56
+ } catch (e) {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ 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"],
@@ -214,6 +214,7 @@ If you tell the user "I'll check back in N minutes" or "I'll run this every morn
214
214
 
215
215
  ## Guidelines
216
216
  - Keep responses concise — many users are on mobile.
217
+ - Be time-aware: the Runtime state block carries the current local time. Before suggesting that the user contact, test with, or wait on a colleague, check it against that person's working hours (entity notes often record their timezone). Late evening / night / early morning → schedule a wakeup for their next working morning instead of suggesting it now.
217
218
  - Markdown: use simple chat-safe formatting only. On Telegram follow the Telegram formatting section exactly; on Kazee use short bullets/paragraphs and avoid tables unless requested.
218
219
  - For long output (logs, diffs, large code), save to a file and send it as an artifact instead of pasting walls of text.
219
220
  - Act on screenshots (fix bugs, implement designs) — don't just describe what you see.
@@ -245,8 +246,13 @@ function buildDynamicContextBlock() {
245
246
  const state = currentState();
246
247
  const adapter = currentAdapter();
247
248
  const channelId = currentChannelId();
249
+ const now = new Date();
250
+ const localTime = now.toLocaleString("en-GB", { weekday: "long", hour: "2-digit", minute: "2-digit", hour12: false, timeZoneName: "short" });
251
+ const hour = now.getHours();
252
+ const offHours = hour >= 19 || hour < 7;
248
253
  const lines = [
249
254
  "## Runtime state (current turn)",
255
+ `- Local time: ${localTime}${offHours ? " — outside normal working hours: don't suggest contacting, testing with, or waiting on colleagues right now; schedule it for their next working morning instead" : ""}`,
250
256
  `- Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}`,
251
257
  `- Session: ${state.lastSessionId ? "resuming existing conversation" : "new conversation"}`,
252
258
  ];
@@ -310,10 +316,9 @@ function formatPackForContext(pack, packsLib) {
310
316
  return clip(parts.join("\n\n"), PACK_INJECT_MAX_CHARS);
311
317
  }
312
318
 
313
- function buildPackBlock(prompt) {
319
+ function buildPackBlock(matches) {
314
320
  try {
315
321
  const packsLib = require("./packs");
316
- const matches = packsLib.matchPacks(prompt, { limit: 3 });
317
322
  if (matches.length === 0) return "";
318
323
  const state = currentState();
319
324
  const adapter = currentAdapter();
@@ -357,10 +362,9 @@ function formatEntityForContext(ent) {
357
362
  return clip(parts.join("\n\n"), ENTITY_INJECT_MAX_CHARS);
358
363
  }
359
364
 
360
- function buildEntityBlock(prompt) {
365
+ function buildEntityBlock(matches) {
361
366
  try {
362
367
  const entitiesLib = require("./entities");
363
- const matches = entitiesLib.matchEntities(prompt, { limit: 4 });
364
368
  if (matches.length === 0) return "";
365
369
  const state = currentState();
366
370
  const adapter = currentAdapter();
@@ -387,10 +391,55 @@ function buildEntityBlock(prompt) {
387
391
  }
388
392
  }
389
393
 
390
- 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 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();
404
+ }
405
+
406
+ async function filterMatches(matchText, packMatches, entityMatches) {
407
+ if (packMatches.length === 0 && entityMatches.length === 0) {
408
+ return { packMatches, entityMatches };
409
+ }
410
+ try {
411
+ const packsLib = require("./packs");
412
+ const entitiesLib = require("./entities");
413
+ const candidates = [];
414
+ for (const m of packMatches) {
415
+ const p = packsLib.readPack(m.dir);
416
+ candidates.push({ id: `pack:${m.dir}`, name: m.name, description: p?.description || "" });
417
+ }
418
+ for (const m of entityMatches) {
419
+ const e = entitiesLib.readEntity(m.slug);
420
+ candidates.push({ id: `entity:${m.slug}`, name: m.name, description: e?.description || "" });
421
+ }
422
+ const kept = await require("./recall-filter").judgeRelevance(matchText, candidates);
423
+ if (!kept) return { packMatches, entityMatches }; // fail-open
424
+ return {
425
+ packMatches: packMatches.filter((m) => kept.has(`pack:${m.dir}`)),
426
+ entityMatches: entityMatches.filter((m) => kept.has(`entity:${m.slug}`)),
427
+ };
428
+ } catch (e) {
429
+ return { packMatches, entityMatches };
430
+ }
431
+ }
432
+
433
+ async function promptWithDynamicContext(prompt) {
391
434
  lastInjected = { packs: [], entities: [] };
392
435
  try {
393
- return `${buildDynamicContextBlock()}${buildPackBlock(prompt)}${buildEntityBlock(prompt)}\n\nCurrent user request:\n${prompt}`;
436
+ const matchText = recallMatchText(prompt);
437
+ let packMatches = [];
438
+ 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));
442
+ return `${buildDynamicContextBlock()}${buildPackBlock(packMatches)}${buildEntityBlock(entityMatches)}\n\nCurrent user request:\n${prompt}`;
394
443
  } catch (e) {
395
444
  return prompt;
396
445
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.2",
3
+ "version": "2.6.4",
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": {