switchroom 0.19.2 → 0.19.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/agent-scheduler/index.js +2 -0
  2. package/dist/auth-broker/index.js +109 -7
  3. package/dist/cli/autoaccept-poll.js +2 -0
  4. package/dist/cli/drive-write-pretool.mjs +2 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +2 -0
  6. package/dist/cli/switchroom.js +404 -245
  7. package/dist/host-control/main.js +1 -1
  8. package/package.json +1 -1
  9. package/profiles/default/CLAUDE.md.hbs +8 -0
  10. package/skills/mental-model-curator/SKILL.md +68 -2
  11. package/telegram-plugin/auth-snapshot-format.ts +104 -12
  12. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  13. package/telegram-plugin/dist/gateway/gateway.js +1194 -794
  14. package/telegram-plugin/dist/server.js +8 -2
  15. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  16. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  17. package/telegram-plugin/gateway/auth-command.ts +138 -5
  18. package/telegram-plugin/gateway/gateway.ts +68 -101
  19. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  20. package/telegram-plugin/gateway/model-command.ts +203 -1
  21. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  22. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  23. package/telegram-plugin/gateway/stream-render.ts +22 -5
  24. package/telegram-plugin/quota-bar-format.ts +60 -12
  25. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  26. package/telegram-plugin/session-tail.ts +27 -3
  27. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  28. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  29. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  30. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
  31. package/telegram-plugin/tests/model-command.test.ts +220 -0
  32. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  35. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  36. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  37. package/vendor/hindsight-memory/README.md +2 -1
  38. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  39. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  40. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  41. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  42. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  43. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  44. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  45. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  46. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  47. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  48. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  54. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  55. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  56. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  57. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  59. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  60. package/vendor/hindsight-memory/settings.json +3 -1
@@ -1,8 +1,13 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
- import { mkdtempSync, rmSync, writeFileSync, statSync } from 'node:fs'
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, appendFileSync, statSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
- import { computeFirstAttachCursor } from '../session-tail.js'
5
+ import {
6
+ computeFirstAttachCursor,
7
+ getProjectsDirForCwd,
8
+ startSessionTail,
9
+ type SessionEvent,
10
+ } from '../session-tail.js'
6
11
 
