switchroom 0.17.5 → 0.17.10
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 +39 -5
- package/dist/auth-broker/index.js +386 -208
- package/dist/cli/notion-write-pretool.mjs +36 -3
- package/dist/cli/switchroom.js +1185 -585
- package/dist/host-control/main.js +149 -15
- package/dist/vault/approvals/kernel-server.js +141 -56
- package/dist/vault/broker/server.js +143 -58
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +50 -6
- package/profiles/default/CLAUDE.md +116 -0
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +50 -2
- 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/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +2597 -645
- 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 +293 -0
- package/telegram-plugin/gateway/auth-command.ts +1 -1
- package/telegram-plugin/gateway/gateway.ts +1414 -119
- 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/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/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +75 -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 +33 -9
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -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/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/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/worktree-watch-cwds.test.ts +103 -0
- 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/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 +60 -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/config.py +37 -0
- 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 +153 -4
- 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_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_recall_exit_codes.py +49 -2
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Post-turn directive-capture verification hook for the Stop event.
|
|
3
|
+
|
|
4
|
+
Switchroom #2848 Stage C — deterministic correction capture (hindsight
|
|
5
|
+
synthesis-layers RFC, Phase 3 "corrections stick").
|
|
6
|
+
|
|
7
|
+
Stage B (recall.py) appends an advisory nudge to the UserPromptSubmit
|
|
8
|
+
context when the inbound looks correction-shaped, then trusts the model to
|
|
9
|
+
call ``mcp__hindsight__create_directive`` itself. That closes part of the
|
|
10
|
+
~55% miss rate Stage A measured, but capture still relies on the model
|
|
11
|
+
CHOOSING to act on the nudge — a purely advisory path. When the model
|
|
12
|
+
silently ignores the nudge, a durable correction is lost the same way it was
|
|
13
|
+
before Stage B.
|
|
14
|
+
|
|
15
|
+
This hook closes the residual gap for the HIGH-CONFIDENCE case. On Stop it:
|
|
16
|
+
|
|
17
|
+
1. Re-reads the transcript, isolates the human turn that opened this turn,
|
|
18
|
+
and tests it against a NARROW, high-precision "durable standing rule"
|
|
19
|
+
regex (a strict subset of Stage B's inclusive detector — see
|
|
20
|
+
``looks_like_durable_directive``). Bare "always"/"never"/"stop …" and
|
|
21
|
+
other one-off-prone shapes are deliberately EXCLUDED here; only explicit
|
|
22
|
+
standing-rule framings ("from now on", "as a rule", "you should always",
|
|
23
|
+
"call me …", "remember to …", "don't … again") qualify.
|
|
24
|
+
2. Scans the assistant messages of the turn for an actual
|
|
25
|
+
``create_directive`` tool call.
|
|
26
|
+
3. If the turn stated a durable rule but recorded NO directive, it BLOCKS
|
|
27
|
+
the stop ONCE (Claude Code ``{"decision":"block"}``) with a terse reason
|
|
28
|
+
telling the model to persist the rule now — or, if on reflection it was
|
|
29
|
+
genuinely a one-off, to just finish. ``stop_hook_active`` gates the
|
|
30
|
+
block to fire at most once per turn, so it can never loop and never
|
|
31
|
+
override the model's second, explicit judgment.
|
|
32
|
+
|
|
33
|
+
Why this stays invariant-clean (same reasoning as Stage B):
|
|
34
|
+
* NO model callsite here — detection is pure regex (claude-native).
|
|
35
|
+
* NO silent hook-side write — the hook never calls the Hindsight API; the
|
|
36
|
+
MODEL authors the directive verbatim and the call is visible in chat
|
|
37
|
+
(chat-legibility / no-self-escalation). The block is a re-prompt, not a
|
|
38
|
+
write.
|
|
39
|
+
* Guarded against spam — the durable regex is high-precision, the block
|
|
40
|
+
fires once, and the reason explicitly authorizes "one-off → don't create,
|
|
41
|
+
just finish". A false positive costs one bounded model continuation.
|
|
42
|
+
|
|
43
|
+
Gated by the same knob as Stage B: ``directiveCaptureNudge`` (switchroom
|
|
44
|
+
default on; operators opt out per-agent via
|
|
45
|
+
``memory.directive_capture_nudge=false`` →
|
|
46
|
+
``HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE``). Disabling the nudge disables this
|
|
47
|
+
verification too — they are one deterministic-capture feature.
|
|
48
|
+
|
|
49
|
+
Exit codes:
|
|
50
|
+
0 — always (graceful degradation; a raise here must never wedge a turn).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import re
|
|
56
|
+
import sys
|
|
57
|
+
|
|
58
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
59
|
+
|
|
60
|
+
from lib.config import debug_log, load_config # noqa: E402
|
|
61
|
+
from lib.directives import ( # noqa: E402
|
|
62
|
+
parse_active_directives_block,
|
|
63
|
+
rule_already_captured,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Reuse Stage B's pleasantry scrub so "as always" / "always happy to help"
|
|
67
|
+
# can't trip the high-confidence detector either. Imported lazily-safe: if
|
|
68
|
+
# recall.py fails to import for any reason we fall back to a no-op scrub so
|
|
69
|
+
# this hook still degrades gracefully rather than wedging Stop.
|
|
70
|
+
try:
|
|
71
|
+
from recall import _DIRECTIVE_NUDGE_NEGATIVE_RE as _NEGATIVE_RE
|
|
72
|
+
from recall import looks_like_standing_rule as _looks_like_standing_rule
|
|
73
|
+
except Exception: # pragma: no cover - defensive import guard
|
|
74
|
+
_NEGATIVE_RE = re.compile(r"(?!x)x") # matches nothing → scrub is a no-op
|
|
75
|
+
|
|
76
|
+
def _looks_like_standing_rule(_text): # type: ignore
|
|
77
|
+
# If recall.py can't be imported, fall back to the high-precision
|
|
78
|
+
# detector alone rather than wedging the hook. (Never expected in
|
|
79
|
+
# practice — recall.py ships in the same plugin tree.)
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# High-confidence, high-precision "durable standing rule" detector. Fires only
|
|
84
|
+
# on explicit standing-rule / preference / identity framings that read as
|
|
85
|
+
# durable on their face. Bare "always"/"never"/"stop …"/"don't …" (without a
|
|
86
|
+
# standing frame), and pure world-fact corrections ("we no longer use X" —
|
|
87
|
+
# recall/retain handle those, they aren't behavioural directives), are
|
|
88
|
+
# intentionally omitted: the blocking re-prompt is more intrusive than Stage
|
|
89
|
+
# B's advisory nudge, so its trigger set is narrower and directive-shaped only.
|
|
90
|
+
#
|
|
91
|
+
# looks_like_durable_directive() additionally AND-gates this against
|
|
92
|
+
# recall.py's inclusive looks_like_standing_rule, so the durable trigger set is
|
|
93
|
+
# a GUARANTEED SUBSET of Stage B's nudge trigger set — Stage C can never block
|
|
94
|
+
# on a turn Stage B wouldn't even have nudged on.
|
|
95
|
+
_DURABLE_DIRECTIVE_RE = re.compile(
|
|
96
|
+
r"""(?ix)
|
|
97
|
+
(?:
|
|
98
|
+
# --- explicit temporal / standing-rule framings ---
|
|
99
|
+
\b from \s+ now \s+ on \b
|
|
100
|
+
| \b going \s+ forwards? \b
|
|
101
|
+
| \b in \s+ (?: the \s+ )? future \b
|
|
102
|
+
| \b as \s+ a \s+ (?: general \s+ )?
|
|
103
|
+
(?: rule | policy | principle | convention | standard | default | habit ) \b
|
|
104
|
+
# --- directed standing behaviour ("you should always", "please never") ---
|
|
105
|
+
| \b you \s+ (?: should | must ) \s+ (?: always | never ) \b
|
|
106
|
+
| \b i \s+ want \s+ you \s+ to \s+ (?: always | never ) \b
|
|
107
|
+
| \b please \s+ (?: always | never ) \b
|
|
108
|
+
# --- durable preferences / identity ---
|
|
109
|
+
| \b i \s* ['’]? d \s+ (?: really \s+ )? prefer \b
|
|
110
|
+
| \b (?: i | we ) \s+ prefer \s+ (?: that \s+ )? you \b
|
|
111
|
+
| \b call \s+ me \b
|
|
112
|
+
# --- memory / reinforcement ---
|
|
113
|
+
| \b remember \s+ (?: to | that | always | never ) \b
|
|
114
|
+
# --- prohibitions with a durable frame ---
|
|
115
|
+
| \b (?: do \s* n['’]? t | don['’]? t | dont | do \s+ not )
|
|
116
|
+
\b [^.?!]{0,40} \b again \b
|
|
117
|
+
)
|
|
118
|
+
"""
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Terse, bounded. Fed back to the model on a single blocked Stop. It must
|
|
122
|
+
# offer an explicit escape hatch (one-off → just finish) so a false positive
|
|
123
|
+
# is cheap and never forces a spurious directive.
|
|
124
|
+
_VERIFY_BLOCK_REASON = (
|
|
125
|
+
"<directive_capture_verify>\n"
|
|
126
|
+
"The user's last message stated a DURABLE, standing rule for how you "
|
|
127
|
+
"should behave going forward (e.g. \"from now on …\", \"as a rule …\", "
|
|
128
|
+
"\"you should always …\", \"call me …\", \"remember to …\", \"don't … "
|
|
129
|
+
"again\"), but this turn is ending without recording it — so the "
|
|
130
|
+
"correction will NOT survive the next session.\n"
|
|
131
|
+
"If it is genuinely a durable rule, call "
|
|
132
|
+
"mcp__hindsight__create_directive NOW (verbatim, in the user's own "
|
|
133
|
+
"words), then briefly confirm you have saved it.\n"
|
|
134
|
+
"UNLESS an equivalent active directive already exists (see the "
|
|
135
|
+
"<active_directives> block for this turn) — in that case it is already "
|
|
136
|
+
"saved; do NOT create a duplicate, just finish.\n"
|
|
137
|
+
"If, on reflection, it was only a one-off instruction for this task, do "
|
|
138
|
+
"NOT create a directive — just finish your reply normally.\n"
|
|
139
|
+
"This verification fires once per turn.\n"
|
|
140
|
+
"</directive_capture_verify>"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Guard: skip any "user" content that is actually hook-injected context
|
|
144
|
+
# (recall's memories/nudge blocks), not a human message.
|
|
145
|
+
_INJECTED_MARKERS = (
|
|
146
|
+
"<directive_capture_check>",
|
|
147
|
+
"<directive_capture_verify>",
|
|
148
|
+
"<hindsight_memories>",
|
|
149
|
+
# recall.py injects the bank's active directives as a top-of-prompt block;
|
|
150
|
+
# it is hook-injected context, not a human turn (#2903 Fix 6.2).
|
|
151
|
+
"<active_directives>",
|
|
152
|
+
"Relevant memories from past conversations",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# SWITCHROOM DIVERGENCE (#2903 Fix 6.3): the `<channel source="...">` envelope
|
|
156
|
+
# grammar and the human-source whitelist used to be hard-coded inline here,
|
|
157
|
+
# silently coupling this vendored Python guard to the switchroom gateway's TS
|
|
158
|
+
# wire format. Extracted to lib/switchroom_envelope.py so a TS-side envelope
|
|
159
|
+
# change has ONE obvious Python counterpart to update (and its own test) rather
|
|
160
|
+
# than breaking this guard undetected. `is_synthetic_inbound` is re-exported for
|
|
161
|
+
# backward compatibility with existing tests/callers.
|
|
162
|
+
from lib.switchroom_envelope import is_synthetic_inbound # noqa: E402,F401
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def looks_like_durable_directive(text) -> bool:
|
|
166
|
+
"""High-precision test for an explicit, durable standing rule.
|
|
167
|
+
|
|
168
|
+
Narrower than recall.py's ``looks_like_standing_rule`` — see the module
|
|
169
|
+
docstring. Pleasantries are scrubbed first (shared Stage B negative
|
|
170
|
+
guard). Returns False on empty / non-string input. Pure regex; no model
|
|
171
|
+
call.
|
|
172
|
+
"""
|
|
173
|
+
if not isinstance(text, str) or not text.strip():
|
|
174
|
+
return False
|
|
175
|
+
scrubbed = _NEGATIVE_RE.sub(" ", text)
|
|
176
|
+
if not _DURABLE_DIRECTIVE_RE.search(scrubbed):
|
|
177
|
+
return False
|
|
178
|
+
# Subset gate: only act where Stage B would also have nudged.
|
|
179
|
+
return bool(_looks_like_standing_rule(text))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _message_text(content) -> str:
|
|
183
|
+
"""Extract the human-authored text from a message's content.
|
|
184
|
+
|
|
185
|
+
Handles the plain-string shape and the Claude Code list shape
|
|
186
|
+
``[{type:"text", text:...}, {type:"tool_use"/"tool_result", ...}]``.
|
|
187
|
+
Only ``text`` parts are joined — tool_result / tool_use parts are
|
|
188
|
+
ignored, so a role="user" tool-result message yields "" (correctly not a
|
|
189
|
+
human turn).
|
|
190
|
+
"""
|
|
191
|
+
if isinstance(content, str):
|
|
192
|
+
return content
|
|
193
|
+
if isinstance(content, list):
|
|
194
|
+
parts = []
|
|
195
|
+
for p in content:
|
|
196
|
+
if isinstance(p, dict) and p.get("type") == "text":
|
|
197
|
+
t = p.get("text")
|
|
198
|
+
if isinstance(t, str):
|
|
199
|
+
parts.append(t)
|
|
200
|
+
return "\n".join(parts)
|
|
201
|
+
return ""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _is_injected(text: str) -> bool:
|
|
205
|
+
return any(marker in text for marker in _INJECTED_MARKERS)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def find_last_human_turn(messages: list) -> tuple:
|
|
209
|
+
"""Return ``(index, text)`` of the most recent genuine human turn.
|
|
210
|
+
|
|
211
|
+
Skips role="user" entries that are tool_result-only (no text) or
|
|
212
|
+
hook-injected context blocks. Returns ``(None, "")`` when there is no
|
|
213
|
+
human message.
|
|
214
|
+
"""
|
|
215
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
216
|
+
msg = messages[i]
|
|
217
|
+
if not isinstance(msg, dict) or msg.get("role") != "user":
|
|
218
|
+
continue
|
|
219
|
+
text = _message_text(msg.get("content"))
|
|
220
|
+
if not text.strip():
|
|
221
|
+
continue # tool_result-only user message
|
|
222
|
+
if _is_injected(text):
|
|
223
|
+
continue # recall/nudge injection, not the human
|
|
224
|
+
return i, text
|
|
225
|
+
return None, ""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _directive_call_ids(content) -> list:
|
|
229
|
+
"""Return the tool_use ids of any create_directive calls in a message's
|
|
230
|
+
content (empty list if none)."""
|
|
231
|
+
ids = []
|
|
232
|
+
if not isinstance(content, list):
|
|
233
|
+
return ids
|
|
234
|
+
for p in content:
|
|
235
|
+
if not isinstance(p, dict) or p.get("type") != "tool_use":
|
|
236
|
+
continue
|
|
237
|
+
name = p.get("name", "")
|
|
238
|
+
if isinstance(name, str) and "create_directive" in name:
|
|
239
|
+
# Track the id so we can pair it with its tool_result and reject a
|
|
240
|
+
# call whose write ERRORED. A call with no id still counts as a
|
|
241
|
+
# (best-effort) attempt — see _directive_call_present.
|
|
242
|
+
ids.append(p.get("id"))
|
|
243
|
+
return ids
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _directive_call_present(content) -> bool:
|
|
247
|
+
"""True if a message's content contains a create_directive tool_use."""
|
|
248
|
+
return len(_directive_call_ids(content)) > 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# SWITCHROOM DIVERGENCE (#2903, Fix 1.3): a create_directive tool_use whose
|
|
252
|
+
# tool_result came back with an error must NOT count as "recorded". A hindsight
|
|
253
|
+
# tools/call returns HTTP 200 + is_error:true on failure (engine down / renamed
|
|
254
|
+
# arg / isError envelope); without this the verifier would see the call, treat
|
|
255
|
+
# the correction as captured, and never fire its one bounded re-prompt — the
|
|
256
|
+
# same false-success class that made chat show "📌 remembered" for a failed
|
|
257
|
+
# write. We scan subsequent user messages for the matching tool_result id and
|
|
258
|
+
# treat is_error:true (or an error-shaped text result) as NOT-recorded.
|
|
259
|
+
def _errored_tool_use_ids(messages: list) -> set:
|
|
260
|
+
"""Collect tool_use ids whose tool_result reported an error."""
|
|
261
|
+
errored = set()
|
|
262
|
+
for msg in messages:
|
|
263
|
+
if not isinstance(msg, dict):
|
|
264
|
+
continue
|
|
265
|
+
content = msg.get("content")
|
|
266
|
+
if not isinstance(content, list):
|
|
267
|
+
continue
|
|
268
|
+
for p in content:
|
|
269
|
+
if not isinstance(p, dict) or p.get("type") != "tool_result":
|
|
270
|
+
continue
|
|
271
|
+
if p.get("is_error") is True:
|
|
272
|
+
tid = p.get("tool_use_id")
|
|
273
|
+
if tid is not None:
|
|
274
|
+
errored.add(tid)
|
|
275
|
+
return errored
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def collect_active_directive_contents(messages: list) -> list:
|
|
279
|
+
"""Gather the CONTENT strings of every active directive injected into this
|
|
280
|
+
turn's context.
|
|
281
|
+
|
|
282
|
+
recall.py injects an ``<active_directives>`` block (the bank's currently
|
|
283
|
+
active directives) into the UserPromptSubmit context; it shows up in the
|
|
284
|
+
transcript as an injected user message. We parse those back out so the
|
|
285
|
+
verifier can tell whether a restated rule is ALREADY stored — in which case
|
|
286
|
+
the model correctly declines to re-create it and we must NOT block (#2903
|
|
287
|
+
Fix 6.2). Pure string parsing; no API call.
|
|
288
|
+
"""
|
|
289
|
+
contents: list = []
|
|
290
|
+
for msg in messages:
|
|
291
|
+
if not isinstance(msg, dict):
|
|
292
|
+
continue
|
|
293
|
+
text = _message_text(msg.get("content"))
|
|
294
|
+
if "<active_directives>" in text:
|
|
295
|
+
contents.extend(parse_active_directives_block(text))
|
|
296
|
+
return contents
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def directive_recorded_after(messages: list, start_index: int) -> bool:
|
|
300
|
+
"""True if any assistant turn after ``start_index`` called create_directive
|
|
301
|
+
with a SUCCESSFUL result. A call whose tool_result errored does not count
|
|
302
|
+
(SWITCHROOM DIVERGENCE #2903, Fix 1.3) — so the verifier still re-prompts
|
|
303
|
+
once for a durable rule whose write failed."""
|
|
304
|
+
errored = _errored_tool_use_ids(messages)
|
|
305
|
+
for msg in messages[start_index + 1:]:
|
|
306
|
+
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
|
307
|
+
continue
|
|
308
|
+
ids = _directive_call_ids(msg.get("content"))
|
|
309
|
+
if not ids:
|
|
310
|
+
continue
|
|
311
|
+
# Recorded only if at least one create_directive call did NOT error.
|
|
312
|
+
# A call with a None id (older/testing shape carrying no id) has no
|
|
313
|
+
# pairable result, so treat it as a successful attempt (prior behaviour).
|
|
314
|
+
for tid in ids:
|
|
315
|
+
if tid is None or tid not in errored:
|
|
316
|
+
return True
|
|
317
|
+
return False
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def read_transcript(transcript_path: str) -> list:
|
|
321
|
+
"""Read a JSONL transcript into a list of message dicts (role/content).
|
|
322
|
+
|
|
323
|
+
Mirrors retain.py.read_transcript: supports the nested Claude Code shape
|
|
324
|
+
``{type, message:{role, content}}`` and the flat testing shape
|
|
325
|
+
``{role, content}``.
|
|
326
|
+
"""
|
|
327
|
+
if not transcript_path or not os.path.isfile(transcript_path):
|
|
328
|
+
return []
|
|
329
|
+
messages = []
|
|
330
|
+
try:
|
|
331
|
+
with open(transcript_path, encoding="utf-8") as f:
|
|
332
|
+
for line in f:
|
|
333
|
+
line = line.strip()
|
|
334
|
+
if not line:
|
|
335
|
+
continue
|
|
336
|
+
try:
|
|
337
|
+
entry = json.loads(line)
|
|
338
|
+
except json.JSONDecodeError:
|
|
339
|
+
continue
|
|
340
|
+
if entry.get("type") in ("user", "assistant"):
|
|
341
|
+
msg = entry.get("message", {})
|
|
342
|
+
if isinstance(msg, dict) and msg.get("role"):
|
|
343
|
+
messages.append(msg)
|
|
344
|
+
elif "role" in entry and "content" in entry:
|
|
345
|
+
messages.append(entry)
|
|
346
|
+
except OSError:
|
|
347
|
+
pass
|
|
348
|
+
return messages
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def evaluate(hook_input: dict, config: dict) -> str | None:
|
|
352
|
+
"""Core decision. Returns a block reason string, or None to allow stop.
|
|
353
|
+
|
|
354
|
+
None → the turn is allowed to end (no-op). A non-empty string → block the
|
|
355
|
+
stop once and feed the string back to the model.
|
|
356
|
+
"""
|
|
357
|
+
# Same knob as Stage B — disabling the nudge disables this verification.
|
|
358
|
+
if not config.get("directiveCaptureNudge", True):
|
|
359
|
+
debug_log(config, "Directive-capture verify: feature disabled, allowing stop")
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
# #2873/#2903 Fix 6.2 — the BLOCK is separately gated: an operator can keep
|
|
363
|
+
# the advisory Stage B nudge while dropping the more intrusive Stop block.
|
|
364
|
+
if not config.get("directiveCaptureVerify", True):
|
|
365
|
+
debug_log(config, "Directive-capture verify: block disabled (nudge-only), allowing stop")
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
# Loop / one-off guard: if we already blocked once this turn, respect the
|
|
369
|
+
# model's second judgment and never re-block.
|
|
370
|
+
if hook_input.get("stop_hook_active"):
|
|
371
|
+
debug_log(config, "Directive-capture verify: stop_hook_active, not re-blocking")
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
messages = read_transcript(hook_input.get("transcript_path", ""))
|
|
375
|
+
if not messages:
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
idx, text = find_last_human_turn(messages)
|
|
379
|
+
if idx is None:
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
# Non-interactive turn guard: cron / synthesized-inbound turns (resume,
|
|
383
|
+
# reaction, vault-grant, subagent-handback, obligation-represent, …) are
|
|
384
|
+
# machine turns, not human corrections. Never block Stop to nag capture on
|
|
385
|
+
# them — that's spurious. (See is_synthetic_inbound.)
|
|
386
|
+
if is_synthetic_inbound(text):
|
|
387
|
+
debug_log(config, "Directive-capture verify: synthetic/cron inbound, allowing stop")
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
if not looks_like_durable_directive(text):
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
if directive_recorded_after(messages, idx):
|
|
394
|
+
debug_log(config, "Directive-capture verify: create_directive already called, allowing stop")
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
# Dedup (#2903 Fix 6.2): if the restated rule is already covered by an
|
|
398
|
+
# active directive injected into this turn's <active_directives> block, the
|
|
399
|
+
# model CORRECTLY declined to re-create a duplicate — blocking here would
|
|
400
|
+
# nag it to double-store. Allow stop.
|
|
401
|
+
existing = collect_active_directive_contents(messages)
|
|
402
|
+
if existing and rule_already_captured(text, existing):
|
|
403
|
+
debug_log(
|
|
404
|
+
config,
|
|
405
|
+
"Directive-capture verify: rule already covered by an active directive, allowing stop",
|
|
406
|
+
)
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
debug_log(
|
|
410
|
+
config,
|
|
411
|
+
"Directive-capture verify: durable rule stated, no create_directive call — blocking once",
|
|
412
|
+
)
|
|
413
|
+
return _VERIFY_BLOCK_REASON
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def main():
|
|
417
|
+
try:
|
|
418
|
+
hook_input = json.load(sys.stdin)
|
|
419
|
+
except (json.JSONDecodeError, EOFError):
|
|
420
|
+
# No input → nothing to verify. Allow stop.
|
|
421
|
+
return
|
|
422
|
+
try:
|
|
423
|
+
config = load_config()
|
|
424
|
+
except Exception:
|
|
425
|
+
return
|
|
426
|
+
try:
|
|
427
|
+
reason = evaluate(hook_input, config)
|
|
428
|
+
except Exception as e: # never wedge a turn on a verify bug
|
|
429
|
+
debug_log(config, f"Directive-capture verify error (allowing stop): {e}")
|
|
430
|
+
return
|
|
431
|
+
if reason:
|
|
432
|
+
# Claude Code Stop-hook block contract: emit decision=block + reason;
|
|
433
|
+
# the model continues the turn with `reason` as feedback.
|
|
434
|
+
print(json.dumps({"decision": "block", "reason": reason}))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
if __name__ == "__main__":
|
|
438
|
+
try:
|
|
439
|
+
main()
|
|
440
|
+
except Exception as e: # absolute backstop — Stop must never hard-fail
|
|
441
|
+
print(f"[Hindsight] Unexpected error in directive_verify: {e}", file=sys.stderr)
|
|
442
|
+
try:
|
|
443
|
+
sys.exit(2 if load_config().get("debug") else 0)
|
|
444
|
+
except Exception:
|
|
445
|
+
sys.exit(0)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -37,6 +37,33 @@ DEFAULTS = {
|
|
|
37
37
|
# scores, so this is the switchroom-side quality filter — see #475.
|
|
38
38
|
"recallMinOverlap": 0.0,
|
|
39
39
|
"recallTypes": ["world", "experience"],
|
|
40
|
+
# Switchroom #2848 Stage B/C — deterministic directive capture.
|
|
41
|
+
# When on (switchroom default; pinned true in the copied plugin
|
|
42
|
+
# settings.json by applyHindsightSettingsOverrides), TWO deterministic
|
|
43
|
+
# hooks share this knob:
|
|
44
|
+
# * Stage B (recall.py, UserPromptSubmit): regex-detects correction /
|
|
45
|
+
# standing-rule-shaped inbound and appends a terse advisory to the
|
|
46
|
+
# turn's additionalContext telling the model to persist the rule with
|
|
47
|
+
# create_directive if it IS durable.
|
|
48
|
+
# * Stage C (directive_verify.py, Stop): after the turn, re-checks the
|
|
49
|
+
# human turn against a HIGH-PRECISION durable-rule regex and, if the
|
|
50
|
+
# model recorded no create_directive call, blocks the stop ONCE to
|
|
51
|
+
# re-prompt capture (closes the "model ignored the nudge" gap).
|
|
52
|
+
# Both are pure detection — no model callsite, no silent hook-side write;
|
|
53
|
+
# the model authors the directive in-session (chat-legible). Operators opt
|
|
54
|
+
# out per-agent via memory.directive_capture_nudge=false →
|
|
55
|
+
# HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE (disables BOTH hooks).
|
|
56
|
+
"directiveCaptureNudge": True,
|
|
57
|
+
# Switchroom #2873/#2903 Fix 6.2 — the BLOCKING half (Stage C
|
|
58
|
+
# directive_verify.py Stop hook) split out from the advisory nudge. When
|
|
59
|
+
# True (default) the verifier may block the stop once to re-prompt capture;
|
|
60
|
+
# when False the Stage B nudge still fires but the Stop hook NEVER blocks
|
|
61
|
+
# (advisory-only mode). Lets an operator keep the gentle nudge while dropping
|
|
62
|
+
# the more intrusive block. Gated UNDER directiveCaptureNudge: turning the
|
|
63
|
+
# nudge off disables both regardless of this knob. Operators opt out
|
|
64
|
+
# per-agent via memory.directive_capture_verify=false →
|
|
65
|
+
# HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY.
|
|
66
|
+
"directiveCaptureVerify": True,
|
|
40
67
|
"recallContextTurns": 1,
|
|
41
68
|
"recallMaxQueryChars": 800,
|
|
42
69
|
"recallRoles": ["user", "assistant"],
|
|
@@ -119,6 +146,16 @@ ENV_OVERRIDES = {
|
|
|
119
146
|
# from agents.<name>.memory.recall.skip_trivial only on override; the
|
|
120
147
|
# switchroom default is on (recall.py falls back to True).
|
|
121
148
|
"HINDSIGHT_RECALL_SKIP_TRIVIAL": ("recallSkipTrivial", bool),
|
|
149
|
+
# Switchroom #2848 Stage B: directive-capture nudge on/off. Set by
|
|
150
|
+
# start.sh from agents.<name>.memory.directive_capture_nudge only when
|
|
151
|
+
# the operator overrode it; the switchroom default is on (settings.json
|
|
152
|
+
# pins true; recall.py falls back to True).
|
|
153
|
+
"HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE": ("directiveCaptureNudge", bool),
|
|
154
|
+
# Switchroom #2873/#2903 Fix 6.2: the Stage C block on/off, independent of
|
|
155
|
+
# the Stage B nudge. Set by start.sh from
|
|
156
|
+
# agents.<name>.memory.directive_capture_verify only when the operator
|
|
157
|
+
# overrode it; the switchroom default is on.
|
|
158
|
+
"HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY": ("directiveCaptureVerify", bool),
|
|
122
159
|
"HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
|
|
123
160
|
"HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
|
|
124
161
|
# 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
|