switchroom 0.17.6 → 0.18.3

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.
Files changed (197) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +65 -5
  3. package/dist/auth-broker/index.js +6623 -514
  4. package/dist/cli/notion-write-pretool.mjs +64 -4
  5. package/dist/cli/switchroom.js +1888 -1162
  6. package/dist/host-control/main.js +6306 -162
  7. package/dist/vault/approvals/kernel-server.js +6014 -202
  8. package/dist/vault/broker/server.js +6741 -940
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +218 -25
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +116 -0
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/skills/mental-model-curator/SKILL.md +162 -0
  18. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  19. package/telegram-plugin/bridge/bridge.ts +80 -1
  20. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  21. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  22. package/telegram-plugin/consolidation-legibility.ts +279 -0
  23. package/telegram-plugin/context-exhaustion.ts +124 -0
  24. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  25. package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
  26. package/telegram-plugin/dist/server.js +86 -2
  27. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  28. package/telegram-plugin/gateway/activity-card-store.ts +369 -0
  29. package/telegram-plugin/gateway/gateway.ts +1861 -172
  30. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  31. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  32. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  33. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  34. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  35. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  36. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  37. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  38. package/telegram-plugin/gateway/model-command.ts +70 -10
  39. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  40. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  41. package/telegram-plugin/memory-legibility.ts +217 -0
  42. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/telegram-plugin/package.json +6 -0
  44. package/telegram-plugin/quota-watch.ts +4 -6
  45. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  46. package/telegram-plugin/registry/turns-schema.ts +78 -0
  47. package/telegram-plugin/render/ir.ts +209 -0
  48. package/telegram-plugin/render/parse.ts +363 -0
  49. package/telegram-plugin/render/render.ts +440 -0
  50. package/telegram-plugin/render/rich-render.ts +72 -0
  51. package/telegram-plugin/scoped-approval.ts +59 -0
  52. package/telegram-plugin/silent-end.ts +78 -0
  53. package/telegram-plugin/stream-controller.ts +14 -3
  54. package/telegram-plugin/subagent-watcher.ts +60 -6
  55. package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
  56. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  57. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  58. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  59. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  60. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  61. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  62. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
  63. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  64. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  65. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  66. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  67. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  68. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  69. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  70. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  71. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  72. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  73. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  74. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  75. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  76. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  77. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  78. package/telegram-plugin/tests/model-command.test.ts +193 -16
  79. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  80. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  81. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  82. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  83. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  84. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  85. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  86. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  87. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  88. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  89. package/telegram-plugin/tests/render/render.test.ts +436 -0
  90. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  91. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  92. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  93. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  94. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  95. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  96. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
  97. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  98. package/telegram-plugin/uat/assertions.ts +88 -4
  99. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  100. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  101. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  102. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  103. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  104. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  105. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  106. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  107. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  109. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  110. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  111. package/telegram-plugin/worktree-watch-cwds.ts +135 -0
  112. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  113. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  119. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  132. package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
  133. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  134. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  135. package/vendor/hindsight-memory/scripts/recall.py +217 -10
  136. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  137. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  138. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  157. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  158. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  159. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  160. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  161. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  162. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  163. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  164. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  165. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  166. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  167. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  168. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  169. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  170. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  172. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  173. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  174. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  175. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  176. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  177. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  178. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  179. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  180. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  181. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  182. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  183. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  184. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  185. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  186. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  187. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  188. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  189. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  190. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  191. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  192. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  193. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  194. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  195. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  196. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
  197. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -124,11 +124,19 @@ class HindsightClient:
124
124
  tags: Optional[list] = None,
125
125
  tags_match: Optional[str] = None,
126
126
  tag_groups: Optional[object] = None,
127
+ prefer_observations: Optional[bool] = None,
127
128
  timeout: int = 10,
128
129
  ) -> dict:
129
130
  """Recall memories from a bank.
130
131
 
131
- Returns the raw API response dict with 'results' list.
132
+ Returns the raw API response dict with 'results' list. Each result
133
+ carries a `scores` object (`RecallScores`) whose `final` field is the
134
+ engine's combined ranking score — callers sort the merged multi-bank
135
+ set by it before applying any count cap.
136
+
137
+ `prefer_observations=True` asks the engine to prefer deduped
138
+ observation statements over the raw facts they supersede, backfilling
139
+ the freed slots — denser coverage inside the same token/count budget.
132
140
  """
133
141
  path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories/recall"
