@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 +12 -0
- package/core/recall-filter.js +75 -0
- package/core/runner.js +6 -5
- package/core/system-prompt.js +92 -6
- package/package.json +1 -1
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",
|
|
973
|
+
"--dangerously-skip-permissions", dynamicPrompt];
|
|
973
974
|
const proc = spawn(CLAUDE_PATH, args, {
|
|
974
975
|
cwd, env: claudeSubprocessEnv(),
|
|
975
976
|
stdio: ["ignore", "pipe", "pipe"],
|
package/core/system-prompt.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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