@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 +12 -0
- package/core/recall-filter.js +61 -0
- package/core/runner.js +6 -5
- package/core/system-prompt.js +55 -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,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",
|
|
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
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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