134
142
  body = {
@@ -145,6 +153,8 @@ class HindsightClient:
145
153
  body["tags_match"] = tags_match
146
154
  if tag_groups:
147
155
  body["tag_groups"] = tag_groups
156
+ if prefer_observations is not None:
157
+ body["prefer_observations"] = prefer_observations
148
158
  return self._request("POST", path, body, timeout=timeout)
149
159
 
150
160
  def retain(
@@ -33,10 +33,44 @@ DEFAULTS = {
33
33
  # user's query terms and a memory's text terms. Memories below this
34
34
  # threshold are dropped before formatting. 0.0 disables the gate
35
35
  # (current behaviour: inject everything Hindsight returns up to the
36
- # count cap). Hindsight's HTTP API does not expose similarity
37
- # scores, so this is the switchroom-side quality filter — see #475.
36
+ # count cap). NOTE: Hindsight's HTTP recall API DOES return per-result
37
+ # relevance scores (`scores.final`, plus `.semantic`/`.keyword`/
38
+ # `.reranker`) — verified at runtime — and recall.py now reads and
39
+ # sorts the merged set by `scores.final`. This Jaccard gate is a
40
+ # separate lexical-overlap quality filter layered on top — see #475.
38
41
  "recallMinOverlap": 0.0,
39
42
  "recallTypes": ["world", "experience"],
43
+ # Switchroom-local: when True (default; Ken-approved ON) recall biases
44
+ # toward synthesized `observation`-tier facts. Escape hatch: pin off via
45
+ # `recallPreferObservations: false` in the user config — read in recall.py.
46
+ "recallPreferObservations": True,
47
+ # Switchroom #2848 Stage B/C — deterministic directive capture.
48
+ # When on (switchroom default; pinned true in the copied plugin
49
+ # settings.json by applyHindsightSettingsOverrides), TWO deterministic
50
+ # hooks share this knob:
51
+ # * Stage B (recall.py, UserPromptSubmit): regex-detects correction /
52
+ # standing-rule-shaped inbound and appends a terse advisory to the
53
+ # turn's additionalContext telling the model to persist the rule with
54
+ # create_directive if it IS durable.
55
+ # * Stage C (directive_verify.py, Stop): after the turn, re-checks the
56
+ # human turn against a HIGH-PRECISION durable-rule regex and, if the
57
+ # model recorded no create_directive call, blocks the stop ONCE to
58
+ # re-prompt capture (closes the "model ignored the nudge" gap).
59
+ # Both are pure detection — no model callsite, no silent hook-side write;
60
+ # the model authors the directive in-session (chat-legible). Operators opt
61
+ # out per-agent via memory.directive_capture_nudge=false →
62
+ # HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE (disables BOTH hooks).
63
+ "directiveCaptureNudge": True,
64
+ # Switchroom #2873/#2903 Fix 6.2 — the BLOCKING half (Stage C
65
+ # directive_verify.py Stop hook) split out from the advisory nudge. When
66
+ # True (default) the verifier may block the stop once to re-prompt capture;
67
+ # when False the Stage B nudge still fires but the Stop hook NEVER blocks
68
+ # (advisory-only mode). Lets an operator keep the gentle nudge while dropping
69
+ # the more intrusive block. Gated UNDER directiveCaptureNudge: turning the
70
+ # nudge off disables both regardless of this knob. Operators opt out
71
+ # per-agent via memory.directive_capture_verify=false →
72
+ # HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY.
73
+ "directiveCaptureVerify": True,
40
74
  "recallContextTurns": 1,
41
75
  "recallMaxQueryChars": 800,
42
76
  "recallRoles": ["user", "assistant"],
@@ -119,6 +153,16 @@ ENV_OVERRIDES = {
119
153
  # from agents.<name>.memory.recall.skip_trivial only on override; the
120
154
  # switchroom default is on (recall.py falls back to True).
121
155
  "HINDSIGHT_RECALL_SKIP_TRIVIAL": ("recallSkipTrivial", bool),
156
+ # Switchroom #2848 Stage B: directive-capture nudge on/off. Set by
157
+ # start.sh from agents.<name>.memory.directive_capture_nudge only when
158
+ # the operator overrode it; the switchroom default is on (settings.json
159
+ # pins true; recall.py falls back to True).
160
+ "HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE": ("directiveCaptureNudge", bool),
161
+ # Switchroom #2873/#2903 Fix 6.2: the Stage C block on/off, independent of
162
+ # the Stage B nudge. Set by start.sh from
163
+ # agents.<name>.memory.directive_capture_verify only when the operator
164
+ # overrode it; the switchroom default is on.
165
+ "HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY": ("directiveCaptureVerify", bool),
122
166
  "HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
123
167
  "HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
124
168
  # Upstream 962140eef — recall tag filters. The tags env var accepts JSON
@@ -16,6 +16,7 @@ stderr. We never raise to the caller — directives are nice-to-have on the
16
16
  recall path; a directive-fetch failure must not kill the recall block.
17
17
  """
18
18
 
19
+ import re
19
20
  import sys
20
21
  from typing import Optional
21
22
 
@@ -117,3 +118,90 @@ def format_active_directives_block(directives: list, max_directives: int = MAX_D
117
118
 
118
119
  lines.append("</active_directives>")
119
120
  return "\n".join(lines)
121
+
122
+
123
+ # --- Directive dedup (switchroom #2903 Fix 6.2) --------------------------------
124
+ #
125
+ # A user restating a rule that is ALREADY an active directive should not get
126
+ # re-nudged, and the Stage C verifier must not BLOCK the turn where the model
127
+ # correctly declines to re-create the duplicate. The verifier can see the
128
+ # directives that were injected THIS turn (recall.py emits the
129
+ # <active_directives> block into the prompt), so it reads them back out of the
130
+ # transcript and treats a rule already covered there as "already captured".
131
+ #
132
+ # Matching is a deterministic lexical-overlap heuristic (no model call, no API
133
+ # call — the verifier is on the Stop critical path). It is intentionally
134
+ # lenient: a false "already captured" only means we skip a re-prompt (the rule
135
+ # is genuinely already stored in that case), whereas a false "not captured"
136
+ # re-blocks a turn the model correctly finished. So we err toward treating a
137
+ # strong token overlap as a duplicate.
138
+
139
+ # Parses the numbered "N. [P<pri>] <name>: <content>" body lines out of a
140
+ # rendered <active_directives> block (see format_active_directives_block).
141
+ _ACTIVE_DIRECTIVE_LINE_RE = re.compile(
142
+ r"^\s*\d+\.\s*\[P-?\d+\]\s*[^:]*:\s*(?P<content>.+?)\s*$"
143
+ )
144
+
145
+ # Low-signal words stripped before overlap scoring so "always"/"you"/"please"
146
+ # framing doesn't inflate similarity between two unrelated rules.
147
+ _DEDUP_STOPWORDS = frozenset(
148
+ {
149
+ "the", "a", "an", "to", "of", "and", "or", "for", "in", "on", "at",
150
+ "is", "are", "be", "you", "your", "i", "we", "me", "my", "it", "that",
151
+ "this", "with", "as", "so", "do", "dont", "don", "not", "never",
152
+ "always", "please", "should", "must", "want", "from", "now", "on",
153
+ "going", "forward", "forwards", "future", "rule", "remember", "call",
154
+ "use", "using", "make", "sure", "when", "if", "just", "will", "can",
155
+ }
156
+ )
157
+
158
+
159
+ def _dedup_tokens(text: str) -> set:
160
+ """Normalize text into a set of significant lower-case word tokens."""
161
+ if not isinstance(text, str):
162
+ return set()
163
+ words = re.findall(r"[a-z0-9]+", text.lower())
164
+ return {w for w in words if w not in _DEDUP_STOPWORDS and len(w) > 1}
165
+
166
+
167
+ def parse_active_directives_block(text: str) -> list:
168
+ """Extract the directive CONTENT strings from a rendered
169
+ <active_directives> block (as produced by format_active_directives_block).
170
+
171
+ Returns [] when the block is absent or malformed. Pure string parsing.
172
+ """
173
+ if not isinstance(text, str) or "<active_directives>" not in text:
174
+ return []
175
+ # Isolate the block body between the tags (tolerate missing close tag).
176
+ body = text.split("<active_directives>", 1)[1]
177
+ body = body.split("</active_directives>", 1)[0]
178
+ contents = []
179
+ for line in body.splitlines():
180
+ m = _ACTIVE_DIRECTIVE_LINE_RE.match(line)
181
+ if m:
182
+ contents.append(m.group("content").strip())
183
+ return contents
184
+
185
+
186
+ def rule_already_captured(
187
+ rule_text: str, directive_contents: list, threshold: float = 0.6
188
+ ) -> bool:
189
+ """True when ``rule_text`` is lexically well-covered by an EXISTING active
190
+ directive in ``directive_contents``.
191
+
192
+ Coverage = |rule_tokens ∩ directive_tokens| / |rule_tokens| for the
193
+ best-matching directive. A high coverage ratio means the restated rule adds
194
+ (almost) no new significant words over one already stored — i.e. a
195
+ duplicate. Deterministic; no model/API call.
196
+ """
197
+ rule_tokens = _dedup_tokens(rule_text)
198
+ if not rule_tokens:
199
+ return False
200
+ for content in directive_contents:
201
+ d_tokens = _dedup_tokens(content)
202
+ if not d_tokens:
203
+ continue
204
+ covered = len(rule_tokens & d_tokens) / len(rule_tokens)
205
+ if covered >= threshold:
206
+ return True
207
+ return False
@@ -0,0 +1,77 @@
1
+ """Switchroom gateway inbound-envelope grammar — the ONE place this vendored
2
+ Python knows about switchroom's ``<channel source="...">`` wire format.
3
+
4
+ SWITCHROOM DIVERGENCE (not upstream Hindsight). This module isolates the
5
+ coupling between the vendored Python hooks and the switchroom telegram-plugin
6
+ gateway's inbound envelope. It exists so a change to the TS-side envelope
7
+ grammar has ONE obvious Python counterpart to update — instead of the grammar
8
+ being buried inline inside directive_verify.py where a silent TS format change
9
+ could break a Python guard undetected (#2903 Fix 6.3).
10
+
11
+ The gateway prepends a leading ``<channel source="..." ...>`` envelope to every
12
+ SYNTHESIZED / non-human inbound it injects into the model (cron fires, reaction
13
+ dispatches, resume synthetics, vault-grant approvals, sub-agent handbacks,
14
+ obligation representations, …). See, on the TS side:
15
+
16
+ * telegram-plugin/gateway/*-inbound-builder.ts (and the reaction / resume /
17
+ vault-grant / subagent / obligation builders) — they emit
18
+ ``<channel source="<name>" ...>``.
19
+ * A genuine human message from the primary channel carries
20
+ ``source="telegram"``; interactive (non-gateway) sessions carry NO envelope
21
+ at all.
22
+
23
+ Contract pinned here (keep in lockstep with the gateway):
24
+ * ``CHANNEL_SOURCE_RE`` — extracts the ``source`` attribute from the leading
25
+ envelope. Handles both open (``<channel source="telegram" ...>``) and
26
+ self-closing (``<channel source="reaction"/>``) forms.
27
+ * ``HUMAN_INBOUND_SOURCES`` — the whitelist of ``source`` values that denote a
28
+ GENUINE human turn. A whitelist (not a blacklist) so any NEW synthetic
29
+ source the gateway adds is treated as machine-authored by default rather
30
+ than accidentally classified human.
31
+
32
+ If the gateway ever renames the envelope tag, changes the attribute name, or
33
+ adds a new human-facing channel, update THIS module (and its test,
34
+ tests/test_switchroom_envelope.py). Nothing else in the Python tree should hard
35
+ -code the ``<channel source=`` grammar.
36
+ """
37
+
38
+ import re
39
+
40
+ # Genuine human inbound channels. A "user" turn whose `<channel source="...">`
41
+ # envelope names anything else is a SYNTHESIZED / cron inbound built by the
42
+ # gateway — a machine turn, NOT a human correction.
43
+ HUMAN_INBOUND_SOURCES = frozenset({"telegram"})
44
+
45
+ # Pulls the source attribute out of the gateway's leading `<channel ...>`
46
+ # envelope. Handles both open (`<channel source="telegram" ...>`) and
47
+ # self-closing (`<channel source="reaction"/>`) forms.
48
+ CHANNEL_SOURCE_RE = re.compile(r"""<channel\b[^>]*\bsource=["']([^"']+)["']""")
49
+
50
+
51
+ def envelope_source(text) -> "str | None":
52
+ """Return the lower-cased ``source`` of the leading channel envelope, or
53
+ None when there is no envelope (interactive session). Pure regex."""
54
+ if not isinstance(text, str):
55
+ return None
56
+ m = CHANNEL_SOURCE_RE.search(text)
57
+ if not m:
58
+ return None
59
+ return m.group(1).strip().lower()
60
+
61
+
62
+ def is_synthetic_inbound(text) -> bool:
63
+ """True when this turn's opening message is a synthesized/cron inbound
64
+ rather than a genuine human message.
65
+
66
+ Detection is purely on the gateway-prepended ``<channel source="...">``
67
+ envelope: a ``source`` outside ``HUMAN_INBOUND_SOURCES`` (``cron``,
68
+ ``reaction``, ``resume_interrupted``, ``vault_grant_approved``,
69
+ ``subagent_handback``, ``obligation_represent``, …) marks a non-human turn.
70
+ No envelope (interactive session) → treated as human. Injected channel tags
71
+ in a human's own body are neutralised upstream by the channel-envelope
72
+ sanitiser, so the only real ``<channel source=`` is the gateway's.
73
+ """
74
+ source = envelope_source(text)
75
+ if source is None:
76
+ return False
77
+ return source not in HUMAN_INBOUND_SOURCES
@@ -373,12 +373,18 @@ def _is_demoted_memory(memory) -> bool:
373
373
 
374
374
  # Switchroom #475 — lexical-overlap relevance gate.
375
375
  #
376
- # Hindsight's HTTP API does not return similarity scores. Without a
377
- # score the existing `recallMaxMemories` cap acts as a *floor* on
378
- # low-relevance prompts: weak matches still fill the slot up to N,
379
- # mis-steering the model. This gate computes Jaccard overlap between
380
- # the user's query terms and each memory's text terms, and drops
381
- # memories below a configurable threshold.
376
+ # Hindsight's HTTP recall API DOES return per-result relevance scores
377
+ # (`RecallResult.scores.final`, plus `.semantic`/`.keyword`/`.reranker`);
378
+ # the merged multi-bank set is now sorted by `scores.final` before the
379
+ # `recallMaxMemories` cap (see the sort just before the cap in
380
+ # process_recall) so the most relevant memories survive the head-slice
381
+ # regardless of which bank they came from. This gate is a *complementary*,
382
+ # opt-in absolute precision floor: `scores.final` is a relative rank that
383
+ # still orders weakly-matching memories rather than excluding them, so on a
384
+ # low-relevance prompt the top-N could still be low-signal. The Jaccard
385
+ # overlap between the user's query terms and each memory's text terms is a
386
+ # query-independent absolute measure that drops memories below a
387
+ # configurable threshold outright — something the relative sort does not do.
382
388
  #
383
389
  # Threshold default is 0.0 (disabled) so the gate is opt-in initially.
384
390
  # Operators tune via `memory.recall.min_overlap` in switchroom.yaml or
@@ -469,6 +475,40 @@ def _filter_by_overlap(results, query: str, threshold: float):
469
475
  return kept, dropped
470
476
 
471
477
 
478
+ def _result_final_score(m) -> float:
479
+ """Return a result's engine relevance score (`scores.final`).
480
+
481
+ Switchroom Phase-1 precision. The Hindsight recall response attaches a
482
+ `scores` object to every result whose required `final` field is the
483
+ engine's combined ranking score (reranker + recency/temporal/proof
484
+ boosts). Results missing a usable score sort last so a malformed or
485
+ score-less entry can never starve a properly-ranked one.
486
+ """
487
+ if isinstance(m, dict):
488
+ scores = m.get("scores")
489
+ if isinstance(scores, dict):
490
+ val = scores.get("final")
491
+ if isinstance(val, (int, float)) and not isinstance(val, bool):
492
+ return float(val)
493
+ return float("-inf")
494
+
495
+
496
+ def _sort_by_final_score(results):
497
+ """Sort merged multi-bank results by `scores.final` descending, in place.
498
+
499
+ Switchroom Phase-1 bank-starvation fix. The recall path appends
500
+ additional-bank (profile / shared / sender) results after the own-bank
501
+ results, then head-slices at `recallMaxMemories`. Before this sort, a
502
+ full own-bank result set silently dropped every additional-bank memory
503
+ at the cap regardless of relevance. Sorting by the engine's real
504
+ relevance score before the cap means the cap keeps the most relevant
505
+ memories cross-bank. Python's sort is stable, so ties preserve the
506
+ prior own-bank-first insertion order.
507
+ """
508
+ results.sort(key=_result_final_score, reverse=True)
509
+ return results
510
+
511
+
472
512
  def _write_recall_log(entry: dict) -> None:
473
513
  """Append a JSONL line to recall_log.jsonl. Bounded by line count.
474
514
 
@@ -597,6 +637,128 @@ def _is_trivial_stateless(ack_form, stripped):
597
637
  return False
598
638
 
599
639
 
640
+ # Switchroom #2848 Stage B — deterministic directive-capture nudge.
641
+ #
642
+ # Stage A audit (issue #2848) measured a ~55% miss rate on durable
643
+ # corrections: the model is instructed (guidance-only, in
644
+ # profiles/default/CLAUDE.md.hbs) to persist standing rules with
645
+ # mcp__hindsight__create_directive, but does so inconsistently — capture
646
+ # is a per-agent lottery (the same broadcast correction was captured by
647
+ # one agent and silently dropped by two others). This adds DETERMINISTIC
648
+ # detection of correction / standing-rule-shaped inbound (pure regex — NO
649
+ # model callsite; the claude-native invariant forbids a classifier call)
650
+ # and appends a terse advisory nudge to the UserPromptSubmit
651
+ # additionalContext. The MODEL makes the judgment IN-SESSION and calls
652
+ # create_directive itself (visible in chat); the hook NEVER writes a
653
+ # directive on its own — a silent hook-side write would break
654
+ # chat-legibility and edge the no-self-escalation invariant.
655
+ #
656
+ # On by default (Stage A proved a real gap → defaults principle);
657
+ # operators opt out per-agent via memory.directive_capture_nudge=false in
658
+ # switchroom.yaml, which start.sh exports as
659
+ # HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE=false (recall.py falls back to the
660
+ # settings.json default, which switchroom pins on).
661
+ #
662
+ # Detection is intentionally inclusive (the nudge is cheap + advisory, so
663
+ # a modest false-positive rate is acceptable — the model just ignores it
664
+ # on a one-off), but a negative guard scrubs the obvious pleasantry
665
+ # shapes ("always happy to help", "never mind") BEFORE the positive
666
+ # match, so those don't fire on a bare "always"/"never".
667
+ _DIRECTIVE_NUDGE_NEGATIVE_RE = re.compile(
668
+ r"""(?ix)
669
+ (?:
670
+ never \s* mind
671
+ | nevermind
672
+ | always \s+ (?: happy | glad | welcome | here \s+ to \s+ help | open \s+ to )
673
+ | never \s+ (?: fails? | hurts? | too \s+ late | a \s+ dull )
674
+ | as \s+ always
675
+ | thanks? \s+ as \s+ always
676
+ | forever \s+ grateful
677
+ )
678
+ """
679
+ )
680
+
681
+ _DIRECTIVE_NUDGE_RE = re.compile(
682
+ r"""(?ix)
683
+ (?:
684
+ # --- explicit standing-rule framings ---
685
+ \b from \s+ now \s+ on \b
686
+ | \b going \s+ forward \b
687
+ | \b in \s+ (?: the \s+ )? future \b
688
+ | \b (?: as \s+ a \s+ )? general \s+ (?: rule | behaviou?r | policy | principle | agent \s+ behaviou?r ) \b
689
+ | \b as \s+ a \s+ rule \b
690
+ | \b i \s+ want \s+ you \s+ to \b
691
+ | \b you \s+ should \s+ (?: always | never ) \b
692
+ # --- always / never (pleasantries pre-scrubbed by the negative guard) ---
693
+ | \b always \b
694
+ | \b never \b
695
+ | \b no \s+ longer \b
696
+ | \b anymore \b
697
+ # --- prohibitions / behavioural corrections ---
698
+ | \b stop \s+ (?: doing | saying | using | calling | adding | making | being | sending | putting ) \b
699
+ | \b (?: do \s* n['’]? t | don['’]? t | do \s+ not ) \s+ (?: ever \s+ )?
700
+ (?: make | guess | assume | use | say | add | send | call | include | do | put | write | reply | respond | mention ) \b
701
+ | \b (?: do \s* n['’]? t | don['’]? t | do \s+ not ) \b [^.?!]{0,40} \b again \b
702
+ | \b (?: should \s* n['’]? t | shouldn['’]? t | should \s+ not ) \b
703
+ # --- preferences / identity ---
704
+ | \b (?: i | we ) \s+ (?: prefer | always \s+ want | never \s+ want ) \b
705
+ | \b i['’]? d \s+ prefer \b
706
+ | \b call \s+ me \b
707
+ # --- memory / reinforcement ---
708
+ | \b remember \s+ (?: to | that | this | always | never ) \b
709
+ | \b make \s+ sure \s+ (?: you | to | that | not | it ) \b
710
+ # --- explicit wrongness + restatement ---
711
+ | \b that['’]? s \s+ (?: wrong | not \s+ right | incorrect | not \s+ what ) \b
712
+ | \b (?: no ,? \s+ )? it['’]? s \s+ (?: actually | not ) \b
713
+ | \b (?: that['’]? s | it['’]? s ) \s+ (?: dead | deprecated | retired | closed ) \b
714
+ | \b we \s+ (?: now \s+ )? (?: use \s+ only | only \s+ use | no \s+ longer \s+ use ) \b
715
+ )
716
+ """
717
+ )
718
+
719
+ # Terse, advisory. The model decides IN-SESSION whether this is a durable
720
+ # rule and, if so, calls create_directive itself (chat-legible). Kept
721
+ # short so it costs a handful of tokens on a false positive.
722
+ _DIRECTIVE_CAPTURE_NUDGE = (
723
+ "<directive_capture_check>\n"
724
+ "The latest user message is correction- or standing-rule-shaped "
725
+ '(e.g. "always/never …", "from now on …", "stop doing …", a stated '
726
+ 'preference, or "that’s wrong, it’s …"). If it states a DURABLE rule '
727
+ "for how you should behave going forward — not a one-off request for "
728
+ "this task — persist it NOW with mcp__hindsight__create_directive "
729
+ "(verbatim, in the user’s own words) BEFORE you answer, so the "
730
+ "correction survives future sessions. UNLESS an equivalent active "
731
+ "directive already exists (see any <active_directives> block above) — "
732
+ "in that case it is already saved; do NOT create a duplicate. If it’s "
733
+ "only a one-off instruction, ignore this note and just answer.\n"
734
+ "</directive_capture_check>"
735
+ )
736
+
737
+
738
+ def looks_like_standing_rule(text) -> bool:
739
+ """Deterministic (regex-only) test for correction / standing-rule shape.
740
+
741
+ Pleasantry shapes ("always happy to help", "never mind") are scrubbed
742
+ BEFORE the positive match so they can't trip the bare always/never
743
+ signals. Returns False on empty / non-string input. No model call —
744
+ the model does the actual judgment in-session (issue #2848).
745
+ """
746
+ if not isinstance(text, str) or not text.strip():
747
+ return False
748
+ scrubbed = _DIRECTIVE_NUDGE_NEGATIVE_RE.sub(" ", text)
749
+ return bool(_DIRECTIVE_NUDGE_RE.search(scrubbed))
750
+
751
+
752
+ def _combine_context(base, nudge) -> str:
753
+ """Join the recall/directives context with the directive-capture nudge,
754
+ skipping empties. Either may be None/empty. The nudge is kept OUT of the
755
+ cached / last-recall context (it's transient per-inbound) and appended
756
+ only at emit time, so a cache hit re-derives it from the current prompt
757
+ rather than replaying a stale one."""
758
+ parts = [p for p in (base, nudge) if p]
759
+ return "\n\n".join(parts)
760
+
761
+
600
762
  def main():
601
763
  config = load_config()
602
764
 
@@ -665,6 +827,22 @@ def main():
665
827
  debug_log(config, f"Prompt is trivial/stateless ({_ack_form!r}), skipping recall")
666
828
  return
667
829
 
830
+ # Switchroom #2848 Stage B — directive-capture nudge. Deterministic
831
+ # (regex) detection of correction / standing-rule-shaped inbound; when
832
+ # it fires we append a terse advisory to the additionalContext telling
833
+ # the model to persist the rule with create_directive if it IS durable.
834
+ # Computed on `_stripped` (the channel-envelope-stripped text) so the
835
+ # `<channel …>` wrapper never trips detection. Runs AFTER the ack/
836
+ # trivial-skip gates (those return early and genuinely need no nudge)
837
+ # and leaves the recall/directive path below untouched — it only adds
838
+ # to whatever additionalContext that path emits. On by default;
839
+ # HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE=false (or memory.directive_capture_nudge:
840
+ # false) turns it off. No model callsite (claude-native invariant).
841
+ nudge_block = None
842
+ if config.get("directiveCaptureNudge", True) and looks_like_standing_rule(_stripped):
843
+ nudge_block = _DIRECTIVE_CAPTURE_NUDGE
844
+ debug_log(config, "Directive-capture nudge: inbound looks like a standing rule")
845
+
668
846
  session_id = hook_input.get("session_id") or ""
669
847
 
670
848
  # Switchroom #303 — push a "📚 recalling memories" status to the
@@ -771,7 +949,10 @@ def main():
771
949
  cached_context = None
772
950
  if cached_context is not None:
773
951
  debug_log(config, f"Recall cache HIT (key={cache_key[:12]}…) — skipping API call")
774
- _emit_cached_context(cached_context)
952
+ # #2848 — append the nudge to the cached context at emit time
953
+ # (the cache stores nudge-free context; the nudge is re-derived
954
+ # from the current prompt, so a hit can't replay a stale one).
955
+ _emit_cached_context(_combine_context(cached_context, nudge_block))
775
956
  _write_recall_log({
776
957
  "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
777
958
  "session_id": (session_id or "")[:32],
@@ -790,6 +971,7 @@ def main():
790
971
  "active_thread_id": active_thread_id,
791
972
  "active_topic_alias": active_topic_alias,
792
973
  "topic_filter_mode": _topic_filter_mode(),
974
+ "directive_nudge": bool(nudge_block),
793
975
  })
794
976
  return
795
977
  debug_log(config, f"Recall cache MISS (key={cache_key[:12]}…)")
@@ -844,6 +1026,11 @@ def main():
844
1026
  tags=recall_tags,
845
1027
  tags_match=tags_match,
846
1028
  tag_groups=tag_groups,
1029
+ # Switchroom Phase-1 precision — prefer deduped observation
1030
+ # statements over the raw facts they supersede, backfilling freed
1031
+ # slots for denser coverage inside the same budget. On by default;
1032
+ # operators can pin off via `recallPreferObservations: false`.
1033
+ prefer_observations=config.get("recallPreferObservations", True),
847
1034
  # 8s in-script timeout leaves 4s headroom inside the 12s
848
1035
  # UserPromptSubmit hook ceiling (see hooks.json:20) for cache
849
1036
  # write + block formatting. Tightened from 10s in switchroom
@@ -889,6 +1076,10 @@ def main():
889
1076
  tags=extra_tags,
890
1077
  tags_match=extra_tags_match,
891
1078
  tag_groups=extra_tag_groups,
1079
+ # Switchroom Phase-1 precision — prefer deduped observation
1080
+ # statements here too so additional banks contribute their
1081
+ # densest statements to the merged, score-sorted set.
1082
+ prefer_observations=config.get("recallPreferObservations", True),
892
1083
  # 8s in-script timeout leaves 4s headroom inside the 12s
893
1084
  # UserPromptSubmit hook ceiling (see hooks.json:20) for cache
894
1085
  # write + block formatting. Tightened from 10s in switchroom
@@ -957,6 +1148,15 @@ def main():
957
1148
  else:
958
1149
  overlap_dropped = 0
959
1150
 
1151
+ # Switchroom Phase-1 precision — sort the merged primary + additional-bank
1152
+ # result set by the engine's relevance score (`scores.final`) descending
1153
+ # BEFORE the head-slice cap below. Previously additional-bank results were
1154
+ # appended after own-bank results and sliced off, silently starving
1155
+ # profile / shared / sender banks whenever own-bank filled the cap. Sorting
1156
+ # by real relevance first means the cap keeps the most relevant memories
1157
+ # regardless of source bank. Stable sort: ties keep own-bank-first order.
1158
+ _sort_by_final_score(results)
1159
+
960
1160
  # Switchroom-local: client-side count cap. Plugin v0.4.0 has no
961
1161
  # `recallTopK` in the Claude Code integration (Openclaw-only), and a
962
1162
  # token budget alone doesn't bound count — a single long memory can
@@ -1025,8 +1225,12 @@ def main():
1025
1225
  update_placeholder(placeholder_chat_id, "💭 thinking")
1026
1226
 
1027
1227
  # If neither block has content, there's nothing to inject — exit
1028
- # silently to avoid emitting an empty hookSpecificOutput.
1228
+ # silently to avoid emitting an empty hookSpecificOutput. #2848: unless
1229
+ # the directive-capture nudge fired, in which case emit the nudge alone
1230
+ # (a correction with no memories/directives still needs the reminder).
1029
1231
  if not directives_block and not memories_block:
1232
+ if nudge_block:
1233
+ _emit_cached_context(nudge_block)
1030
1234
  return
1031
1235
 
1032
1236
  # Compose final context. Directives block goes ABOVE memories so the
@@ -1092,13 +1296,16 @@ def main():
1092
1296
  "source_topics": source_topic_summary,
1093
1297
  "topic_filter_mode": topic_filter_mode,
1094
1298
  "topic_dropped": topic_dropped,
1299
+ "directive_nudge": bool(nudge_block),
1095
1300
  })
1096
1301
 
1097
- # Output JSON for Claude Code hook system
1302
+ # Output JSON for Claude Code hook system. #2848: append the
1303
+ # directive-capture nudge (if it fired) at emit time — it's kept out of
1304
+ # the cached / last-recall context above so it can't go stale.
1098
1305
  output = {
1099
1306
  "hookSpecificOutput": {
1100
1307
  "hookEventName": "UserPromptSubmit",
1101
- "additionalContext": context_message,
1308
+ "additionalContext": _combine_context(context_message, nudge_block),
1102
1309
  }
1103
1310
  }
1104
1311
  json.dump(output, sys.stdout)
@@ -147,6 +147,23 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
147
147
 
148
148
  debug_log(config, f"Retain hook_input keys: {list(hook_input.keys())} force={force}")
149
149
 
150
+ # Blocked-Stop double-fire guard (switchroom #2848 Phase 3): when
151
+ # directive_verify.py blocks a Stop, the model continues and the Stop
152
+ # event fires AGAIN, this time carrying ``stop_hook_active: true``.
153
+ # Retaining on that second fire would (a) POST a duplicate transcript
154
+ # document and (b) at retainEveryNTurns>1 call increment_turn_count a
155
+ # second time for the SAME logical turn — drifting the cadence throttle.
156
+ # Skip the re-fire entirely BEFORE any store or turn-count increment.
157
+ # A forced SessionEnd sweep (force=True) is a distinct hook event and
158
+ # must always flush, so it is exempt.
159
+ if not force and hook_input.get("stop_hook_active"):
160
+ debug_log(
161
+ config,
162
+ "Stop re-fire (stop_hook_active=true) — skipping retain to avoid "
163
+ "duplicate document / turn-count drift",
164
+ )
165
+ return {"status": "skipped", "reason": "stop_hook_active"}
166
+
150
167
  session_id = hook_input.get("session_id", "unknown")
151
168
  transcript_path = hook_input.get("transcript_path", "")
152
169
 
@@ -44,6 +44,15 @@ def build_hooks(plugin_root: str) -> dict:
44
44
  }
45
45
  ],
46
46
  "Stop": [
47
+ {
48
+ "hooks": [
49
+ {
50
+ "type": "command",
51
+ "command": f'python3 "{plugin_root}/scripts/directive_verify.py"',
52
+ "timeout": 10,
53
+ }
54
+ ]
55
+ },
47
56
  {
48
57
  "hooks": [
49
58
  {