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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.21.19", COMMIT_SHA = "7e7061ba", COMMIT_DATE = "2026-08-18T15:36:04Z";
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.19";
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.19",
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.19";
105922
- var COMMIT_SHA = "7e7061ba";
105923
- var COMMIT_DATE = "2026-08-18T15:36:04Z";
105924
- var LATEST_PR = 4777;
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.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(session_id: str, context: str, telemetry: Optional[dict] = None) -> None:
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 ``{"context": str, "telemetry": dict}``.
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 {"context": payload.get("context", ""), "telemetry": payload.get("telemetry", {})}, token
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
- recall_buffer.write_buffer(session_id, memories_block, {"result_count": len(results)})
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
- recall_buffer.write_buffer(SESSION, marker, {})
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
- recall_buffer.write_buffer(SESSION, "- a prefetched memory xyz", {})
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
- recall_buffer.write_buffer(SESSION, "- a prefetched memory", {})
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"]