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.
- package/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +65 -5
- package/dist/auth-broker/index.js +6623 -514
- package/dist/cli/notion-write-pretool.mjs +64 -4
- package/dist/cli/switchroom.js +1888 -1162
- package/dist/host-control/main.js +6306 -162
- package/dist/vault/approvals/kernel-server.js +6014 -202
- package/dist/vault/broker/server.js +6741 -940
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +218 -25
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +116 -0
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +369 -0
- package/telegram-plugin/gateway/gateway.ts +1861 -172
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +135 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +217 -10
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
Binary file
|
|
@@ -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
|
|
37
|
-
# scores
|
|
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
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
#
|
|
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
|
-
|
|
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
|
|
|
Binary file
|
|
Binary file
|
package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|