switchroom 0.21.19 → 0.21.20
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/dist/cli/switchroom.js +98 -2
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +4 -4
- package/vendor/hindsight-memory/.claude-plugin/plugin.json +1 -1
- package/vendor/hindsight-memory/CHANGELOG.md +20 -0
- package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +26 -3
- package/vendor/hindsight-memory/scripts/prefetch.py +7 -1
- package/vendor/hindsight-memory/scripts/recall.py +87 -0
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_invalidation.py +7 -2
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +1 -1
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_topic_guard.py +279 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +7 -4
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.21.
|
|
2123
|
+
var VERSION = "0.21.20", COMMIT_SHA = "fa05da22", COMMIT_DATE = "2026-08-18T20:00:16Z";
|
|
2124
2124
|
|
|
2125
2125
|
// src/cli/resolve-version.ts
|
|
2126
2126
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -30555,16 +30555,25 @@ function resolveHindsightVendorResolution() {
|
|
|
30555
30555
|
execPath: process.execPath
|
|
30556
30556
|
});
|
|
30557
30557
|
}
|
|
30558
|
+
function removeVendoredHindsightPlugin(agentDir) {
|
|
30559
|
+
const destPath = join15(agentDir, ".claude", "plugins", "hindsight-memory");
|
|
30560
|
+
if (existsSync20(destPath)) {
|
|
30561
|
+
rmSync7(destPath, { recursive: true, force: true });
|
|
30562
|
+
}
|
|
30563
|
+
}
|
|
30558
30564
|
function installHindsightPlugin(agentName, agentDir, switchroomConfig, resolvedAgentConfig) {
|
|
30559
30565
|
if (!switchroomConfig)
|
|
30560
30566
|
return null;
|
|
30561
30567
|
const memory = switchroomConfig.memory;
|
|
30562
|
-
if (!isHindsightEnabled(switchroomConfig))
|
|
30568
|
+
if (!isHindsightEnabled(switchroomConfig)) {
|
|
30569
|
+
removeVendoredHindsightPlugin(agentDir);
|
|
30563
30570
|
return null;
|
|
30571
|
+
}
|
|
30564
30572
|
if (!memory)
|
|
30565
30573
|
return null;
|
|
30566
30574
|
const agentMemory = switchroomConfig.agents[agentName]?.memory;
|
|
30567
30575
|
if (resolveHindsightAutoRecall(switchroomConfig, agentName, resolvedAgentConfig) === false) {
|
|
30576
|
+
removeVendoredHindsightPlugin(agentDir);
|
|
30568
30577
|
return null;
|
|
30569
30578
|
}
|
|
30570
30579
|
const vendorResolution = resolveHindsightVendorResolution();
|
|
@@ -63817,6 +63826,92 @@ function detectPrefetchAsyncTimeoutDrift(name, agentConfig, agentDir, config) {
|
|
|
63817
63826
|
}
|
|
63818
63827
|
];
|
|
63819
63828
|
}
|
|
63829
|
+
function hashPluginScriptsTree(scriptsRoot) {
|
|
63830
|
+
if (!existsSync69(scriptsRoot))
|
|
63831
|
+
return null;
|
|
63832
|
+
const out = new Map;
|
|
63833
|
+
const skipDir = (n) => n === "__pycache__";
|
|
63834
|
+
const skipFile = (n) => n.endsWith(".pyc") || n.includes(".bak");
|
|
63835
|
+
const walk = (dir, rel) => {
|
|
63836
|
+
let entries;
|
|
63837
|
+
try {
|
|
63838
|
+
entries = readdirSync27(dir, { withFileTypes: true });
|
|
63839
|
+
} catch {
|
|
63840
|
+
return;
|
|
63841
|
+
}
|
|
63842
|
+
for (const ent of entries) {
|
|
63843
|
+
const childRel = rel === "" ? ent.name : `${rel}/${ent.name}`;
|
|
63844
|
+
const childAbs = join66(dir, ent.name);
|
|
63845
|
+
if (ent.isDirectory()) {
|
|
63846
|
+
if (skipDir(ent.name))
|
|
63847
|
+
continue;
|
|
63848
|
+
walk(childAbs, childRel);
|
|
63849
|
+
} else if (ent.isFile()) {
|
|
63850
|
+
if (skipFile(ent.name))
|
|
63851
|
+
continue;
|
|
63852
|
+
try {
|
|
63853
|
+
out.set(childRel, createHash13("sha256").update(readFileSync62(childAbs)).digest("hex"));
|
|
63854
|
+
} catch {
|
|
63855
|
+
out.set(childRel, "unreadable");
|
|
63856
|
+
}
|
|
63857
|
+
}
|
|
63858
|
+
}
|
|
63859
|
+
};
|
|
63860
|
+
walk(scriptsRoot, "");
|
|
63861
|
+
return out;
|
|
63862
|
+
}
|
|
63863
|
+
function detectHindsightPluginTreeDrift(name, agentDir) {
|
|
63864
|
+
const pluginDir = join66(agentDir, ".claude", "plugins", "hindsight-memory");
|
|
63865
|
+
if (!existsSync69(pluginDir))
|
|
63866
|
+
return [];
|
|
63867
|
+
const releaseResolution = resolveHindsightVendorResolution();
|
|
63868
|
+
if (releaseResolution.path === null) {
|
|
63869
|
+
return [];
|
|
63870
|
+
}
|
|
63871
|
+
const releaseScripts = join66(releaseResolution.path, "scripts");
|
|
63872
|
+
const deployedScripts = join66(pluginDir, "scripts");
|
|
63873
|
+
const releaseHashes = hashPluginScriptsTree(releaseScripts);
|
|
63874
|
+
if (releaseHashes === null || releaseHashes.size === 0) {
|
|
63875
|
+
return [];
|
|
63876
|
+
}
|
|
63877
|
+
const deployedHashes = hashPluginScriptsTree(deployedScripts) ?? new Map;
|
|
63878
|
+
const missing = [];
|
|
63879
|
+
const changed = [];
|
|
63880
|
+
const extra = [];
|
|
63881
|
+
for (const [rel, hash2] of releaseHashes) {
|
|
63882
|
+
const got = deployedHashes.get(rel);
|
|
63883
|
+
if (got === undefined)
|
|
63884
|
+
missing.push(rel);
|
|
63885
|
+
else if (got !== hash2)
|
|
63886
|
+
changed.push(rel);
|
|
63887
|
+
}
|
|
63888
|
+
for (const rel of deployedHashes.keys()) {
|
|
63889
|
+
if (!releaseHashes.has(rel))
|
|
63890
|
+
extra.push(rel);
|
|
63891
|
+
}
|
|
63892
|
+
if (missing.length === 0 && changed.length === 0 && extra.length === 0) {
|
|
63893
|
+
return [];
|
|
63894
|
+
}
|
|
63895
|
+
const parts = [];
|
|
63896
|
+
const summarise = (label, items) => {
|
|
63897
|
+
if (items.length === 0)
|
|
63898
|
+
return;
|
|
63899
|
+
const shown = items.slice(0, 6).sort();
|
|
63900
|
+
const more = items.length > shown.length ? ` (+${items.length - shown.length} more)` : "";
|
|
63901
|
+
parts.push(`${label}: ${shown.join(", ")}${more}`);
|
|
63902
|
+
};
|
|
63903
|
+
summarise("missing", missing);
|
|
63904
|
+
summarise("changed", changed);
|
|
63905
|
+
summarise("stale-extra", extra);
|
|
63906
|
+
return [
|
|
63907
|
+
{
|
|
63908
|
+
surface: "memory-plugin-build",
|
|
63909
|
+
agent: name,
|
|
63910
|
+
detail: `vendored hindsight-memory plugin scripts/ tree does NOT match the ` + `release build (manifest version is not a reliable signal \u2014 it can ` + `sit unchanged across builds): ${parts.join("; ")}`,
|
|
63911
|
+
fix: "Re-vendor the plugin: `switchroom apply` re-copies the release " + "`scripts/` tree into this agent (or removes it entirely when the " + "agent has memory turned off), then restart the agent " + "(`switchroom agent restart " + name + "`). A tree that keeps drifting after apply means the agent's memory " + "config and its on-disk plugin disagree \u2014 check memory.backend / " + "memory.auto_recall for this agent."
|
|
63912
|
+
}
|
|
63913
|
+
];
|
|
63914
|
+
}
|
|
63820
63915
|
function writeDriftReport(agentDir, findings) {
|
|
63821
63916
|
try {
|
|
63822
63917
|
const report = {
|
|
@@ -63841,6 +63936,7 @@ function detectAgentDrift(name, agentConfigRaw, agentsDir, config, configPath, o
|
|
|
63841
63936
|
findings.push(...detectSkillsDrift(name, agentDir));
|
|
63842
63937
|
findings.push(...detectHindsightRecallTunableDrift(name, agentConfig, agentDir, config));
|
|
63843
63938
|
findings.push(...detectPrefetchAsyncTimeoutDrift(name, agentConfig, agentDir, config));
|
|
63939
|
+
findings.push(...detectHindsightPluginTreeDrift(name, agentDir));
|
|
63844
63940
|
if (!opts.skipContainerProbes) {
|
|
63845
63941
|
findings.push(...detectHookScriptDrift(name, {
|
|
63846
63942
|
binDir: opts.binDir,
|
|
@@ -21598,7 +21598,7 @@ function allocateAgentUid(name) {
|
|
|
21598
21598
|
}
|
|
21599
21599
|
|
|
21600
21600
|
// src/build-info.ts
|
|
21601
|
-
var VERSION = "0.21.
|
|
21601
|
+
var VERSION = "0.21.20";
|
|
21602
21602
|
|
|
21603
21603
|
// src/setup/hindsight-recall-passthrough.ts
|
|
21604
21604
|
var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.21.
|
|
4
|
+
"version": "0.21.20",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -105918,10 +105918,10 @@ function startOutboxSweep(deps) {
|
|
|
105918
105918
|
}
|
|
105919
105919
|
|
|
105920
105920
|
// ../src/build-info.ts
|
|
105921
|
-
var VERSION2 = "0.21.
|
|
105922
|
-
var COMMIT_SHA = "
|
|
105923
|
-
var COMMIT_DATE = "2026-08-
|
|
105924
|
-
var LATEST_PR =
|
|
105921
|
+
var VERSION2 = "0.21.20";
|
|
105922
|
+
var COMMIT_SHA = "fa05da22";
|
|
105923
|
+
var COMMIT_DATE = "2026-08-18T20:00:16Z";
|
|
105924
|
+
var LATEST_PR = 4782;
|
|
105925
105925
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
105926
105926
|
|
|
105927
105927
|
// gateway/boot-version.ts
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hindsight-memory",
|
|
3
3
|
"description": "Automatic long-term memory for Claude Code via Hindsight. Recalls relevant memories before each prompt and retains conversation transcripts after each response.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"author": {"name": "Hindsight Team", "url": "https://vectorize.io/hindsight"},
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": ["memory", "hindsight", "recall", "retain"]
|
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
> **Versioning scheme (switchroom, #4779).** The manifest `version` in
|
|
4
|
+
> `.claude-plugin/plugin.json` MUST be bumped on every substantive change to
|
|
5
|
+
> this vendored tree — new/removed `scripts/`, changed hook wiring, or any
|
|
6
|
+
> behaviour an agent would observe. It sat frozen at `0.4.0` across the entire
|
|
7
|
+
> M4/M5 async-recall-prefetch rewrite, so a version-string check could not tell
|
|
8
|
+
> a June pre-M4 tree from the shipped build and `test-harness` silently ran the
|
|
9
|
+
> stale one. The version is only a COARSE signal, though — the authoritative
|
|
10
|
+
> drift guard is the `scripts/`-tree hash in
|
|
11
|
+
> `detectHindsightPluginTreeDrift` (`src/agents/drift.ts`), surfaced by
|
|
12
|
+
> `switchroom doctor` and the gateway boot-card. Bump the version AND rely on
|
|
13
|
+
> the hash; never the version alone.
|
|
14
|
+
|
|
3
15
|
## [Unreleased]
|
|
4
16
|
|
|
17
|
+
### Changed (switchroom divergence)
|
|
18
|
+
|
|
19
|
+
- **Manifest version bumped `0.4.0` → `0.5.0`** to reflect the M4/M5
|
|
20
|
+
async-recall-prefetch tree (adds `scripts/prefetch.py`,
|
|
21
|
+
`scripts/lib/recall_buffer.py`, `scripts/orientation.py`, and the
|
|
22
|
+
`memoryPrefetch*` settings). Per the scheme note above this is the first
|
|
23
|
+
bump of the discipline that stops build drift from going invisible (#4779).
|
|
24
|
+
|
|
5
25
|
### Added (switchroom divergence)
|
|
6
26
|
|
|
7
27
|
- **Per-row `observation_scopes` on every retain.** Hindsight accepts and
|
|
@@ -93,13 +93,27 @@ def _sentinel_path(session_id: str) -> str:
|
|
|
93
93
|
return os.path.join(buffer_dir(), f"{_safe_session(session_id)}.buffer.done")
|
|
94
94
|
|
|
95
95
|
|
|
96
|
-
def write_buffer(
|
|
96
|
+
def write_buffer(
|
|
97
|
+
session_id: str,
|
|
98
|
+
context: str,
|
|
99
|
+
telemetry: Optional[dict] = None,
|
|
100
|
+
query: Optional[str] = None,
|
|
101
|
+
) -> None:
|
|
97
102
|
"""Write the prefetched recall payload for ``session_id``.
|
|
98
103
|
|
|
99
104
|
Atomic (temp file + ``os.replace`` within the same directory). Does NOT
|
|
100
105
|
write the sentinel — the caller MUST call ``write_sentinel`` after this,
|
|
101
106
|
and only once the payload write has returned, to preserve the
|
|
102
107
|
read-after-write ordering guarantee.
|
|
108
|
+
|
|
109
|
+
``query`` (#4778) is the speculative query the producer used to build this
|
|
110
|
+
buffer. It is stored verbatim so the consumer can gate the join on topical
|
|
111
|
+
similarity between it and turn N+1's ACTUAL prompt — the buffer is keyed by
|
|
112
|
+
``session_id`` alone and, without this, a fresh buffer built for the prior
|
|
113
|
+
turn is served on a topic pivot regardless of relevance. A ``None``/absent
|
|
114
|
+
query is stored as ``""``, which the consumer treats as "cannot establish a
|
|
115
|
+
topic match" and falls through to synchronous recall (fail-safe; also the
|
|
116
|
+
backward-compat behaviour for buffers written before this field existed).
|
|
103
117
|
"""
|
|
104
118
|
d = _ensure_dir()
|
|
105
119
|
final = _buffer_path(session_id)
|
|
@@ -108,6 +122,7 @@ def write_buffer(session_id: str, context: str, telemetry: Optional[dict] = None
|
|
|
108
122
|
"schema": SCHEMA,
|
|
109
123
|
"session_id": session_id,
|
|
110
124
|
"context": context,
|
|
125
|
+
"query": query or "",
|
|
111
126
|
"telemetry": telemetry or {},
|
|
112
127
|
"written_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
113
128
|
}
|
|
@@ -187,7 +202,11 @@ def sentinel_exists(session_id: str) -> bool:
|
|
|
187
202
|
def read_if_fresh(session_id: str, last_consumed_token: Optional[int]) -> tuple:
|
|
188
203
|
"""Return ``(payload_dict | None, current_token)``.
|
|
189
204
|
|
|
190
|
-
``payload_dict`` (when present) is
|
|
205
|
+
``payload_dict`` (when present) is
|
|
206
|
+
``{"context": str, "query": str, "telemetry": dict}``. ``query`` (#4778) is
|
|
207
|
+
the speculative query the buffer was built for — the consumer uses it to
|
|
208
|
+
gate the join on topical similarity to turn N+1's prompt. It is ``""`` for a
|
|
209
|
+
legacy buffer written before the field existed.
|
|
191
210
|
|
|
192
211
|
Returns ``(None, token_or_last_consumed)`` when:
|
|
193
212
|
* no sentinel exists yet (nothing has been produced this session), or
|
|
@@ -207,7 +226,11 @@ def read_if_fresh(session_id: str, last_consumed_token: Optional[int]) -> tuple:
|
|
|
207
226
|
# Torn write: sentinel landed but payload didn't (or is corrupt).
|
|
208
227
|
# Fail-closed — never serve this as fresh.
|
|
209
228
|
return None, token
|
|
210
|
-
return {
|
|
229
|
+
return {
|
|
230
|
+
"context": payload.get("context", ""),
|
|
231
|
+
"query": payload.get("query", ""),
|
|
232
|
+
"telemetry": payload.get("telemetry", {}),
|
|
233
|
+
}, token
|
|
211
234
|
|
|
212
235
|
|
|
213
236
|
def invalidate(session_id: str) -> None:
|
|
@@ -159,7 +159,13 @@ def run_prefetch(hook_input: dict, config: dict) -> bool:
|
|
|
159
159
|
|
|
160
160
|
# Step 3 — write payload THEN sentinel, strictly in that order.
|
|
161
161
|
try:
|
|
162
|
-
|
|
162
|
+
# #4778 — persist the speculative `query` alongside the buffer so the
|
|
163
|
+
# consumer can gate the turn-N+1 join on topical similarity, not just
|
|
164
|
+
# session freshness. Without it a fresh buffer built for THIS turn's
|
|
165
|
+
# prompt is served on next turn's prompt even after a topic pivot.
|
|
166
|
+
recall_buffer.write_buffer(
|
|
167
|
+
session_id, memories_block, {"result_count": len(results)}, query=query
|
|
168
|
+
)
|
|
163
169
|
recall_buffer.write_sentinel(session_id)
|
|
164
170
|
except Exception as exc: # pragma: no cover - defensive
|
|
165
171
|
debug_log(config, f"Prefetch: buffer write failed: {exc}")
|
|
@@ -664,6 +664,21 @@ def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool
|
|
|
664
664
|
|
|
665
665
|
payload, _token = recall_buffer.read_if_fresh(session_id, last_consumed_token=last_consumed)
|
|
666
666
|
|
|
667
|
+
# #4778 — TOPIC-RELEVANCE GUARD. `read_if_fresh` only proves the buffer is
|
|
668
|
+
# FRESH (a strictly-newer sentinel this session), never that it is ON TOPIC:
|
|
669
|
+
# the producer built it from turn N's last human prompt, so on a topic PIVOT
|
|
670
|
+
# a perfectly-fresh buffer holds the WRONG-topic memories. Gate the join on
|
|
671
|
+
# topical similarity between the buffered query and turn N+1's ACTUAL prompt
|
|
672
|
+
# BEFORE the directive fetch below; a mismatch marks the token consumed (so
|
|
673
|
+
# it is never reconsidered) and falls through to SYNCHRONOUS recall — which
|
|
674
|
+
# fetches correct, current-topic memories AND re-injects directives itself.
|
|
675
|
+
# This is layered ON TOP of the F3 freshness machinery, not a replacement.
|
|
676
|
+
if payload is not None and not _prefetch_topic_matches(prompt, payload.get("query", ""), config):
|
|
677
|
+
if _token is not None:
|
|
678
|
+
_write_consumed_token(session_id, _token)
|
|
679
|
+
debug_log(config, "Prefetch buffer: topic mismatch vs current prompt, falling through to synchronous recall")
|
|
680
|
+
return False
|
|
681
|
+
|
|
667
682
|
# Directives stay on the synchronous, always-fresh path (M3 rule) even
|
|
668
683
|
# in the fast path — fetched here directly, never from the buffer.
|
|
669
684
|
directives_block = None
|
|
@@ -888,6 +903,78 @@ def _overlap_tokens(text) -> set:
|
|
|
888
903
|
return out
|
|
889
904
|
|
|
890
905
|
|
|
906
|
+
_PREFETCH_TOPIC_OVERLAP_DEFAULT = 0.3
|
|
907
|
+
|
|
908
|
+
# #4778 review MAJOR — short-prompt Jaccard floor. Jaccard is probabilistic, not
|
|
909
|
+
# absolute, on SHORT prompts: two ~2-content-token turns that share ONE incidental
|
|
910
|
+
# token while EACH carries a divergent token give 1/3 = 0.333 >= 0.30 and wrongly
|
|
911
|
+
# clear the ratio bar ("call mom" buffer served on a "call ended" turn — the pivot
|
|
912
|
+
# signature). When either token set is small (fewer than ``MIN_SMALL_SET_TOKENS``)
|
|
913
|
+
# such a partial overlap must supply at least ``_PREFETCH_MIN_SMALL_SET_INTERSECTION``
|
|
914
|
+
# shared tokens, not just clear the ratio.
|
|
915
|
+
#
|
|
916
|
+
# The floor fires ONLY on that divergent-on-both-sides shape. It deliberately
|
|
917
|
+
# EXEMPTS full containment (``intersection == min(|A|, |B|)`` — the smaller set is
|
|
918
|
+
# wholly shared: identical or subset/narrowing queries like "decide" vs "decide",
|
|
919
|
+
# or "restart klanker" vs "restart the klanker agent"), which is a legitimate
|
|
920
|
+
# topical match regardless of brevity and must still be served. Long prompts (both
|
|
921
|
+
# sides at or above ``MIN_SMALL_SET_TOKENS``) skip the floor entirely and use the
|
|
922
|
+
# Jaccard ratio exactly as before. The floor only ever ANDs a stricter condition
|
|
923
|
+
# onto the ratio — it can reject, never serve.
|
|
924
|
+
MIN_SMALL_SET_TOKENS = 3
|
|
925
|
+
_PREFETCH_MIN_SMALL_SET_INTERSECTION = 2
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _prefetch_topic_matches(prompt, buffered_query, config) -> bool:
|
|
929
|
+
"""#4778 — True iff turn N+1's ``prompt`` is topically close enough to the
|
|
930
|
+
``buffered_query`` the M4 producer used to build the warm buffer.
|
|
931
|
+
|
|
932
|
+
Jaccard overlap (``|A∩B| / |A∪B|``) on ``_overlap_tokens`` — the same
|
|
933
|
+
stop-word-stripped, digit-preserving, dependency-free tokenizer the
|
|
934
|
+
transcript-fallback keyword match uses (unicode-safe via ``str.isalnum``,
|
|
935
|
+
characterised in ``tests/test_overlap_tokens.py``) — against
|
|
936
|
+
``memoryPrefetchMinTopicOverlap`` (default 0.3). Jaccard, not the overlap
|
|
937
|
+
coefficient: both score 0 on a true pivot (disjoint content tokens), so the
|
|
938
|
+
correctness guarantee is identical, but Jaccard cannot be driven to 1.0 by a
|
|
939
|
+
single incidental shared token in a short prompt — it biases the residual
|
|
940
|
+
error toward a harmless false-MISS (sync recall, slower) rather than a
|
|
941
|
+
false-HIT (wrong-topic memories, the bug).
|
|
942
|
+
|
|
943
|
+
Fail-safe: an empty token set on EITHER side — a contentless prompt, or a
|
|
944
|
+
legacy/torn buffer whose ``query`` is ``""`` — returns False, so the caller
|
|
945
|
+
falls through to synchronous recall. A miss only ever costs latency; it can
|
|
946
|
+
never serve wrong-topic memories. Both sides are stripped of the ``<channel>``
|
|
947
|
+
envelope first, matching the producer's own query derivation.
|
|
948
|
+
"""
|
|
949
|
+
now_tokens = _overlap_tokens(strip_channel_envelope(prompt or ""))
|
|
950
|
+
buf_tokens = _overlap_tokens(strip_channel_envelope(buffered_query or ""))
|
|
951
|
+
if not now_tokens or not buf_tokens:
|
|
952
|
+
return False
|
|
953
|
+
intersection = len(now_tokens & buf_tokens)
|
|
954
|
+
if intersection == 0:
|
|
955
|
+
return False
|
|
956
|
+
# Short-prompt floor (#4778 review MAJOR): when either side is small, a single
|
|
957
|
+
# incidental shared token must not clear the guard on the Jaccard ratio alone.
|
|
958
|
+
# Exempt full containment (intersection == smaller set) — identical/subset
|
|
959
|
+
# queries are a legitimate topical match, never the divergent-on-both-sides
|
|
960
|
+
# pivot this floor targets. ANDed with the ratio check below: stricter, never
|
|
961
|
+
# looser, and long prompts (both sides >= MIN_SMALL_SET_TOKENS) are untouched.
|
|
962
|
+
min_set = min(len(now_tokens), len(buf_tokens))
|
|
963
|
+
if min_set < MIN_SMALL_SET_TOKENS \
|
|
964
|
+
and intersection < min_set \
|
|
965
|
+
and intersection < _PREFETCH_MIN_SMALL_SET_INTERSECTION:
|
|
966
|
+
return False
|
|
967
|
+
union = len(now_tokens | buf_tokens)
|
|
968
|
+
threshold = config.get("memoryPrefetchMinTopicOverlap", _PREFETCH_TOPIC_OVERLAP_DEFAULT)
|
|
969
|
+
try:
|
|
970
|
+
threshold = float(threshold)
|
|
971
|
+
except (TypeError, ValueError):
|
|
972
|
+
threshold = _PREFETCH_TOPIC_OVERLAP_DEFAULT
|
|
973
|
+
if not (0.0 <= threshold <= 1.0):
|
|
974
|
+
threshold = _PREFETCH_TOPIC_OVERLAP_DEFAULT
|
|
975
|
+
return (intersection / union) >= threshold
|
|
976
|
+
|
|
977
|
+
|
|
891
978
|
def _result_final_score(m) -> float:
|
|
892
979
|
"""Return a result's engine relevance score (`scores.final`).
|
|
893
980
|
|
|
@@ -278,7 +278,9 @@ class NoDuplicateInjectionTests(InvalidationBase):
|
|
|
278
278
|
config = self._config(prefetch_enabled=True)
|
|
279
279
|
marker = "- unique-fact-abc123 decided at standup"
|
|
280
280
|
|
|
281
|
-
|
|
281
|
+
# #4778 — on-topic with the `_consume` prompt ("what did we decide") so
|
|
282
|
+
# the topic guard passes and this stays a genuine warm-buffer hit.
|
|
283
|
+
recall_buffer.write_buffer(SESSION, marker, {}, query="what did we decide")
|
|
282
284
|
recall_buffer.write_sentinel(SESSION)
|
|
283
285
|
|
|
284
286
|
# Turn N+1: consumer injects the buffered block once.
|
|
@@ -311,7 +313,10 @@ class ReplyPathDoesNotBlockOnRecallTests(InvalidationBase):
|
|
|
311
313
|
|
|
312
314
|
def test_fresh_buffer_served_without_calling_recall(self):
|
|
313
315
|
config = self._config(prefetch_enabled=True)
|
|
314
|
-
|
|
316
|
+
# #4778 — on-topic with the `_consume` prompt so the topic guard passes;
|
|
317
|
+
# the point of THIS test is that a matched fresh buffer never blocks on a
|
|
318
|
+
# synchronous recall, so the query must clear the guard.
|
|
319
|
+
recall_buffer.write_buffer(SESSION, "- a prefetched memory xyz", {}, query="what did we decide")
|
|
315
320
|
recall_buffer.write_sentinel(SESSION)
|
|
316
321
|
|
|
317
322
|
class _ExplodingRecallClient:
|
|
@@ -147,7 +147,7 @@ class CallOrderTests(PrefetchPipelineBase):
|
|
|
147
147
|
order.append("retain")
|
|
148
148
|
return {"status": "ok"}
|
|
149
149
|
|
|
150
|
-
def _spy_write_buffer(session_id, context, telemetry=None):
|
|
150
|
+
def _spy_write_buffer(session_id, context, telemetry=None, query=None):
|
|
151
151
|
order.append("buffer")
|
|
152
152
|
|
|
153
153
|
def _spy_write_sentinel(session_id):
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""M4 #4778 — prefetch buffer TOPIC-RELEVANCE guard.
|
|
2
|
+
|
|
3
|
+
The F3 freshness machinery proves a joined buffer is FRESH (a strictly-newer
|
|
4
|
+
sentinel this session); it does NOT prove the buffer is ON TOPIC. The producer
|
|
5
|
+
builds the buffer from turn N's last human prompt, so on a topic PIVOT a
|
|
6
|
+
perfectly-fresh buffer holds the WRONG-topic memories. These are OUTCOME tests
|
|
7
|
+
on the rendered ``additionalContext`` (the real injection surface):
|
|
8
|
+
|
|
9
|
+
* RED/GREEN: a pivot query must NOT be served the prior-topic buffer — it must
|
|
10
|
+
fall through to SYNCHRONOUS recall (correct, current-topic memories). On the
|
|
11
|
+
pre-guard code the prior-topic block is injected at warm-buffer latency; with
|
|
12
|
+
the guard the pivot turn shows the synchronous result instead.
|
|
13
|
+
* An on-topic follow-up is STILL a warm hit (the guard is not so tight it kills
|
|
14
|
+
the latency win).
|
|
15
|
+
* A legacy buffer with no stored query fails safe to synchronous recall.
|
|
16
|
+
|
|
17
|
+
Mirrors the harness in ``tests/test_recall_buffer_join.py`` (drives ``recall.main``
|
|
18
|
+
end-to-end, asserts on transport). Stdlib-only.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import io
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import unittest
|
|
28
|
+
from unittest import mock
|
|
29
|
+
from unittest.mock import patch
|
|
30
|
+
|
|
31
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
32
|
+
if SCRIPTS_DIR not in sys.path:
|
|
33
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
34
|
+
|
|
35
|
+
import recall # noqa: E402
|
|
36
|
+
from lib import recall_buffer # noqa: E402
|
|
37
|
+
|
|
38
|
+
SESSION = "topic-guard-session"
|
|
39
|
+
|
|
40
|
+
# The producer built the warm buffer for turn N's prompt about a DB failover
|
|
41
|
+
# rollback; its recalled block is the us-east-1 standby plan.
|
|
42
|
+
PRODUCER_QUERY = "what's the rollback plan for the us-east-1 primary failover"
|
|
43
|
+
BUFFERED_BLOCK = "- Rollback plan: keep the us-east-1 primary in read-only standby"
|
|
44
|
+
|
|
45
|
+
# Turn N+1 PIVOTS sharply to an unrelated topic (the issue's reproduction shape).
|
|
46
|
+
PIVOT_PROMPT = "what's the weather forecast for Melbourne this weekend"
|
|
47
|
+
SYNC_RECALL_TEXT = "sync recalled: Melbourne weekend outlook is sunny"
|
|
48
|
+
|
|
49
|
+
# An on-topic FOLLOW-UP that reuses the salient nouns of the producer query.
|
|
50
|
+
ONTOPIC_PROMPT = "and is the us-east-1 primary still the rollback target"
|
|
51
|
+
|
|
52
|
+
# --- #4778 review MAJOR: SHORT-PROMPT Jaccard floor -------------------------
|
|
53
|
+
# Jaccard is probabilistic on short prompts. The producer built a buffer for a
|
|
54
|
+
# 2-content-token "call mom" turn; its block is a mom-topic reminder. Turn N+1 is
|
|
55
|
+
# "call ended" — a SHARP pivot that shares only the incidental "call". Raw
|
|
56
|
+
# Jaccard = |{call}| / |{call, mom, ended}| = 1/3 = 0.333 >= 0.30, so the
|
|
57
|
+
# pre-floor guard WRONGLY serves the mom buffer. The small-set intersection floor
|
|
58
|
+
# (either side < 3 tokens => demand >= 2 shared tokens) rejects it.
|
|
59
|
+
SHORT_PRODUCER_QUERY = "call mom"
|
|
60
|
+
SHORT_BUFFERED_BLOCK = "- Reminder: call mom about her dentist appointment"
|
|
61
|
+
SHORT_PIVOT_PROMPT = "call ended"
|
|
62
|
+
SHORT_BUFFER_MARKER = "dentist"
|
|
63
|
+
|
|
64
|
+
# Positive control: two SHORT prompts that LEGITIMATELY share >= 2 content tokens
|
|
65
|
+
# still join the warm buffer. Buffer query has 3 content tokens (restart, klanker,
|
|
66
|
+
# agent — "the" is a stop word); the follow-up "restart klanker" is 2 tokens, so
|
|
67
|
+
# the small-set floor applies, but the intersection {restart, klanker} = 2 clears
|
|
68
|
+
# it and Jaccard = 2/3 = 0.667 clears the ratio. Served, no synchronous recall.
|
|
69
|
+
SHORT_ONTOPIC_PRODUCER_QUERY = "restart the klanker agent"
|
|
70
|
+
SHORT_ONTOPIC_BLOCK = "- Runbook: restart klanker with docker restart switchroom-klanker"
|
|
71
|
+
SHORT_ONTOPIC_PROMPT = "restart klanker"
|
|
72
|
+
SHORT_ONTOPIC_MARKER = "docker"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _SyncClient:
|
|
76
|
+
"""Directive-free client whose synchronous recall returns a marker distinct
|
|
77
|
+
from the buffered block, so a served warm buffer and a served sync result are
|
|
78
|
+
unambiguously distinguishable in the rendered output."""
|
|
79
|
+
|
|
80
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
81
|
+
return {"items": []}
|
|
82
|
+
|
|
83
|
+
def recall(self, bank_id, query, **kwargs):
|
|
84
|
+
return {"results": [{
|
|
85
|
+
"text": SYNC_RECALL_TEXT, "type": "fact",
|
|
86
|
+
"mentioned_at": "2026-01-01", "id": "s1", "scores": {"final": 0.9},
|
|
87
|
+
}]}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TopicGuardBase(unittest.TestCase):
|
|
91
|
+
def setUp(self):
|
|
92
|
+
self._tmpdir = tempfile.mkdtemp(prefix="topic-guard-test-")
|
|
93
|
+
self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
94
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
|
|
95
|
+
|
|
96
|
+
self._bufdir = tempfile.mkdtemp(prefix="topic-guard-buf-")
|
|
97
|
+
self.env = mock.patch.dict(
|
|
98
|
+
os.environ, {"HINDSIGHT_PREFETCH_BUFFER_DIR": self._bufdir}, clear=False
|
|
99
|
+
)
|
|
100
|
+
self.env.start()
|
|
101
|
+
|
|
102
|
+
def tearDown(self):
|
|
103
|
+
self.env.stop()
|
|
104
|
+
shutil.rmtree(self._bufdir, ignore_errors=True)
|
|
105
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
106
|
+
if self._prev is None:
|
|
107
|
+
os.environ.pop("CLAUDE_PLUGIN_DATA", None)
|
|
108
|
+
else:
|
|
109
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
|
|
110
|
+
|
|
111
|
+
def _config(self):
|
|
112
|
+
return {
|
|
113
|
+
"autoRecall": True,
|
|
114
|
+
"bankId": "test-bank",
|
|
115
|
+
"recallMaxTokens": 4096,
|
|
116
|
+
"recallBudget": "mid",
|
|
117
|
+
"recallContextTurns": 1,
|
|
118
|
+
"recallMaxQueryChars": 800,
|
|
119
|
+
"recallPromptPreamble": "",
|
|
120
|
+
"recallParallelDeadlineSeconds": 5,
|
|
121
|
+
"directivesCacheTtlSeconds": 0,
|
|
122
|
+
"memoryPrefetchEnabled": True,
|
|
123
|
+
"memoryPrefetchPollCapMs": 100,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def _run(self, config, client, prompt):
|
|
127
|
+
hook_input = {"prompt": prompt, "session_id": SESSION, "transcript_path": "", "cwd": "/tmp"}
|
|
128
|
+
stdout = io.StringIO()
|
|
129
|
+
with patch("recall.load_config", return_value=config), \
|
|
130
|
+
patch("recall.get_api_url", return_value="http://fake"), \
|
|
131
|
+
patch("recall.HindsightClient", return_value=client), \
|
|
132
|
+
patch("recall.ensure_bank_mission"), \
|
|
133
|
+
patch("sys.stdin", io.StringIO(json.dumps(hook_input))), \
|
|
134
|
+
patch("sys.stdout", stdout):
|
|
135
|
+
recall.main()
|
|
136
|
+
out = stdout.getvalue()
|
|
137
|
+
return json.loads(out)["hookSpecificOutput"]["additionalContext"] if out else ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class PivotFallsThroughTests(TopicGuardBase):
|
|
141
|
+
def test_pivot_query_is_not_served_the_prior_topic_buffer(self):
|
|
142
|
+
# RED on pre-guard code: `read_if_fresh` returns the fresh buffer and the
|
|
143
|
+
# consumer injects the rollback block for a WEATHER prompt at ~1ms. GREEN
|
|
144
|
+
# with the guard: the topic mismatch falls through to synchronous recall.
|
|
145
|
+
recall_buffer.write_buffer(SESSION, BUFFERED_BLOCK, {}, query=PRODUCER_QUERY)
|
|
146
|
+
recall_buffer.write_sentinel(SESSION)
|
|
147
|
+
|
|
148
|
+
ctx = self._run(self._config(), _SyncClient(), PIVOT_PROMPT)
|
|
149
|
+
|
|
150
|
+
self.assertNotIn(
|
|
151
|
+
"us-east-1", ctx,
|
|
152
|
+
"a topic pivot must NOT be served the prior turn's wrong-topic buffer",
|
|
153
|
+
)
|
|
154
|
+
self.assertIn(
|
|
155
|
+
SYNC_RECALL_TEXT, ctx,
|
|
156
|
+
"a topic pivot must fall through to synchronous recall for correct memories",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class OnTopicStillHitsTests(TopicGuardBase):
|
|
161
|
+
def test_ontopic_followup_still_gets_the_warm_buffer(self):
|
|
162
|
+
# The guard must not be so tight it kills the latency win: a follow-up
|
|
163
|
+
# reusing the salient nouns still clears the Jaccard threshold and joins
|
|
164
|
+
# the warm buffer WITHOUT a synchronous recall.
|
|
165
|
+
class _ExplodingRecallClient:
|
|
166
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
167
|
+
return {"items": []}
|
|
168
|
+
|
|
169
|
+
def recall(self, bank_id, query, **kwargs):
|
|
170
|
+
raise AssertionError("on-topic warm hit must not fall through to synchronous recall")
|
|
171
|
+
|
|
172
|
+
recall_buffer.write_buffer(SESSION, BUFFERED_BLOCK, {}, query=PRODUCER_QUERY)
|
|
173
|
+
recall_buffer.write_sentinel(SESSION)
|
|
174
|
+
|
|
175
|
+
ctx = self._run(self._config(), _ExplodingRecallClient(), ONTOPIC_PROMPT)
|
|
176
|
+
self.assertIn(
|
|
177
|
+
"us-east-1", ctx,
|
|
178
|
+
"an on-topic follow-up must still join the warm prefetch buffer",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class LegacyBufferFailsSafeTests(TopicGuardBase):
|
|
183
|
+
def test_buffer_without_stored_query_falls_through_to_sync_recall(self):
|
|
184
|
+
# Backward-compat: a buffer written before the `query` field existed has
|
|
185
|
+
# query="" -> the guard cannot establish a topic match -> fail-safe to
|
|
186
|
+
# synchronous recall, never a blind wrong-topic serve.
|
|
187
|
+
recall_buffer.write_buffer(SESSION, BUFFERED_BLOCK, {}) # no query
|
|
188
|
+
recall_buffer.write_sentinel(SESSION)
|
|
189
|
+
|
|
190
|
+
ctx = self._run(self._config(), _SyncClient(), PIVOT_PROMPT)
|
|
191
|
+
self.assertNotIn("us-east-1", ctx)
|
|
192
|
+
self.assertIn(SYNC_RECALL_TEXT, ctx)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class ShortPromptFloorTests(TopicGuardBase):
|
|
196
|
+
def test_short_pivot_sharing_one_incidental_token_is_not_served(self):
|
|
197
|
+
# RED without the small-set floor: "call mom" buffer + "call ended" turn
|
|
198
|
+
# gives raw Jaccard 1/3 = 0.333 >= 0.30, so the mom-topic block is served
|
|
199
|
+
# on an unrelated turn. GREEN with the floor: either side has < 3 tokens
|
|
200
|
+
# and the intersection is only {call} = 1 < 2, so the guard rejects and
|
|
201
|
+
# the turn falls through to synchronous recall.
|
|
202
|
+
recall_buffer.write_buffer(SESSION, SHORT_BUFFERED_BLOCK, {}, query=SHORT_PRODUCER_QUERY)
|
|
203
|
+
recall_buffer.write_sentinel(SESSION)
|
|
204
|
+
|
|
205
|
+
ctx = self._run(self._config(), _SyncClient(), SHORT_PIVOT_PROMPT)
|
|
206
|
+
|
|
207
|
+
self.assertNotIn(
|
|
208
|
+
SHORT_BUFFER_MARKER, ctx,
|
|
209
|
+
"a short-prompt pivot sharing one incidental token must NOT be served the buffer",
|
|
210
|
+
)
|
|
211
|
+
self.assertIn(
|
|
212
|
+
SYNC_RECALL_TEXT, ctx,
|
|
213
|
+
"a short-prompt pivot must fall through to synchronous recall",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def test_short_ontopic_sharing_two_tokens_still_gets_the_warm_buffer(self):
|
|
217
|
+
# Positive control: the floor must not kill a legitimate short warm hit.
|
|
218
|
+
# "restart klanker" (2 tokens) vs "restart the klanker agent" shares
|
|
219
|
+
# {restart, klanker} = 2, clearing both the small-set floor and the ratio;
|
|
220
|
+
# served WITHOUT any synchronous recall.
|
|
221
|
+
class _ExplodingRecallClient:
|
|
222
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
223
|
+
return {"items": []}
|
|
224
|
+
|
|
225
|
+
def recall(self, bank_id, query, **kwargs):
|
|
226
|
+
raise AssertionError("short on-topic warm hit must not fall through to sync recall")
|
|
227
|
+
|
|
228
|
+
recall_buffer.write_buffer(SESSION, SHORT_ONTOPIC_BLOCK, {}, query=SHORT_ONTOPIC_PRODUCER_QUERY)
|
|
229
|
+
recall_buffer.write_sentinel(SESSION)
|
|
230
|
+
|
|
231
|
+
ctx = self._run(self._config(), _ExplodingRecallClient(), SHORT_ONTOPIC_PROMPT)
|
|
232
|
+
self.assertIn(
|
|
233
|
+
SHORT_ONTOPIC_MARKER, ctx,
|
|
234
|
+
"a short on-topic follow-up sharing >= 2 tokens must still join the warm buffer",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def test_short_floor_unit_boundaries(self):
|
|
238
|
+
cfg = self._config()
|
|
239
|
+
# Short pivot, one incidental shared token -> floor rejects despite ratio.
|
|
240
|
+
self.assertFalse(recall._prefetch_topic_matches(SHORT_PIVOT_PROMPT, SHORT_PRODUCER_QUERY, cfg))
|
|
241
|
+
# Short on-topic, two shared tokens -> floor and ratio both clear.
|
|
242
|
+
self.assertTrue(
|
|
243
|
+
recall._prefetch_topic_matches(SHORT_ONTOPIC_PROMPT, SHORT_ONTOPIC_PRODUCER_QUERY, cfg)
|
|
244
|
+
)
|
|
245
|
+
# The reviewer's borderline case: "kill process 4080" / "process 4080 logs"
|
|
246
|
+
# shares two tokens (both sides 3 tokens) -> served, unaffected by floor.
|
|
247
|
+
self.assertTrue(
|
|
248
|
+
recall._prefetch_topic_matches("kill process 4080", "process 4080 logs", cfg)
|
|
249
|
+
)
|
|
250
|
+
# Full-containment exemption: an identical single-content-token query
|
|
251
|
+
# ("what did we decide" -> {decide} both sides) is wholly shared, not a
|
|
252
|
+
# divergent pivot -> the floor must NOT reject it. Guards the regression
|
|
253
|
+
# the naive floor caused in test_prefetch_invalidation's short queries.
|
|
254
|
+
self.assertTrue(recall._prefetch_topic_matches("what did we decide", "what did we decide", cfg))
|
|
255
|
+
# Subset/narrowing short query is likewise exempt from the floor.
|
|
256
|
+
self.assertTrue(recall._prefetch_topic_matches("call mom please", "call mom", cfg))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class TopicMatchUnitTests(TopicGuardBase):
|
|
260
|
+
def test_jaccard_threshold_boundaries(self):
|
|
261
|
+
cfg = self._config()
|
|
262
|
+
# Disjoint content tokens -> pivot -> no match.
|
|
263
|
+
self.assertFalse(recall._prefetch_topic_matches(PIVOT_PROMPT, PRODUCER_QUERY, cfg))
|
|
264
|
+
# Identical query -> match.
|
|
265
|
+
self.assertTrue(recall._prefetch_topic_matches(PRODUCER_QUERY, PRODUCER_QUERY, cfg))
|
|
266
|
+
# On-topic follow-up sharing salient nouns -> match.
|
|
267
|
+
self.assertTrue(recall._prefetch_topic_matches(ONTOPIC_PROMPT, PRODUCER_QUERY, cfg))
|
|
268
|
+
# Empty buffered query (legacy) -> fail-safe miss.
|
|
269
|
+
self.assertFalse(recall._prefetch_topic_matches(PRODUCER_QUERY, "", cfg))
|
|
270
|
+
# Empty current prompt -> fail-safe miss.
|
|
271
|
+
self.assertFalse(recall._prefetch_topic_matches("", PRODUCER_QUERY, cfg))
|
|
272
|
+
# Garbage threshold coerces to the 0.3 default rather than raising.
|
|
273
|
+
bad = dict(cfg)
|
|
274
|
+
bad["memoryPrefetchMinTopicOverlap"] = "not-a-number"
|
|
275
|
+
self.assertTrue(recall._prefetch_topic_matches(PRODUCER_QUERY, PRODUCER_QUERY, bad))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
if __name__ == "__main__":
|
|
279
|
+
unittest.main()
|
|
@@ -99,7 +99,10 @@ class BufferJoinBase(unittest.TestCase):
|
|
|
99
99
|
|
|
100
100
|
class FreshHitTests(BufferJoinBase):
|
|
101
101
|
def test_fresh_buffer_hit_is_rendered_with_directives_layered_on(self):
|
|
102
|
-
|
|
102
|
+
# #4778 — the buffered query must be on-topic with the consumer prompt
|
|
103
|
+
# (default "what did we decide about deploys") for the join to fire;
|
|
104
|
+
# here it is identical, so the topic guard passes and the warm hit lands.
|
|
105
|
+
recall_buffer.write_buffer(SESSION, "- a prefetched memory", {}, query="what did we decide about deploys")
|
|
103
106
|
recall_buffer.write_sentinel(SESSION)
|
|
104
107
|
|
|
105
108
|
out = self._run(self._config(prefetch_enabled=True), _DirectiveClient())
|
|
@@ -194,7 +197,7 @@ class StaleBufferTokenTests(BufferJoinBase):
|
|
|
194
197
|
turns N+1..N+k when no strictly-newer sentinel has been produced."""
|
|
195
198
|
|
|
196
199
|
def test_consumed_buffer_is_not_reserved_as_fresh_next_turn(self):
|
|
197
|
-
recall_buffer.write_buffer(SESSION, "- a prefetched memory", {})
|
|
200
|
+
recall_buffer.write_buffer(SESSION, "- a prefetched memory", {}, query="what did we decide about deploys")
|
|
198
201
|
recall_buffer.write_sentinel(SESSION)
|
|
199
202
|
cfg = self._config(prefetch_enabled=True)
|
|
200
203
|
|
|
@@ -219,7 +222,7 @@ class StaleBufferTokenTests(BufferJoinBase):
|
|
|
219
222
|
# Positive control: once the producer writes a STRICTLY-NEWER sentinel,
|
|
220
223
|
# the fresh path serves again — the token gate rejects only re-reads of
|
|
221
224
|
# an ALREADY-consumed sentinel, never a genuinely new one.
|
|
222
|
-
recall_buffer.write_buffer(SESSION, "- memory one", {})
|
|
225
|
+
recall_buffer.write_buffer(SESSION, "- memory one", {}, query="what did we decide about deploys")
|
|
223
226
|
recall_buffer.write_sentinel(SESSION)
|
|
224
227
|
cfg = self._config(prefetch_enabled=True)
|
|
225
228
|
|
|
@@ -227,7 +230,7 @@ class StaleBufferTokenTests(BufferJoinBase):
|
|
|
227
230
|
self.assertIn("memory one", json.loads(out1)["hookSpecificOutput"]["additionalContext"])
|
|
228
231
|
|
|
229
232
|
# New turn's producer output.
|
|
230
|
-
recall_buffer.write_buffer(SESSION, "- memory two", {})
|
|
233
|
+
recall_buffer.write_buffer(SESSION, "- memory two", {}, query="what did we decide about deploys")
|
|
231
234
|
recall_buffer.write_sentinel(SESSION)
|
|
232
235
|
out2 = self._run(cfg, _DirectiveClient())
|
|
233
236
|
ctx2 = json.loads(out2)["hookSpecificOutput"]["additionalContext"]
|