7
12
  /**
8
13
  * computeFirstAttachCursor: on first attach to a transcript, seek to EOF
@@ -63,3 +68,111 @@ describe('computeFirstAttachCursor', () => {
63
68
  expect(computeFirstAttachCursor(missing, 0)).toBe(0)
64
69
  })
65
70
  })
71
+
72
+ // ── #3427 H2: first-attach replay marks `model` events as replayed ───────────
73
+ //
74
+ // A /model relaunch during an active turn is EXACTLY the shape that triggers
75
+ // the in-flight-turn replay above: the new gateway attaches to the OLD
76
+ // session's JSONL and replays its enqueue + assistant lines — which carry the
77
+ // PRE-relaunch model. Those replayed `model` observations must be flagged so
78
+ // the divergence tripwire (session-model-source) ignores them; live lines
79
+ // appended after attach must NOT be flagged. Outcome-asserted against the
80
+ // real tailer, not the source.
81
+
82
+ describe('startSessionTail — replayed model events are flagged (#3427 H2)', () => {
83
+ const tempDirs: string[] = []
84
+ afterEach(() => {
85
+ for (const d of tempDirs) {
86
+ try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ }
87
+ }
88
+ tempDirs.length = 0
89
+ })
90
+
91
+ function mkProjectsDir(): { claudeHome: string; cwd: string; projectsDir: string } {
92
+ const base = mkdtempSync(join(tmpdir(), 'first-attach-replayed-'))
93
+ tempDirs.push(base)
94
+ const cwd = join(base, 'agent')
95
+ const claudeHome = join(base, 'claude-home')
96
+ const projectsDir = getProjectsDirForCwd(cwd, claudeHome)
97
+ mkdirSync(projectsDir, { recursive: true })
98
+ return { claudeHome, cwd, projectsDir }
99
+ }
100
+
101
+ const wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
102
+
103
+ const assistantModelLine = (model: string): string =>
104
+ JSON.stringify({
105
+ type: 'assistant',
106
+ message: { model, content: [{ type: 'text', text: 'hi' }] },
107
+ }) + '\n'
108
+
109
+ function modelEvents(events: SessionEvent[]): Array<{ model: string; replayed?: boolean }> {
110
+ return events.filter((e): e is Extract<SessionEvent, { kind: 'model' }> => e.kind === 'model')
111
+ }
112
+
113
+ it('mid-turn restart: replayed OLD-model lines carry replayed:true; a live line appended after attach does not', async () => {
114
+ const { claudeHome, cwd, projectsDir } = mkProjectsDir()
115
+ const file = join(projectsDir, 'sess.jsonl')
116
+ // The pre-relaunch session ended mid-turn: enqueue with no turn_duration,
117
+ // followed by an assistant line served by the OLD model.
118
+ writeFileSync(
119
+ file,
120
+ ENQUEUE + '\n' + DEQUEUE + '\n' + assistantModelLine('claude-opus-4-8'),
121
+ )
122
+
123
+ const events: SessionEvent[] = []
124
+ const handle = startSessionTail({
125
+ cwd,
126
+ claudeHome,
127
+ rescanIntervalMs: 50,
128
+ onEvent: (ev) => { events.push(ev) },
129
+ })
130
+ try {
131
+ await wait(200) // attach + replay the in-flight turn
132
+ const replayedBatch = modelEvents(events)
133
+ expect(replayedBatch.length).toBeGreaterThan(0)
134
+ // THE H2 false-positive shape: the old model arrives AFTER boot — but
135
+ // flagged, so the divergence tripwire skips it.
136
+ expect(replayedBatch[0]).toMatchObject({ model: 'claude-opus-4-8', replayed: true })
137
+
138
+ // The live session now writes its first assistant line (new model).
139
+ appendFileSync(file, assistantModelLine('claude-sonnet-5'))
140
+ await wait(200)
141
+ const all = modelEvents(events)
142
+ const live = all[all.length - 1]
143
+ expect(live.model).toBe('claude-sonnet-5')
144
+ expect(live.replayed).not.toBe(true)
145
+ } finally {
146
+ handle.stop()
147
+ }
148
+ })
149
+
150
+ it('completed-turn attach (no replay): appended model lines are never flagged', async () => {
151
+ const { claudeHome, cwd, projectsDir } = mkProjectsDir()
152
+ const file = join(projectsDir, 'sess.jsonl')
153
+ writeFileSync(
154
+ file,
155
+ ENQUEUE + '\n' + assistantModelLine('claude-opus-4-8') + TURN_DURATION + '\n',
156
+ )
157
+
158
+ const events: SessionEvent[] = []
159
+ const handle = startSessionTail({
160
+ cwd,
161
+ claudeHome,
162
+ rescanIntervalMs: 50,
163
+ onEvent: (ev) => { events.push(ev) },
164
+ })
165
+ try {
166
+ await wait(200) // attach seeks to EOF — no replay
167
+ expect(modelEvents(events)).toHaveLength(0)
168
+ appendFileSync(file, assistantModelLine('claude-sonnet-5'))
169
+ await wait(200)
170
+ const all = modelEvents(events)
171
+ expect(all.length).toBeGreaterThan(0)
172
+ expect(all[all.length - 1].model).toBe('claude-sonnet-5')
173
+ expect(all[all.length - 1].replayed).not.toBe(true)
174
+ } finally {
175
+ handle.stop()
176
+ }
177
+ })
178
+ })
@@ -4,6 +4,108 @@
4
4
 
5
5
  ### Changed (switchroom divergence)
6
6
 
7
+ - **`recallContextTurns` default `1` → `2`** (switchroom hindsight-leverage
8
+ PR2, workstream A2). A bare follow-up user message ("and the port?", "what
9
+ about staging?") now embeds together with its antecedent human turn in the
10
+ recall query, instead of recalling on the pronoun alone. Depends on PR1's
11
+ (#3435) `<channel>` envelope strip so the composed 2-turn query stays
12
+ envelope-free (both the trailing latest segment and the `Prior context:`
13
+ lines). The composition is bounded by `recallMaxQueryChars` (800) —
14
+ `truncate_recall_query` preserves the latest turn and drops oldest context
15
+ first, so a large antecedent can never blow the recall query budget.
16
+ - **New `recallTranscriptTailBytes` (default `262144`)** — latency bound for
17
+ multi-turn recall. With `recallContextTurns > 1` now the default, every
18
+ recall reads the transcript to slice prior turns; `read_transcript_messages`
19
+ now byte-tail-bounds that read (seek to `EOF - tail_bytes`, discard the
20
+ partial first line, parse only complete trailing lines) so the added
21
+ per-recall read stays O(1) regardless of session `.jsonl` size. `0` reads the
22
+ whole file (rollback lever). Env: `HINDSIGHT_RECALL_TRANSCRIPT_TAIL_BYTES`.
23
+
24
+ ### Added (switchroom divergence)
25
+
26
+ - **retain.py + recall.py: lesson/anti-pattern tagging → recall demotion**
27
+ (switchroom hindsight-leverage PR9, workstream E2, #398). Closes the corpus-
28
+ hygiene half of #398: a retained transcript that captures a self-recognised
29
+ lesson ("lesson learned", "note to self:") or a failure mode ("anti-pattern:",
30
+ "what not to do") is now deterministically tagged (`lesson` / `anti-pattern`)
31
+ at retain time by `retain.detect_lesson_tags` — a case-insensitive substring
32
+ match against the configurable `lessonTagMarkers` map (NOT model-dependent),
33
+ wired into `build_retain_payload` so it applies to both Stop-hook and sidechain
34
+ retains without clobbering configured `retainTags`. Recall then DEMOTES those
35
+ tags via the PR5 score-penalty weight map: `recall._effective_tag_weights`
36
+ merges built-in `lessonDemotionWeights` (`{lesson: 0.85, anti-pattern: 0.5}`)
37
+ UNDER `recallTagWeights`, so a failure-mode-adjacent transcript ranks below a
38
+ clean equal-score session memory yet is NEVER hard-dropped (re-rank, not the
39
+ demote-tag DROP filter) and still surfaces when it is the only relevant hit.
40
+ Precedence: an explicit `recallTagWeights` entry wins over the built-in for the
41
+ same tag; the PR5 `sidechain: 0.8` seed composes cleanly. Toggles:
42
+ `HINDSIGHT_LESSON_TAGGING=false` (retain side), `HINDSIGHT_LESSON_DEMOTION=false`
43
+ (recall side) as rollback levers; `HINDSIGHT_LESSON_TAG_MARKERS` /
44
+ `HINDSIGHT_LESSON_DEMOTION_WEIGHTS` (JSON) for overrides. NON-GOAL
45
+ (epic-recorded): the historical corpus is NOT re-tagged — this fires on NEW
46
+ retains only. Acceptance: `scripts/tests/test_lesson_tagging.py`.
47
+
48
+ - **recall.py: parallel multi-bank recall under one shared deadline**
49
+ (switchroom hindsight-leverage PR3, workstream A3 stage 2). The directives
50
+ fetch and every bank recall (own + additional/profile/shared/sender banks)
51
+ now run CONCURRENTLY in daemon threads via `lib/parallel_recall.py`
52
+ (`run_parallel`), bounded by ONE shared deadline
53
+ (`recallParallelDeadlineSeconds`, default 10 = the 12s UserPromptSubmit hook
54
+ ceiling minus 2s headroom). Serially the round-trips SUM, so a heavy
55
+ multi-bank agent could breach the ceiling and drop recall entirely; parallel
56
+ makes the critical path the SLOWEST slot. A slot still running at the deadline
57
+ is abandoned (daemon thread, reaped on process exit) and marked `timed_out` —
58
+ a straggler bank can never hold the hook open past its ceiling; `recall.py`'s
59
+ `__main__` additionally `os._exit(0)`s (after a stdout flush) as a
60
+ belt-and-suspenders. The directives slot is dedicated and composes with the A4
61
+ directives cache (a cache HIT returns near-instantly with no HTTP). Env-gated
62
+ rollback: `HINDSIGHT_RECALL_PARALLEL=false` restores the pre-A3 serial path.
63
+ The `deadline_hit` telemetry field (shipped interim in PR1) is FINALIZED:
64
+ True when any bank raised a hard per-request timeout OR any bank/directives
65
+ slot was abandoned at the shared deadline; serial mode reduces to the pre-A3
66
+ per-bank-only form so both modes' `recall_log.jsonl` rows stay comparable in
67
+ the breach baseline. New log fields: `recall_mode`, `deadline_budget_ms`
68
+ (the CONFIGURED budget), `deadline_effective_ms` (the smaller wait the slots
69
+ actually got after pre-fan-out spend; null in serial mode / on cache hits),
70
+ `directives_timed_out`. Acceptance: `scripts/tests/test_recall_parallel_deadline.py`
71
+ (stub-timing tests proving a 3s bank cannot breach a 0.6s deadline).
72
+
73
+ - **SubagentStop sidechain retain** (switchroom hindsight-leverage PR5). New
74
+ `scripts/subagent_retain.py`, registered on the `SubagentStop` event in
75
+ `hooks/hooks.json` (async, 15s). Delegated (Task-tool / sub-agent) work was
76
+ the biggest systematic memory hole — the main-session Stop retain only reads
77
+ the parent `transcript_path`, so a worker's process facts reached memory only
78
+ as its terse final report. This hook retains a bounded window (last 40 human
79
+ turns) of the *sidechain* transcript, tagged `sidechain` +
80
+ `parent_session:<id>`, with a deterministic content-derived `document_id` in a
81
+ distinct namespace (`{session}-sub-{agent}-r{start}-{end}`) so re-fires upsert.
82
+ Failures enqueue to the same `pending-retains` durability queue the Stop retain
83
+ uses. A **volume gate** (< 6 human turns OR < 2,000 chars of non-tool-result
84
+ text) skips trivial forks (every Task fires SubagentStop, including
85
+ 10-second ones). Empirically probed on Claude Code 2.1.215: the hook input
86
+ carries a first-class `agent_transcript_path` pointing at
87
+ `<project>/<session>/subagents/agent-<agent_id>.jsonl` (used as the primary
88
+ path), with a directory-scan of the newest `isSidechain:true` jsonl as the
89
+ fallback for CLIs that omit the field. `reconcile_tail.py` and any transcript
90
+ sweeper now **skip** sidechain transcripts (shared
91
+ `content.transcript_first_line_is_sidechain` predicate) so the boot reconciler
92
+ cannot re-retain a sub-agent fork as a pseudo-session — untagged, at full
93
+ recall weight, bypassing the volume gate — which its recursive `**/*.jsonl`
94
+ glob would otherwise do one restart after any worker.
95
+
96
+ - **recall.py: `recallTagWeights` per-tag score penalty** (switchroom
97
+ hindsight-leverage PR5). A `{tag: multiplier}` config map (default `{}`,
98
+ env `HINDSIGHT_RECALL_TAG_WEIGHTS`) applied to each result's `scores.final`
99
+ immediately before the relevance sort. Unlike the demote-tag DROP filter
100
+ (`_is_demoted_memory`), which removes a tagged memory from recall entirely,
101
+ this DEMOTES (down-ranks) a memory while keeping it recallable when it is the
102
+ only relevant hit — the "reduced weight" the drop filter cannot express.
103
+ Switchroom's scaffold seeds `{"sidechain": 0.8}` so delegated-worker
104
+ process-memories rank just below first-party session memory. **Candidate to
105
+ upstream** — a general recall-shaping primitive, not switchroom-specific.
106
+
107
+ ### Changed (switchroom divergence)
108
+
7
109
  - **retain.py: decouple chunked window-slicing from the `retainEveryNTurns > 1`
8
110
  throttle** (switchroom Phase 6b). Previously the chunked sliding-window only
9
111
  applied when `retainEveryNTurns > 1`; with `retainEveryNTurns=1` (switchroom
@@ -183,8 +183,9 @@ Auto-recall runs on every user prompt. It queries Hindsight for relevant memorie
183
183
  | `recallBudget` | `HINDSIGHT_RECALL_BUDGET` | `"mid"` | Controls how hard Hindsight searches for memories. `"low"` = fast, fewer strategies; `"mid"` = balanced; `"high"` = thorough, slower. Affects latency directly. |
184
184
  | `recallMaxTokens` | `HINDSIGHT_RECALL_MAX_TOKENS` | `1024` | Maximum number of tokens in the recalled memory block. Lower values reduce context usage but may truncate relevant memories. |
185
185
  | `recallTypes` | — | `["world", "experience"]` | Which memory types to retrieve. `"world"` = general facts; `"experience"` = personal experiences; `"observation"` = raw observations. |
186
- | `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `1` | How many prior conversation turns to include when composing the recall query. `1` = only the latest user message; higher values give more context but may dilute the query. |
186
+ | `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `2` | How many prior conversation turns to include when composing the recall query. `1` = only the latest user message; `2` (default) also embeds the antecedent human turn so a bare follow-up ("and the port?") recalls with context instead of on the pronoun alone. Higher values give more context but may dilute the query. The composed query is always bounded by `recallMaxQueryChars` (latest turn preserved, oldest context dropped first). |
187
187
  | `recallMaxQueryChars` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `800` | Maximum character length of the query sent to Hindsight. Longer queries are truncated. |
188
+ | `recallTranscriptTailBytes` | `HINDSIGHT_RECALL_TRANSCRIPT_TAIL_BYTES` | `262144` | Latency bound for multi-turn recall (`recallContextTurns > 1`): read only the last N bytes of the session transcript when slicing prior turns, so the per-recall read stays cheap on long sessions. `0` reads the whole file. |
188
189
  | `recallRoles` | — | `["user", "assistant"]` | Which message roles to include when building the recall query from prior turns. |
189
190
  | `recallTags` | `HINDSIGHT_RECALL_TAGS` | `[]` | Optional tags to pass to the recall API, such as `["memory_type:rule"]`. The env var accepts JSON or a comma-separated list. |
190
191
  | `recallTagsMatch` | `HINDSIGHT_RECALL_TAGS_MATCH` | `"any"` | Tag matching mode used with `recallTags` or `recallTagGroups`: `"any"`, `"all"`, `"any_strict"`, or `"all_strict"`. |
@@ -43,6 +43,18 @@
43
43
  ]
44
44
  }
45
45
  ],
46
+ "SubagentStop": [
47
+ {
48
+ "hooks": [
49
+ {
50
+ "type": "command",
51
+ "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent_retain.py\"",
52
+ "timeout": 15,
53
+ "async": true
54
+ }
55
+ ]
56
+ }
57
+ ],
46
58
  "SessionEnd": [
47
59
  {
48
60
  "hooks": [
@@ -59,6 +59,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
59
59
 
60
60
  from lib.config import debug_log, load_config # noqa: E402
61
61
  from lib.directives import ( # noqa: E402
62
+ DIRECTIVES_CACHE_TTL_SECONDS,
63
+ invalidate_directives_cache,
62
64
  parse_active_directives_block,
63
65
  rule_already_captured,
64
66
  )
@@ -317,6 +319,70 @@ def directive_recorded_after(messages: list, start_index: int) -> bool:
317
319
  return False
318
320
 
319
321
 
322
+ # --- Directives-cache invalidation (switchroom hindsight-leverage A4) ---------
323
+ #
324
+ # recall.py caches the bank's active-directives list with a short TTL to skip an
325
+ # HTTP round-trip on the critical path. That cache would otherwise stay stale
326
+ # until the TTL elapsed — so this Stop hook, which already re-reads the turn's
327
+ # transcript, deletes the cache whenever the just-ended turn performed a
328
+ # directive WRITE (create/update/delete). The next recall then re-fetches, so an
329
+ # in-session directive change is visible in the very next turn's
330
+ # <active_directives> block (cross-process writes still rely on TTL alone).
331
+
332
+ # Matches the hindsight directive-write MCP tools by their bare or namespaced
333
+ # name, e.g. "create_directive", "mcp__hindsight__update_directive",
334
+ # "delete_directive". Read-only "list_directives" is deliberately excluded.
335
+ _DIRECTIVE_WRITE_TOOL_RE = re.compile(r"(?:create|update|delete)_directive")
336
+
337
+
338
+ def _is_directive_write_tool(name) -> bool:
339
+ return isinstance(name, str) and bool(_DIRECTIVE_WRITE_TOOL_RE.search(name))
340
+
341
+
342
+ def turn_contains_directive_write(messages: list, start_index: int) -> bool:
343
+ """True if any assistant turn after ``start_index`` issued a directive-write
344
+ tool_use (create/update/delete). ``start_index = -1`` scans all messages
345
+ (used when there is no human turn, e.g. a synthetic/cron inbound that still
346
+ wrote a directive). Pure inspection; no API call."""
347
+ for msg in messages[start_index + 1:]:
348
+ if not isinstance(msg, dict) or msg.get("role") != "assistant":
349
+ continue
350
+ content = msg.get("content")
351
+ if not isinstance(content, list):
352
+ continue
353
+ for p in content:
354
+ if (
355
+ isinstance(p, dict)
356
+ and p.get("type") == "tool_use"
357
+ and _is_directive_write_tool(p.get("name", ""))
358
+ ):
359
+ return True
360
+ return False
361
+
362
+
363
+ def invalidate_cache_on_directive_write(messages: list, config: dict) -> None:
364
+ """Delete the directives cache if this turn wrote a directive.
365
+
366
+ Takes the ALREADY-READ transcript ``messages`` (main() reads the transcript
367
+ exactly once and shares it with the capture verifier — a Stop hook must not
368
+ parse a multi-MB session twice). Only the tail after the last human turn is
369
+ scanned, since only writes issued THIS turn matter. Runs independently of
370
+ the capture-verify decision (and of the directiveCaptureNudge knob) — the
371
+ cache is a separate feature. Best-effort; never raises, so a bug here can
372
+ never wedge Stop.
373
+ """
374
+ try:
375
+ if not messages:
376
+ return
377
+ idx, _text = find_last_human_turn(messages)
378
+ start = idx if idx is not None else -1
379
+ if turn_contains_directive_write(messages, start):
380
+ invalidate_directives_cache()
381
+ debug_log(config, "Directives cache invalidated — turn wrote a directive")
382
+ except Exception as e: # pragma: no cover - defensive; Stop must not wedge
383
+ debug_log(config, f"Directives cache invalidation skipped (error): {e}")
384
+
385
+
320
386
  def read_transcript(transcript_path: str) -> list:
321
387
  """Read a JSONL transcript into a list of message dicts (role/content).
322
388
 
@@ -348,11 +414,15 @@ def read_transcript(transcript_path: str) -> list:
348
414
  return messages
349
415
 
350
416
 
351
- def evaluate(hook_input: dict, config: dict) -> str | None:
417
+ def evaluate(hook_input: dict, config: dict, messages: list | None = None) -> str | None:
352
418
  """Core decision. Returns a block reason string, or None to allow stop.
353
419
 
354
420
  None → the turn is allowed to end (no-op). A non-empty string → block the
355
421
  stop once and feed the string back to the model.
422
+
423
+ ``messages`` is the pre-read transcript when the caller already parsed it
424
+ (main() reads once and shares it); when None, the transcript is read here so
425
+ direct callers/tests keep the old single-arg contract.
356
426
  """
357
427
  # Same knob as Stage B — disabling the nudge disables this verification.
358
428
  if not config.get("directiveCaptureNudge", True):
@@ -371,7 +441,8 @@ def evaluate(hook_input: dict, config: dict) -> str | None:
371
441
  debug_log(config, "Directive-capture verify: stop_hook_active, not re-blocking")
372
442
  return None
373
443
 
374
- messages = read_transcript(hook_input.get("transcript_path", ""))
444
+ if messages is None:
445
+ messages = read_transcript(hook_input.get("transcript_path", ""))
375
446
  if not messages:
376
447
  return None
377
448
 
@@ -423,8 +494,34 @@ def main():
423
494
  config = load_config()
424
495
  except Exception:
425
496
  return
497
+
498
+ # Read the transcript AT MOST ONCE and share it with both consumers below
499
+ # (a Stop hook must not parse a multi-MB session twice, nor at all when
500
+ # nothing here needs it). Two features want the transcript:
501
+ # * A4 directives-cache invalidation — only when the cache is enabled
502
+ # (TTL > 0); with the cache off there is nothing to invalidate.
503
+ # * capture-verify (evaluate) — only when the nudge+verify knobs are on
504
+ # and this is not an already-blocked re-fire.
505
+ ttl = config.get("directivesCacheTtlSeconds", DIRECTIVES_CACHE_TTL_SECONDS)
506
+ cache_on = isinstance(ttl, (int, float)) and ttl > 0
507
+ verify_maybe = (
508
+ config.get("directiveCaptureNudge", True)
509
+ and config.get("directiveCaptureVerify", True)
510
+ and not hook_input.get("stop_hook_active")
511
+ )
512
+
513
+ messages: list = []
514
+ if cache_on or verify_maybe:
515
+ messages = read_transcript(hook_input.get("transcript_path", ""))
516
+
517
+ # A4: invalidate the directives cache when this turn wrote a directive, so
518
+ # the change is visible on the very next recall. Independent of the
519
+ # capture-verify decision below and self-guarded — runs on every Stop.
520
+ if cache_on:
521
+ invalidate_cache_on_directive_write(messages, config)
522
+
426
523
  try:
427
- reason = evaluate(hook_input, config)
524
+ reason = evaluate(hook_input, config, messages=messages)
428
525
  except Exception as e: # never wedge a turn on a verify bug
429
526
  debug_log(config, f"Directive-capture verify error (allowing stop): {e}")
430
527
  return
@@ -71,8 +71,28 @@ DEFAULTS = {
71
71
  # per-agent via memory.directive_capture_verify=false →
72
72
  # HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY.
73
73
  "directiveCaptureVerify": True,
74
- "recallContextTurns": 1,
74
+ # Switchroom hindsight-leverage A4 — TTL (seconds) for the directives-list
75
+ # cache on the recall critical path (see lib/directives.py). The list is
76
+ # re-fetched at most once per TTL window for no-write turns; in-session
77
+ # directive writes invalidate the cache immediately via directive_verify.py.
78
+ # 0 disables the cache (live fetch every turn — the A4 rollback lever).
79
+ "directivesCacheTtlSeconds": 120,
80
+ # Switchroom hindsight-leverage A2 (PR2): default 2 so a bare follow-up
81
+ # user message ("and the port?", "what about staging?") embeds together
82
+ # with its antecedent turn, instead of recalling on the pronoun alone.
83
+ # Bounded by recallMaxQueryChars (truncate_recall_query preserves the
84
+ # latest turn and drops oldest context first) so the composition can never
85
+ # blow the recall query budget; the transcript read is byte-tail-bounded
86
+ # by recallTranscriptTailBytes so the added per-turn read stays O(1).
87
+ "recallContextTurns": 2,
75
88
  "recallMaxQueryChars": 800,
89
+ # Switchroom hindsight-leverage A2 (PR2) — latency bound for the multi-turn
90
+ # composition. With recallContextTurns>1 now the default, EVERY recall reads
91
+ # the transcript to slice the last N human turns. A long session's .jsonl can
92
+ # grow to many MB, so read only the last N bytes (complete trailing lines);
93
+ # the last 2-3 human turns always live at the tail. 0 disables the bound
94
+ # (read the whole file — the pre-A2 behaviour / rollback lever).
95
+ "recallTranscriptTailBytes": 262144,
76
96
  "recallRoles": ["user", "assistant"],
77
97
  # Upstream 962140eef — optional recall tag filters passed through to the
78
98
  # recall API, plus per-additional-bank overrides keyed by bank ID.
@@ -80,6 +100,19 @@ DEFAULTS = {
80
100
  "recallTagsMatch": "any",
81
101
  "recallTagGroups": None,
82
102
  "recallAdditionalBankFilters": {},
103
+ # Switchroom (hindsight-leverage PR5) — per-tag recall score weights. A map
104
+ # of ``{tag: multiplier}`` applied to each result's ``scores.final`` just
105
+ # before the final relevance sort/cap in recall.py. A weight < 1.0 DEMOTES
106
+ # (down-ranks) memories carrying that tag without DROPPING them — distinct
107
+ # from the hard demote-tag drop filter (`_is_demoted_memory`), which removes
108
+ # a memory from recall entirely. This is the "reduced weight" the drop
109
+ # filter cannot express: a demoted memory still surfaces when it is the only
110
+ # relevant hit, just below equal-scoring untagged memories. Default {} =
111
+ # no-op (identity weighting). Switchroom's scaffold seeds
112
+ # ``{"sidechain": 0.8}`` so delegated sub-agent process-memories rank just
113
+ # under first-party session memories. Env: HINDSIGHT_RECALL_TAG_WEIGHTS
114
+ # (JSON object).
115
+ "recallTagWeights": {},
83
116
  "recallPromptPreamble": (
84
117
  "Relevant memories from past conversations (prioritize recent when "
85
118
  "conflicting). Only use memories that are directly useful to continue "
@@ -95,6 +128,56 @@ DEFAULTS = {
95
128
  "retainContext": "claude-code",
96
129
  "retainTags": [],
97
130
  "retainMetadata": {},
131
+ # Switchroom hindsight-leverage E2 / PR9 (#398) — lesson & anti-pattern
132
+ # tagging at retain time. When on (default), build_retain_payload scans the
133
+ # formatted transcript slice for explicit lesson / anti-pattern markers and
134
+ # attaches the matching tag(s) to the retain. This is the retain-side half of
135
+ # #398: a transcript that captures a failure mode ("anti-pattern:", "what not
136
+ # to do") or a self-recognised lesson ("lesson learned", "note to self:") is
137
+ # tagged so the recall-side score-penalty weight map (recallTagWeights, PR5)
138
+ # can DEMOTE it below clean first-party session memories — without ever hard-
139
+ # dropping it. NON-GOAL (epic-recorded): historical corpus is NOT re-tagged;
140
+ # this fires on NEW retains only. Detection is deterministic substring match
141
+ # (case-insensitive), never model-dependent. Disable via
142
+ # HINDSIGHT_LESSON_TAGGING=false (rollback lever).
143
+ "lessonTagging": True,
144
+ # Marker map {tag: [substrings]}. A slice whose lower-cased text contains ANY
145
+ # of a tag's substrings gets that tag. Deliberately explicit prefixes to keep
146
+ # false positives low — a passing mention of the word "lesson" should not tag
147
+ # a whole transcript; an explicit "lesson learned" / "anti-pattern:" should.
148
+ # Operators can extend/replace via HINDSIGHT_LESSON_TAG_MARKERS (JSON object).
149
+ "lessonTagMarkers": {
150
+ "lesson": [
151
+ "lesson learned",
152
+ "lessons learned",
153
+ "lesson:",
154
+ "note to self:",
155
+ "for next time:",
156
+ "takeaway:",
157
+ "key takeaway",
158
+ ],
159
+ "anti-pattern": [
160
+ "anti-pattern:",
161
+ "anti pattern:",
162
+ "antipattern:",
163
+ "what not to do",
164
+ "do not do this again",
165
+ "don't do this again",
166
+ "failure mode:",
167
+ ],
168
+ },
169
+ # Switchroom hindsight-leverage E2 / PR9 (#398) — recall-side demotion weights
170
+ # for the lesson/anti-pattern tags above. Merged UNDER recallTagWeights at
171
+ # recall time (an explicit recallTagWeights entry for the same tag WINS), so
172
+ # lesson/anti-pattern transcripts are down-ranked out of the box while the
173
+ # PR5 sidechain seed and any operator override still compose cleanly. A raw
174
+ # transcript that merely discusses a failure mode should rank below a clean
175
+ # session memory of equal engine score, yet still surface when it is the only
176
+ # relevant hit (re-rank, never drop). Set HINDSIGHT_LESSON_DEMOTION=false to
177
+ # disable the built-in weights (rollback lever); override individual weights
178
+ # via recallTagWeights / HINDSIGHT_RECALL_TAG_WEIGHTS.
179
+ "lessonDemotion": True,
180
+ "lessonDemotionWeights": {"lesson": 0.85, "anti-pattern": 0.5},
98
181
  # Switchroom #3244 — boot reconciliation of un-committed transcript tails.
99
182
  # On by default; the load-bearing recovery for work an abrupt session death
100
183
  # (SIGKILL/OOM/watchdog) skipped. Disable per-agent via
@@ -102,6 +185,46 @@ DEFAULTS = {
102
185
  # HINDSIGHT_RECONCILE_{LOOKBACK_H,MAX_TURNS,BUDGET_S} bounds (read directly).
103
186
  "reconcileOnStart": True,
104
187
  "recallAdditionalBanks": [],
188
+ # Switchroom hindsight-leverage A3 — parallelise multi-bank recall.
189
+ # When on (default), the directives fetch and every bank recall run
190
+ # concurrently in daemon threads under ONE shared deadline
191
+ # (recallParallelDeadlineSeconds), so total critical-path latency is the
192
+ # SLOWEST slot instead of their SUM. Set false
193
+ # (HINDSIGHT_RECALL_PARALLEL=false) to restore the pre-A3 serial path —
194
+ # the rollback lever if the parallel path ever misbehaves.
195
+ "recallParallel": True,
196
+ # Shared deadline (seconds) for the whole parallel recall section. Sized
197
+ # at the UserPromptSubmit hook ceiling (12s, hooks.json) MINUS 2s headroom
198
+ # for block formatting + cache write + stdout flush, so a straggler bank
199
+ # can never push the hook past its ceiling. Slots still unfinished when the
200
+ # deadline elapses are abandoned (daemon threads) and marked timed_out.
201
+ "recallParallelDeadlineSeconds": 10,
202
+ # Switchroom hindsight-leverage E1 / PR8 (#3369) — bounded transcript-grep
203
+ # fallback. Boot reconciliation (reconcile_tail.py) closes the crash-loss
204
+ # window at the NEXT SessionStart, but between an abrupt kill and that boot,
205
+ # live recall returns nothing for the lost turns because the fact layer was
206
+ # never told about them. When on (default) AND every bank returned zero
207
+ # results AND no bank/directives slot hit its deadline (deadline_hit False —
208
+ # so a timed-out bank can't masquerade as a genuinely empty fact layer, the
209
+ # #3369 sequencing constraint that depends on A3's shared-deadline telemetry),
210
+ # recall greps the CURRENT session's transcript tail for turns that mention
211
+ # the query's terms and injects them as a clearly-labelled, lower-confidence
212
+ # fallback block. Everything is bounded: the tail read (…MaxBytes), the number
213
+ # of matched turns (…MaxTurns), the emitted characters (…MaxChars), and the
214
+ # grep wall-time (…DeadlineMs). Set false (HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK
215
+ # =false) to disable — the rollback lever.
216
+ "recallTranscriptFallback": True,
217
+ # Tail bytes read from the session transcript for the grep. 256 KiB covers
218
+ # many turns of recent conversation while keeping the read well inside the
219
+ # hook budget; only the LAST max_bytes are read (partial first line dropped).
220
+ "recallTranscriptFallbackMaxBytes": 262144,
221
+ # Hard ceiling on matched turns injected into the fallback block.
222
+ "recallTranscriptFallbackMaxTurns": 6,
223
+ # Hard ceiling on the fallback block's excerpt characters.
224
+ "recallTranscriptFallbackMaxChars": 2000,
225
+ # Wall-clock bound (ms) on the grep itself — abandons scanning older turns
226
+ # once exceeded so the fallback can never eat the recall critical path.
227
+ "recallTranscriptFallbackDeadlineMs": 1500,
105
228
  # Connection
106
229
  "hindsightApiUrl": None,
107
230
  "hindsightApiToken": None,
@@ -140,6 +263,12 @@ ENV_OVERRIDES = {
140
263
  "HINDSIGHT_AUTO_RECALL": ("autoRecall", bool),
141
264
  "HINDSIGHT_AUTO_RETAIN": ("autoRetain", bool),
142
265
  "HINDSIGHT_RETAIN_MODE": ("retainMode", str),
266
+ # Switchroom hindsight-leverage E2 / PR9 (#398) — lesson/anti-pattern tagging
267
+ # + recall demotion toggles and overrides.
268
+ "HINDSIGHT_LESSON_TAGGING": ("lessonTagging", bool),
269
+ "HINDSIGHT_LESSON_TAG_MARKERS": ("lessonTagMarkers", dict),
270
+ "HINDSIGHT_LESSON_DEMOTION": ("lessonDemotion", bool),
271
+ "HINDSIGHT_LESSON_DEMOTION_WEIGHTS": ("lessonDemotionWeights", dict),
143
272
  # Switchroom #3244 — boot reconciliation on/off (default on).
144
273
  "HINDSIGHT_RECONCILE_ON_START": ("reconcileOnStart", bool),
145
274
  "HINDSIGHT_RECALL_BUDGET": ("recallBudget", str),
@@ -171,13 +300,33 @@ ENV_OVERRIDES = {
171
300
  # agents.<name>.memory.directive_capture_verify only when the operator
172
301
  # overrode it; the switchroom default is on.
173
302
  "HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY": ("directiveCaptureVerify", bool),
303
+ # Switchroom hindsight-leverage A4 — directives-list cache TTL (seconds).
304
+ # 0 disables the cache (rollback lever).
305
+ "HINDSIGHT_DIRECTIVES_CACHE_TTL_SECONDS": ("directivesCacheTtlSeconds", int),
306
+ # Switchroom hindsight-leverage A3 — parallel multi-bank recall toggle +
307
+ # shared deadline. HINDSIGHT_RECALL_PARALLEL=false is the serial rollback
308
+ # lever; the deadline is the ceiling-minus-2s hard budget (see DEFAULTS).
309
+ "HINDSIGHT_RECALL_PARALLEL": ("recallParallel", bool),
310
+ "HINDSIGHT_RECALL_PARALLEL_DEADLINE_SECONDS": ("recallParallelDeadlineSeconds", int),
311
+ # Switchroom hindsight-leverage E1 / PR8 (#3369) — transcript-grep fallback
312
+ # toggle + bounds. HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK=false is the rollback
313
+ # lever; the others tune the byte / turn / char / time bounds.
314
+ "HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK": ("recallTranscriptFallback", bool),
315
+ "HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK_MAX_BYTES": ("recallTranscriptFallbackMaxBytes", int),
316
+ "HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK_MAX_TURNS": ("recallTranscriptFallbackMaxTurns", int),
317
+ "HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK_MAX_CHARS": ("recallTranscriptFallbackMaxChars", int),
318
+ "HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK_DEADLINE_MS": ("recallTranscriptFallbackDeadlineMs", int),
174
319
  "HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
175
320
  "HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
321
+ # Switchroom hindsight-leverage A2 — byte-tail bound for the multi-turn
322
+ # transcript read (0 = read whole file / rollback lever).
323
+ "HINDSIGHT_RECALL_TRANSCRIPT_TAIL_BYTES": ("recallTranscriptTailBytes", int),
176
324
  # Upstream 962140eef — recall tag filters. The tags env var accepts JSON
177
325
  # or a comma-separated list; the others must be JSON.
178
326
  "HINDSIGHT_RECALL_TAGS": ("recallTags", list),
179
327
  "HINDSIGHT_RECALL_TAGS_MATCH": ("recallTagsMatch", str),
180
328
  "HINDSIGHT_RECALL_TAG_GROUPS": ("recallTagGroups", dict),
329
+ "HINDSIGHT_RECALL_TAG_WEIGHTS": ("recallTagWeights", dict),
181
330
  "HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS": ("recallAdditionalBankFilters", dict),
182
331
  "HINDSIGHT_API_PORT": ("apiPort", int),
183
332
  "HINDSIGHT_DAEMON_IDLE_TIMEOUT": ("daemonIdleTimeout", int),