@inetafrica/open-claudia 2.6.5 → 2.6.7
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 +6 -0
- package/core/recall-filter.js +10 -6
- package/core/runner.js +13 -7
- package/core/system-prompt.js +13 -7
- package/core/transcripts.js +33 -0
- package/package.json +1 -1
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.
|
package/core/recall-filter.js
CHANGED
|
@@ -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";
|
|
@@ -21,14 +24,15 @@ const FILTER_SYSTEM_PROMPT = [
|
|
|
21
24
|
].join("\n");
|
|
22
25
|
|
|
23
26
|
// candidates: [{ id, name, description }]. opts.context: surrounding
|
|
24
|
-
// conversation text (
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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).
|
|
28
32
|
async function judgeRelevance(message, candidates, opts = {}) {
|
|
29
33
|
if (!enabled() || candidates.length === 0) return null;
|
|
30
34
|
const msg = String(message || "").slice(0, MESSAGE_CLIP_CHARS);
|
|
31
|
-
const context = String(opts.context || "").slice(0,
|
|
35
|
+
const context = String(opts.context || "").slice(0, CONTEXT_CLIP_CHARS);
|
|
32
36
|
const lines = candidates.map((c) => `- ${c.id}: ${c.name}${c.description ? ` — ${c.description}` : ""}`);
|
|
33
37
|
const prompt = [
|
|
34
38
|
"A user sent this message to their assistant:",
|
|
@@ -38,7 +42,7 @@ async function judgeRelevance(message, candidates, opts = {}) {
|
|
|
38
42
|
...(context
|
|
39
43
|
? [
|
|
40
44
|
"",
|
|
41
|
-
"
|
|
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):",
|
|
42
46
|
"<<<",
|
|
43
47
|
context,
|
|
44
48
|
">>>",
|
package/core/runner.js
CHANGED
|
@@ -132,6 +132,7 @@ async function buildClaudeArgs(prompt, opts = {}) {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
args.push("--model", settings.model || DEFAULT_CLAUDE_MODEL);
|
|
135
|
+
if (opts.maxTurns) args.push("--max-turns", String(opts.maxTurns));
|
|
135
136
|
if (settings.effort) args.push("--effort", settings.effort);
|
|
136
137
|
if (settings.budget) args.push("--max-budget-usd", String(settings.budget));
|
|
137
138
|
if (settings.permissionMode) args.push("--permission-mode", settings.permissionMode);
|
|
@@ -276,6 +277,11 @@ function compactSeedPrompt(summary, extras = {}) {
|
|
|
276
277
|
"Treat the following summary as the prior conversation context. Do not repeat it back unless asked.",
|
|
277
278
|
"Continue from this state in future turns.",
|
|
278
279
|
"",
|
|
280
|
+
// A seed turn that starts working (e.g. resuming a CI watch from the
|
|
281
|
+
// brief) can background a long-running command, and `claude -p` won't
|
|
282
|
+
// exit while a background task runs — that wedges the message queue.
|
|
283
|
+
"RIGHT NOW: reply with ONE short acknowledgement line only. Do NOT use any tools, run any commands, or start/resume any work in this turn — even if the summary, pending tasks, or recent turns suggest obvious next steps. Work resumes with the next user message.",
|
|
284
|
+
"",
|
|
279
285
|
"Before telling the user you lack context on something they reference:",
|
|
280
286
|
"1. Check the summary below.",
|
|
281
287
|
"2. Read the full archived brief on disk (path given below, when present) — it is the detailed version of this summary.",
|
|
@@ -545,10 +551,6 @@ async function compactActiveSession(cwd, opts = {}) {
|
|
|
545
551
|
if (opts.notify) await send(opts.message || "Context is getting large, compacting first so this stays fast…");
|
|
546
552
|
const summaryRun = await runClaudeCapture(compactSummaryPrompt(), cwd, { resumeSessionId: oldSessionId, skipAutoCompact: true, label: "compact-summary", timeoutMs: COMPACT_SUMMARY_TIMEOUT });
|
|
547
553
|
summary = summaryRun.text || "No prior context was returned by the summarizer.";
|
|
548
|
-
state[sessionKey] = null;
|
|
549
|
-
state.sessionUsage = { turns: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, lastInputTokens: 0 };
|
|
550
|
-
state.isFirstMessage = true;
|
|
551
|
-
saveState();
|
|
552
554
|
} finally {
|
|
553
555
|
state.isCompacting = false;
|
|
554
556
|
saveState();
|
|
@@ -559,9 +561,13 @@ async function compactActiveSession(cwd, opts = {}) {
|
|
|
559
561
|
// Only seed with the condensed version when the full text actually made it to disk.
|
|
560
562
|
const seedSummary = (condensed && briefPath) ? condensed : (condensed ? `${fullBrief}\n\n${condensed}` : fullBrief);
|
|
561
563
|
const repoFacts = collectRepoStateFacts(cwd);
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
564
|
+
// The old session id stays in place until the seed succeeds: if the seed
|
|
565
|
+
// dies (timeout, /stop, crash) the conversation keeps resuming the
|
|
566
|
+
// pre-compaction session instead of being orphaned. maxTurns: 1 hard-stops
|
|
567
|
+
// a seed that ignores the no-work instruction and reaches for tools.
|
|
568
|
+
const seedRun = await runClaudeCapture(compactSeedPrompt(seedSummary, { briefPath, condensed: !!(condensed && briefPath), repoFacts, recentTurns }), cwd, { fresh: true, skipAutoCompact: true, label: "compact-seed", timeoutMs: COMPACT_SUMMARY_TIMEOUT, maxTurns: 1 });
|
|
569
|
+
const newSessionId = seedRun.sessionId || null;
|
|
570
|
+
state[sessionKey] = newSessionId;
|
|
565
571
|
state.isFirstMessage = false;
|
|
566
572
|
state.lastCompactedAt = Date.now();
|
|
567
573
|
state.sessionUsage = { turns: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, lastInputTokens: 0 };
|
package/core/system-prompt.js
CHANGED
|
@@ -396,10 +396,11 @@ function buildEntityBlock(matches) {
|
|
|
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
398
|
// score 2 — instant threshold). So we split: the user's own words drive
|
|
399
|
-
// recall as before,
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
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.
|
|
403
404
|
function stripRecallAnnouncements(text) {
|
|
404
405
|
return String(text || "").split("\n").filter((l) => !l.includes("📖")).join("\n").trim();
|
|
405
406
|
}
|
|
@@ -461,6 +462,11 @@ async function promptWithDynamicContext(prompt) {
|
|
|
461
462
|
lastInjected = { packs: [], entities: [] };
|
|
462
463
|
try {
|
|
463
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");
|
|
464
470
|
const packsLib = require("./packs");
|
|
465
471
|
const entitiesLib = require("./entities");
|
|
466
472
|
let packMatches = [];
|
|
@@ -468,18 +474,18 @@ async function promptWithDynamicContext(prompt) {
|
|
|
468
474
|
try {
|
|
469
475
|
packMatches = mergeMatches(
|
|
470
476
|
packsLib.matchPacks(userText, { limit: 3 }),
|
|
471
|
-
|
|
477
|
+
fullContext ? packsLib.matchPacks(fullContext, { limit: 3 }) : [],
|
|
472
478
|
(m) => m.dir,
|
|
473
479
|
);
|
|
474
480
|
} catch (e) {}
|
|
475
481
|
try {
|
|
476
482
|
entityMatches = mergeMatches(
|
|
477
483
|
entitiesLib.matchEntities(userText, { limit: 4 }),
|
|
478
|
-
|
|
484
|
+
fullContext ? entitiesLib.matchEntities(fullContext, { limit: 4 }) : [],
|
|
479
485
|
(m) => m.slug,
|
|
480
486
|
);
|
|
481
487
|
} catch (e) {}
|
|
482
|
-
({ packMatches, entityMatches } = await filterMatches(userText,
|
|
488
|
+
({ packMatches, entityMatches } = await filterMatches(userText, fullContext, packMatches, entityMatches));
|
|
483
489
|
packMatches = packMatches.slice(0, 3);
|
|
484
490
|
entityMatches = entityMatches.slice(0, 4);
|
|
485
491
|
return `${buildDynamicContextBlock()}${buildPackBlock(packMatches)}${buildEntityBlock(entityMatches)}\n\nCurrent user request:\n${prompt}`;
|
package/core/transcripts.js
CHANGED
|
@@ -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