switchroom 0.21.16 → 0.21.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +5 -0
- package/dist/auth-broker/index.js +5 -0
- package/dist/cli/notion-write-pretool.mjs +5 -0
- package/dist/cli/switchroom.js +163 -31
- package/dist/host-control/main.js +6 -1
- package/dist/vault/approvals/kernel-server.js +5 -0
- package/dist/vault/broker/server.js +5 -0
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +11 -0
- package/telegram-plugin/dist/gateway/gateway.js +9 -4
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +43 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +35 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/orientation.py +248 -0
- package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +29 -0
- package/vendor/hindsight-memory/scripts/orientation.py +195 -0
- package/vendor/hindsight-memory/scripts/prefetch.py +10 -0
- package/vendor/hindsight-memory/scripts/recall.py +144 -11
- package/vendor/hindsight-memory/scripts/setup_hooks.py +10 -1
- package/vendor/hindsight-memory/scripts/tests/fixtures/rules-block.golden.md +9 -0
- package/vendor/hindsight-memory/scripts/tests/test_orientation_hook.py +283 -0
- package/vendor/hindsight-memory/scripts/tests/test_orientation_logic.py +176 -0
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_invalidation.py +329 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_directive_suppression.py +328 -0
|
@@ -210,6 +210,35 @@ def read_if_fresh(session_id: str, last_consumed_token: Optional[int]) -> tuple:
|
|
|
210
210
|
return {"context": payload.get("context", ""), "telemetry": payload.get("telemetry", {})}, token
|
|
211
211
|
|
|
212
212
|
|
|
213
|
+
def invalidate(session_id: str) -> None:
|
|
214
|
+
"""Drop any pending prefetch buffer + sentinel for ``session_id``.
|
|
215
|
+
|
|
216
|
+
Called at MUTATION sites — a rule/directive retire (``directive_verify``)
|
|
217
|
+
or a retain (``prefetch.run_prefetch`` before it re-recalls) — so a buffer
|
|
218
|
+
captured BEFORE the mutation can never be consumed AFTER it. This closes
|
|
219
|
+
the resurrection class the M3 red-team (R2, BLOCKER) flagged at the
|
|
220
|
+
M3/M4 intersection: a rule retired mid-session must not re-inject from a
|
|
221
|
+
buffer prefetched while the rule was still active. It is the read-after-
|
|
222
|
+
write ordering the producer already builds for the happy path, extended
|
|
223
|
+
to the mutation path — NOT a reliance on the buffer's TTL (timing, not
|
|
224
|
+
correctness).
|
|
225
|
+
|
|
226
|
+
The SENTINEL is removed FIRST so a partial invalidation still fails
|
|
227
|
+
closed: ``read_if_fresh`` checks the sentinel before the payload, so once
|
|
228
|
+
the sentinel is gone the reader returns ``None`` regardless of whether the
|
|
229
|
+
payload unlink also succeeded — an orphaned payload is never served as
|
|
230
|
+
fresh. Best-effort and idempotent: a missing file is a no-op, and the
|
|
231
|
+
function NEVER raises (a failed unlink must not break the turn).
|
|
232
|
+
"""
|
|
233
|
+
for path in (_sentinel_path(session_id), _buffer_path(session_id)):
|
|
234
|
+
try:
|
|
235
|
+
os.remove(path)
|
|
236
|
+
except FileNotFoundError:
|
|
237
|
+
pass
|
|
238
|
+
except OSError:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
|
|
213
242
|
def poll_for_sentinel(session_id: str, last_consumed_token: Optional[int], cap_ms: int) -> bool:
|
|
214
243
|
"""Clock-bounded poll for a fresh sentinel. Never busy-spins.
|
|
215
244
|
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionStart hook: Memory v2 M5 — orientation-at-boot (Surface B).
|
|
3
|
+
|
|
4
|
+
carve-M5.md §3. Injects the agent's cron-refreshed ``orientation`` mental
|
|
5
|
+
model into context at SessionStart AND re-seats it after every compaction —
|
|
6
|
+
"reflect once, serve from cache" (E-79), deterministically, zero tool call.
|
|
7
|
+
|
|
8
|
+
DARK BY DEFAULT (red-team kill switch). Entirely gated by
|
|
9
|
+
``memoryOrientationEnabled`` (default False — carve §4, same discipline as
|
|
10
|
+
M4's ``memoryPrefetchEnabled``). When off this hook is a hard no-op: it reads
|
|
11
|
+
config, sees the flag off, and exits BEFORE any bank resolve or network call.
|
|
12
|
+
The flag is per-agent — a stripped/absent value fails to OFF (fail-safe), so
|
|
13
|
+
an un-flipped agent boots exactly as pre-M5.
|
|
14
|
+
|
|
15
|
+
MATCHER-LESS (load-bearing, carve §1/§3). Registered with NO matcher, so it
|
|
16
|
+
fires on every SessionStart source — startup, resume, clear, fork AND compact.
|
|
17
|
+
The compact firing is the free deterministic post-compaction re-seat that
|
|
18
|
+
answers E-88 ("nothing re-seats orientation today"): the hook re-reads the
|
|
19
|
+
model from the engine each time, so a mid-session refresh is picked up at the
|
|
20
|
+
next compaction with no session restart. Do NOT add a matcher.
|
|
21
|
+
|
|
22
|
+
FAIL-SAFE INVARIANTS (all asserted in tests/test_orientation_hook.py):
|
|
23
|
+
- Boot is NEVER blocked on the orientation read. Every failure path
|
|
24
|
+
(server down, no model, read error, read timeout, degraded/undated
|
|
25
|
+
model) degrades to a VISIBLE one-line cold notice + a refresh request,
|
|
26
|
+
and exits 0. Never a silently memoryless boot, never a stale briefing
|
|
27
|
+
presented as fresh.
|
|
28
|
+
- A stale (1.5×–3× cadence) model is injected WITH a visible prefix; a
|
|
29
|
+
degraded (>3×) or undated model is NOT injected — the cold notice is
|
|
30
|
+
emitted instead. Thresholds are per-tier (carve §5), not a fixed 36h.
|
|
31
|
+
|
|
32
|
+
STDOUT CONTRACT. A SessionStart hook's stdout IS added to the model's context
|
|
33
|
+
(unlike PreCompact); we emit ``{"hookSpecificOutput":{"hookEventName":
|
|
34
|
+
"SessionStart","additionalContext": "…"}}``. Exit code is 0 ALWAYS — a
|
|
35
|
+
non-zero exit BLOCKS the turn in Claude Code, and orientation must degrade to
|
|
36
|
+
a notice, never a hard stop (same doctrine as rules-sentinel-hook.sh).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import sys
|
|
42
|
+
|
|
43
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
44
|
+
|
|
45
|
+
from lib import orientation as orient
|
|
46
|
+
from lib.bank import derive_bank_id
|
|
47
|
+
from lib.client import HindsightClient
|
|
48
|
+
from lib.config import debug_log, load_config
|
|
49
|
+
from lib.daemon import get_api_url
|
|
50
|
+
|
|
51
|
+
#: Generous read-timeout for the model GET (carve §3). overlord's own-bank read
|
|
52
|
+
#: is ~270ms and klanker's ~4ms (m0/m5-0 probes), but the latency asymmetry is
|
|
53
|
+
#: unexplained and possibly bank-size-correlated, so leave headroom — and on a
|
|
54
|
+
#: timeout degrade to the cold notice, never a hung boot.
|
|
55
|
+
ORIENTATION_READ_TIMEOUT_S = 3
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _emit(additional_context: str) -> None:
|
|
59
|
+
"""Print the SessionStart additionalContext envelope to stdout."""
|
|
60
|
+
print(
|
|
61
|
+
json.dumps(
|
|
62
|
+
{
|
|
63
|
+
"hookSpecificOutput": {
|
|
64
|
+
"hookEventName": "SessionStart",
|
|
65
|
+
"additionalContext": additional_context,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def enqueue_refresh(config: dict, bank_id: str, model_name: str) -> None:
|
|
73
|
+
"""Signal that ``bank_id``'s orientation model needs a background refresh.
|
|
74
|
+
|
|
75
|
+
The cold/degraded path requests a refresh so a boot that found no usable
|
|
76
|
+
briefing does not stay briefing-less indefinitely (carve §3-4). This is the
|
|
77
|
+
SEAM the M5-C out-of-session tiered scheduler consumes; the scheduler is
|
|
78
|
+
cadence-driven regardless, so this is a best-effort nudge, never a blocking
|
|
79
|
+
dependency. Kept as a thin, override-able function so the cold-path tests
|
|
80
|
+
can assert the OUTCOME ("a refresh was requested") without a live scheduler.
|
|
81
|
+
|
|
82
|
+
Best-effort and total: any failure is swallowed — a boot must never fail
|
|
83
|
+
because a refresh request could not be recorded.
|
|
84
|
+
"""
|
|
85
|
+
debug_log(
|
|
86
|
+
config,
|
|
87
|
+
f"orientation cold/degraded on bank '{bank_id}' model "
|
|
88
|
+
f"'{model_name}' — refresh requested",
|
|
89
|
+
)
|
|
90
|
+
try:
|
|
91
|
+
state_dir = os.environ.get("HINDSIGHT_STATE_DIR") or os.path.join(
|
|
92
|
+
os.path.expanduser("~"), ".hindsight"
|
|
93
|
+
)
|
|
94
|
+
os.makedirs(state_dir, exist_ok=True)
|
|
95
|
+
marker = os.path.join(state_dir, "orientation-refresh-pending.jsonl")
|
|
96
|
+
with open(marker, "a") as f:
|
|
97
|
+
f.write(json.dumps({"bank_id": bank_id, "model": model_name}) + "\n")
|
|
98
|
+
except Exception as e: # noqa: BLE001 — best-effort, never breaks boot
|
|
99
|
+
debug_log(config, f"orientation refresh-request write failed (ignored): {e}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _match_model(models: dict, name: str) -> dict | None:
|
|
103
|
+
"""Find the mental model whose ``name`` matches the configured orientation name."""
|
|
104
|
+
items = models.get("items") if isinstance(models, dict) else None
|
|
105
|
+
if not isinstance(items, list):
|
|
106
|
+
return None
|
|
107
|
+
for item in items:
|
|
108
|
+
if isinstance(item, dict) and item.get("name") == name:
|
|
109
|
+
return item
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def run(hook_input: dict, config: dict) -> None:
|
|
114
|
+
"""Core hook logic. Emits at most one additionalContext block; never raises."""
|
|
115
|
+
if not config.get("memoryOrientationEnabled", False):
|
|
116
|
+
debug_log(config, "memoryOrientationEnabled off — orientation no-op")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
model_name = config.get("memoryOrientationModel", "orientation")
|
|
120
|
+
cadence_hours = int(config.get("memoryOrientationCadenceHours", 48) or 48)
|
|
121
|
+
bank_id = derive_bank_id(hook_input, config)
|
|
122
|
+
|
|
123
|
+
def _dbg(*a):
|
|
124
|
+
debug_log(config, *a)
|
|
125
|
+
|
|
126
|
+
# Resolve the engine REST URL WITHOUT starting the daemon (too slow for
|
|
127
|
+
# SessionStart). If the server is unreachable, degrade to the cold notice —
|
|
128
|
+
# we cannot read the model, and boot must not block.
|
|
129
|
+
try:
|
|
130
|
+
api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=False)
|
|
131
|
+
client = HindsightClient(api_url, config.get("hindsightApiToken"))
|
|
132
|
+
except (RuntimeError, ValueError) as e:
|
|
133
|
+
debug_log(config, f"orientation: Hindsight unreachable ({e}) — cold notice")
|
|
134
|
+
enqueue_refresh(config, bank_id, model_name)
|
|
135
|
+
_emit(orient.cold_notice())
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# Resolve the orientation model by NAME → id, then read its content +
|
|
139
|
+
# freshness watermark. Any read error/timeout is a cold notice, not a hang.
|
|
140
|
+
try:
|
|
141
|
+
models = client.list_mental_models(bank_id, timeout=ORIENTATION_READ_TIMEOUT_S)
|
|
142
|
+
match = _match_model(models, model_name)
|
|
143
|
+
except Exception as e: # noqa: BLE001 — degrade to cold, never block boot
|
|
144
|
+
debug_log(config, f"orientation: list models failed ({e}) — cold notice")
|
|
145
|
+
enqueue_refresh(config, bank_id, model_name)
|
|
146
|
+
_emit(orient.cold_notice())
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
if not match or not match.get("id"):
|
|
150
|
+
debug_log(config, f"orientation: no model named '{model_name}' — cold notice")
|
|
151
|
+
enqueue_refresh(config, bank_id, model_name)
|
|
152
|
+
_emit(orient.cold_notice())
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
full = client.get_mental_model(
|
|
157
|
+
bank_id, match["id"], detail="full", timeout=ORIENTATION_READ_TIMEOUT_S
|
|
158
|
+
)
|
|
159
|
+
except Exception as e: # noqa: BLE001 — includes urllib timeout
|
|
160
|
+
debug_log(config, f"orientation: model read failed ({e}) — cold notice")
|
|
161
|
+
enqueue_refresh(config, bank_id, model_name)
|
|
162
|
+
_emit(orient.cold_notice())
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
content = (full or {}).get("content") or ""
|
|
166
|
+
last_refreshed_at = (full or {}).get("last_refreshed_at")
|
|
167
|
+
state, hours_ago = orient.classify_staleness(last_refreshed_at, cadence_hours)
|
|
168
|
+
|
|
169
|
+
if not content.strip() or state in ("degraded", "unknown"):
|
|
170
|
+
# Empty payload, or refreshed > 3× cadence ago / undated: never present
|
|
171
|
+
# stale-as-fresh. Cold notice + refresh request (carve §5).
|
|
172
|
+
debug_log(config, f"orientation: state={state}, empty={not content.strip()} — cold notice")
|
|
173
|
+
enqueue_refresh(config, bank_id, model_name)
|
|
174
|
+
_emit(orient.cold_notice())
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
debug_log(config, f"orientation: injecting (state={state}, {hours_ago}h ago)")
|
|
178
|
+
_emit(orient.render_orientation(content, state, hours_ago))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main():
|
|
182
|
+
config = load_config()
|
|
183
|
+
try:
|
|
184
|
+
hook_input = json.load(sys.stdin)
|
|
185
|
+
except (json.JSONDecodeError, EOFError):
|
|
186
|
+
hook_input = {}
|
|
187
|
+
run(hook_input, config)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
try:
|
|
192
|
+
main()
|
|
193
|
+
except Exception as e: # noqa: BLE001 — a hook that exits non-zero blocks the turn
|
|
194
|
+
print(f"[Hindsight] Orientation SessionStart error: {e}", file=sys.stderr)
|
|
195
|
+
sys.exit(0)
|
|
@@ -80,6 +80,16 @@ def run_prefetch(hook_input: dict, config: dict) -> bool:
|
|
|
80
80
|
debug_log(config, "Prefetch: task-notification turn, skipping")
|
|
81
81
|
return False
|
|
82
82
|
|
|
83
|
+
# Step 0 — MUTATION INVALIDATION (red-team-M3 R2, BLOCKER). This turn is
|
|
84
|
+
# about to retain (step 1) — a memory mutation. Drop any buffer left from a
|
|
85
|
+
# prior turn BEFORE recalling, so that if this turn's fresh recall fails or
|
|
86
|
+
# returns empty (steps 2-3 below bail without overwriting), the consumer
|
|
87
|
+
# cannot resurrect the pre-mutation snapshot: with no sentinel it falls to
|
|
88
|
+
# the explicitly stale-marked LAST_RECALL_STATE / degraded notice instead of
|
|
89
|
+
# serving a stale buffer as fresh. A successful recall repopulates the buffer
|
|
90
|
+
# with the post-mutation snapshot at step 3. Never raises.
|
|
91
|
+
recall_buffer.invalidate(session_id)
|
|
92
|
+
|
|
83
93
|
# Step 1 — delta retain (Fix A: content-derived document_id, never the
|
|
84
94
|
# bare {session_id} document; never truncates).
|
|
85
95
|
try:
|
|
@@ -157,6 +157,110 @@ def _topic_filter_mode() -> str:
|
|
|
157
157
|
return "soft-preamble"
|
|
158
158
|
|
|
159
159
|
|
|
160
|
+
# ── Memory v2 M3 (Surface-A) — directive-injection flip guard ───────────────
|
|
161
|
+
#
|
|
162
|
+
# Once an agent has migrated its standing directives into the CLAUDE.md rules
|
|
163
|
+
# block (Memory v2 M1) AND flipped `injectDirectives` OFF, this hook stops
|
|
164
|
+
# injecting the `<active_directives>` block on every turn: the rules block
|
|
165
|
+
# already carries the same guarantees inline, so re-injecting the directives is
|
|
166
|
+
# ~pure token waste (the whole point of M3 — ~49M directive-tokens/30d saved
|
|
167
|
+
# fleet-wide once the heavy agents follow ziggy).
|
|
168
|
+
#
|
|
169
|
+
# `injectDirectives` defaults TRUE (lib/config.py DEFAULTS); an agent is only
|
|
170
|
+
# flipped when switchroom.yaml sets `memory.inject_directives: false`, which
|
|
171
|
+
# plumbs to `HINDSIGHT_INJECT_DIRECTIVES=false` (lib/config.py ENV_OVERRIDES).
|
|
172
|
+
#
|
|
173
|
+
# The flip is DELIBERATELY guarded on physical evidence, not the flag alone:
|
|
174
|
+
# suppression fires IFF the flag is off AND a non-empty rules block is actually
|
|
175
|
+
# present in CLAUDE.md. If the flag is off but no rules block exists (mis-
|
|
176
|
+
# sequenced flip, or M1 not yet run for this agent), we FAIL SAFE — keep
|
|
177
|
+
# injecting directives and surface a one-line degraded-canary notice — rather
|
|
178
|
+
# than strip every guardrail the agent has. A mis-sequenced flip is therefore
|
|
179
|
+
# loud and non-destructive, never silently memory-less. Suppress-only: this
|
|
180
|
+
# NEVER deletes a directive, it only stops re-injecting it. Format-locked to
|
|
181
|
+
# src/memory/rules-block.ts (a golden-fixture test guards against TS drift).
|
|
182
|
+
# See carve-M3.md §2/§4.
|
|
183
|
+
RULES_BLOCK_BEGIN_MARKER = "<!-- switchroom:rules:begin -->"
|
|
184
|
+
RULES_BLOCK_END_MARKER = "<!-- switchroom:rules:end -->"
|
|
185
|
+
|
|
186
|
+
# A rendered rule line looks like `- **R-01** (source: X, added Y): text`. The
|
|
187
|
+
# literal `(none)` placeholder an empty block renders must NOT count as a rule
|
|
188
|
+
# (that is exactly the empty-block case we fail-safe on). Anchored, no
|
|
189
|
+
# unbounded alternation.
|
|
190
|
+
_RULES_BLOCK_RULE_LINE_RE = re.compile(r"^- \*\*R-", re.MULTILINE)
|
|
191
|
+
|
|
192
|
+
# config.py DEFAULTS key + settings.json field name for the flip flag.
|
|
193
|
+
INJECT_DIRECTIVES_CONFIG_KEY = "injectDirectives"
|
|
194
|
+
|
|
195
|
+
_DIRECTIVE_FLIP_DEGRADED_NOTICE = (
|
|
196
|
+
"⚠️ memory config: injectDirectives is OFF for this agent but no CLAUDE.md "
|
|
197
|
+
"rules block was found — still injecting <active_directives> this turn to "
|
|
198
|
+
"avoid a guardrail-less turn. Run the M1 rules-block migration (or "
|
|
199
|
+
"re-enable injectDirectives) to clear this notice."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _recall_project_dir(hook_input: dict) -> str | None:
|
|
204
|
+
"""Resolve the agent's project directory (where CLAUDE.md lives).
|
|
205
|
+
|
|
206
|
+
Prefers `CLAUDE_PROJECT_DIR` (set by Claude Code for hook subprocesses);
|
|
207
|
+
falls back to the hook payload's `cwd`. Returns None when neither is a
|
|
208
|
+
usable directory, so callers treat "can't locate the project dir" the same
|
|
209
|
+
as "no rules block" — fail-safe, keep injecting.
|
|
210
|
+
"""
|
|
211
|
+
for candidate in (os.environ.get("CLAUDE_PROJECT_DIR"), hook_input.get("cwd")):
|
|
212
|
+
if candidate and os.path.isdir(candidate):
|
|
213
|
+
return candidate
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def rules_block_present(project_dir: str | None) -> bool:
|
|
218
|
+
"""True iff CLAUDE.md in `project_dir` carries a NON-EMPTY rules block.
|
|
219
|
+
|
|
220
|
+
Non-empty means: the `switchroom:rules:begin/end` markers are both present
|
|
221
|
+
AND at least one rendered rule line (`- **R-…`) sits between them. The
|
|
222
|
+
`(none)` placeholder an empty block renders does NOT count. Any IO error →
|
|
223
|
+
False (fail-safe: absence of proof of a rules block keeps injection on).
|
|
224
|
+
"""
|
|
225
|
+
if not project_dir:
|
|
226
|
+
return False
|
|
227
|
+
path = os.path.join(project_dir, "CLAUDE.md")
|
|
228
|
+
try:
|
|
229
|
+
with open(path, encoding="utf-8") as fh:
|
|
230
|
+
text = fh.read()
|
|
231
|
+
except OSError:
|
|
232
|
+
return False
|
|
233
|
+
begin = text.find(RULES_BLOCK_BEGIN_MARKER)
|
|
234
|
+
if begin == -1:
|
|
235
|
+
return False
|
|
236
|
+
end = text.find(RULES_BLOCK_END_MARKER, begin)
|
|
237
|
+
if end == -1:
|
|
238
|
+
return False
|
|
239
|
+
inner = text[begin + len(RULES_BLOCK_BEGIN_MARKER):end]
|
|
240
|
+
return _RULES_BLOCK_RULE_LINE_RE.search(inner) is not None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def directive_injection_decision(config: dict, hook_input: dict) -> tuple[bool, str | None]:
|
|
244
|
+
"""Decide whether to inject the `<active_directives>` block this turn.
|
|
245
|
+
|
|
246
|
+
Returns `(inject, degraded_notice)`:
|
|
247
|
+
* flag ON (default) → (True, None) inject normally
|
|
248
|
+
* flag OFF and rules block present → (False, None) SUPPRESS (M3 flip)
|
|
249
|
+
* flag OFF but NO rules block present → (True, notice) FAIL SAFE: still
|
|
250
|
+
inject AND surface a one-line degraded-canary notice, so a mis-
|
|
251
|
+
sequenced flip is loud instead of silently guardrail-less.
|
|
252
|
+
|
|
253
|
+
Central to all three recall emit surfaces (main path, prefetch fast path,
|
|
254
|
+
task-notification directives-only path) so a flipped agent cannot leak
|
|
255
|
+
directives on a cache hit. Suppress-only — never deletes a directive.
|
|
256
|
+
"""
|
|
257
|
+
if config.get(INJECT_DIRECTIVES_CONFIG_KEY, True):
|
|
258
|
+
return True, None
|
|
259
|
+
if rules_block_present(_recall_project_dir(hook_input)):
|
|
260
|
+
return False, None
|
|
261
|
+
return True, _DIRECTIVE_FLIP_DEGRADED_NOTICE
|
|
262
|
+
|
|
263
|
+
|
|
160
264
|
def _filter_by_active_topic(results: list, active_thread_id: str | None) -> tuple[list, int]:
|
|
161
265
|
"""When hard-filter mode is on AND we know the active thread, drop
|
|
162
266
|
any memory whose stored metadata.thread_id is set to a different
|
|
@@ -471,9 +575,17 @@ def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool
|
|
|
471
575
|
debug_log(config, f"Prefetch buffer: directive fetch failed: {exc}")
|
|
472
576
|
directives_block = None
|
|
473
577
|
|
|
578
|
+
# Memory v2 M3 (Surface-A) — apply the directive-injection flip decision on
|
|
579
|
+
# the fast path too, so a flipped agent does not leak `<active_directives>`
|
|
580
|
+
# on a prefetch/cache hit. Suppress iff flag off AND rules block present;
|
|
581
|
+
# else keep injecting (and carry the degraded-canary notice, if any).
|
|
582
|
+
_inject_directives, _dir_notice = directive_injection_decision(config, hook_input)
|
|
583
|
+
if not _inject_directives:
|
|
584
|
+
directives_block = None
|
|
585
|
+
|
|
474
586
|
if payload is not None:
|
|
475
587
|
memories_block = payload.get("context") or ""
|
|
476
|
-
parts = [b for b in (directives_block, memories_block) if b]
|
|
588
|
+
parts = [b for b in (_dir_notice, directives_block, memories_block) if b]
|
|
477
589
|
if not parts:
|
|
478
590
|
return False
|
|
479
591
|
_emit_cached_context("\n\n".join(parts))
|
|
@@ -486,13 +598,13 @@ def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool
|
|
|
486
598
|
last = read_state(LAST_RECALL_STATE) or {}
|
|
487
599
|
stale_memories = last.get("memories_context") or ""
|
|
488
600
|
stale_block = stale_recall_notice(stale_memories)
|
|
489
|
-
parts = [b for b in (directives_block, stale_block) if b]
|
|
601
|
+
parts = [b for b in (_dir_notice, directives_block, stale_block) if b]
|
|
490
602
|
if parts:
|
|
491
603
|
_emit_cached_context("\n\n".join(parts))
|
|
492
604
|
return True
|
|
493
605
|
|
|
494
|
-
if directives_block:
|
|
495
|
-
_emit_cached_context(directives_block)
|
|
606
|
+
if directives_block or _dir_notice:
|
|
607
|
+
_emit_cached_context("\n\n".join([b for b in (_dir_notice, directives_block) if b]))
|
|
496
608
|
return True
|
|
497
609
|
|
|
498
610
|
# Nothing fresh, nothing stale, nothing cached — say so explicitly
|
|
@@ -534,8 +646,15 @@ def _emit_directives_only(config: dict, hook_input: dict) -> None:
|
|
|
534
646
|
debug_log(config, f"Task-notification skip: directive fetch failed: {exc}")
|
|
535
647
|
return
|
|
536
648
|
|
|
537
|
-
|
|
538
|
-
|
|
649
|
+
# Memory v2 M3 (Surface-A) — apply the flip decision here too, so a flipped
|
|
650
|
+
# agent's directives-only fast path (task-notification junk gate) also
|
|
651
|
+
# suppresses `<active_directives>` once the rules block carries them.
|
|
652
|
+
_inject_directives, _dir_notice = directive_injection_decision(config, hook_input)
|
|
653
|
+
if not _inject_directives:
|
|
654
|
+
directives_block = None
|
|
655
|
+
|
|
656
|
+
if directives_block or _dir_notice:
|
|
657
|
+
_emit_cached_context("\n\n".join([b for b in (_dir_notice, directives_block) if b]))
|
|
539
658
|
|
|
540
659
|
|
|
541
660
|
def _is_demoted_memory(memory) -> bool:
|
|
@@ -2829,6 +2948,16 @@ def main():
|
|
|
2829
2948
|
bank_errored = any(bt.get("errored") for bt in bank_timings)
|
|
2830
2949
|
|
|
2831
2950
|
directives_block = format_active_directives_block(directives) if directives else None
|
|
2951
|
+
|
|
2952
|
+
# Memory v2 M3 (Surface-A) — directive-injection flip decision. Suppress the
|
|
2953
|
+
# `<active_directives>` block iff this agent is flipped (injectDirectives
|
|
2954
|
+
# OFF) AND a non-empty CLAUDE.md rules block physically carries the same
|
|
2955
|
+
# guarantees; else keep injecting and carry a degraded-canary notice. This
|
|
2956
|
+
# is the primary (synchronous) surface; the two fast paths above mirror it.
|
|
2957
|
+
_inject_directives, directive_flip_notice = directive_injection_decision(config, hook_input)
|
|
2958
|
+
if not _inject_directives:
|
|
2959
|
+
directives_block = None
|
|
2960
|
+
|
|
2832
2961
|
if directives_block:
|
|
2833
2962
|
debug_log(config, f"Injecting {len(directives)} active directives")
|
|
2834
2963
|
|
|
@@ -3267,12 +3396,13 @@ def main():
|
|
|
3267
3396
|
# is precisely the turn on which the agent must not assume it remembers.
|
|
3268
3397
|
# #3837: so is a set the score floor withheld entirely.
|
|
3269
3398
|
if not directives_block and not memories_block and not transcript_fallback_block:
|
|
3270
|
-
if degraded_block or withheld_block or nudge_block or profile_nudge_block:
|
|
3399
|
+
if degraded_block or withheld_block or nudge_block or profile_nudge_block or directive_flip_notice:
|
|
3271
3400
|
_emit_cached_context(
|
|
3272
3401
|
"\n\n".join(
|
|
3273
3402
|
[
|
|
3274
3403
|
b
|
|
3275
3404
|
for b in (
|
|
3405
|
+
directive_flip_notice,
|
|
3276
3406
|
degraded_block,
|
|
3277
3407
|
withheld_block,
|
|
3278
3408
|
nudge_block,
|
|
@@ -3346,14 +3476,17 @@ def main():
|
|
|
3346
3476
|
"hookSpecificOutput": {
|
|
3347
3477
|
"hookEventName": "UserPromptSubmit",
|
|
3348
3478
|
"additionalContext": _combine_context(
|
|
3479
|
+
directive_flip_notice,
|
|
3349
3480
|
_combine_context(
|
|
3350
3481
|
_combine_context(
|
|
3351
|
-
_combine_context(
|
|
3352
|
-
|
|
3482
|
+
_combine_context(
|
|
3483
|
+
_combine_context(degraded_block, withheld_block),
|
|
3484
|
+
context_message,
|
|
3485
|
+
),
|
|
3486
|
+
nudge_block,
|
|
3353
3487
|
),
|
|
3354
|
-
|
|
3488
|
+
profile_nudge_block,
|
|
3355
3489
|
),
|
|
3356
|
-
profile_nudge_block,
|
|
3357
3490
|
),
|
|
3358
3491
|
}
|
|
3359
3492
|
}
|
|
@@ -73,7 +73,16 @@ def build_hooks(plugin_root: str) -> dict:
|
|
|
73
73
|
"timeout": 5,
|
|
74
74
|
}
|
|
75
75
|
]
|
|
76
|
-
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"hooks": [
|
|
79
|
+
{
|
|
80
|
+
"type": "command",
|
|
81
|
+
"command": f'python3 "{plugin_root}/scripts/orientation.py"',
|
|
82
|
+
"timeout": 8,
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
},
|
|
77
86
|
],
|
|
78
87
|
"SessionEnd": [
|
|
79
88
|
{
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- switchroom:rules:begin -->
|
|
2
|
+
Standing rules — sanctioned via the `memory rule` tool. Do not hand-edit;
|
|
3
|
+
edits made outside the tool break the tamper sentinel below.
|
|
4
|
+
|
|
5
|
+
- **R-01** (source: reflect-directive, added 2026-08-17T00:00:00.000Z): Always end replies to the user with a one-line summary.
|
|
6
|
+
- **R-02** (source: reflect-directive, added 2026-08-17T00:00:00.000Z): Never send email without explicit user approval.
|
|
7
|
+
|
|
8
|
+
<!-- switchroom:rules:sentinel sha256=533c23603bb3b72930c8c79b4f4970481b0ce7caf13c3e2bb63953b652b8a0ae rules=2 -->
|
|
9
|
+
<!-- switchroom:rules:end -->
|