switchroom 0.19.1 → 0.19.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/agent-scheduler/index.js +31 -1
  2. package/dist/auth-broker/index.js +565 -48
  3. package/dist/cli/autoaccept-poll.js +31 -1
  4. package/dist/cli/drive-write-pretool.mjs +32 -2
  5. package/dist/cli/ms-365-write-pretool.mjs +32 -2
  6. package/dist/cli/switchroom.js +1148 -274
  7. package/dist/host-control/main.js +3 -3
  8. package/dist/vault/approvals/kernel-server.js +2 -2
  9. package/dist/vault/broker/server.js +2 -2
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +1 -0
  12. package/profiles/default/CLAUDE.md.hbs +8 -0
  13. package/skills/mental-model-curator/SKILL.md +68 -2
  14. package/skills/switchroom-cli/SKILL.md +25 -0
  15. package/telegram-plugin/auth-snapshot-format.ts +143 -12
  16. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  17. package/telegram-plugin/dist/gateway/gateway.js +1427 -689
  18. package/telegram-plugin/dist/server.js +8 -2
  19. package/telegram-plugin/external-spend.ts +135 -0
  20. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  21. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  22. package/telegram-plugin/gateway/auth-command.ts +138 -5
  23. package/telegram-plugin/gateway/gateway.ts +141 -158
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  25. package/telegram-plugin/gateway/model-command.ts +309 -1
  26. package/telegram-plugin/gateway/narrative-lane.ts +23 -9
  27. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  28. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  29. package/telegram-plugin/gateway/status-pin-store.ts +64 -4
  30. package/telegram-plugin/gateway/stream-render.ts +22 -5
  31. package/telegram-plugin/gateway/usage-mask.ts +29 -0
  32. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
  33. package/telegram-plugin/quota-bar-format.ts +78 -12
  34. package/telegram-plugin/quota-check.ts +17 -2
  35. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  36. package/telegram-plugin/session-tail.ts +27 -3
  37. package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
  38. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  39. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  40. package/telegram-plugin/tests/external-spend.test.ts +168 -0
  41. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  42. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +219 -29
  43. package/telegram-plugin/tests/model-command.test.ts +220 -0
  44. package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
  45. package/telegram-plugin/tests/quota-check.test.ts +57 -0
  46. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  47. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  48. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  49. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  50. package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
  51. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
  52. package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
  53. package/telegram-plugin/tests/usage-mask.test.ts +35 -0
  54. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
  55. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
  56. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  57. package/vendor/hindsight-memory/README.md +2 -1
  58. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  60. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  61. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  62. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  63. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  64. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  65. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  66. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  67. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  68. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  73. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  75. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  77. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  78. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  79. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  80. package/vendor/hindsight-memory/settings.json +3 -1
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Adversarial-review F1 + F2 — the /usage card must NEVER stamp "Live" when
3
+ * no account row carries usable live data.
4
+ *
5
+ * Root cause (F1): the gateway `/usage` handler decided the freshness footer
6
+ * from `probeResp.results.length > 0`. But opProbeQuota
7
+ * (src/auth/broker/server.ts) returns `{result:{ok:false}, served:"live"}`
8
+ * for a failed live probe against an EMPTY cache — a non-empty results array
9
+ * where every row is `ok:false`. `zipProbeResults` only marks
10
+ * `served==="cache"` rows stale, so those failed rows carry no cache stamp
11
+ * either. Result: on a fresh restart (empty cache) + a transient probe
12
+ * failure, every row rendered "⚠️ no data — probe failed" UNDER a false
13
+ * "Live · refreshed 0s ago" stamp.
14
+ *
15
+ * F2 (test gap): the footer decision had zero coverage. This drives the
16
+ * extracted decision (`deriveUsageFooterFreshness`) composed with the real
17
+ * card renderer (`renderUsageCard`) with an all-`ok:false`, no-cache broker
18
+ * stub and asserts the rendered footer is NOT "Live" — i.e. it would fail
19
+ * against the pre-fix `.length > 0` gate.
20
+ */
21
+
22
+ import { describe, it, expect } from 'vitest'
23
+ import {
24
+ deriveUsageFooterFreshness,
25
+ type AccountSnapshot,
26
+ type ProbeQuotaResultRow,
27
+ } from '../auth-snapshot-format.js'
28
+ import { renderUsageCard } from '../quota-bar-format.js'
29
+
30
+ const NOW = new Date('2026-07-19T12:00:00Z')
31
+
32
+ // Two accounts, no usable quota (the probe failed, nothing cached) — mirrors
33
+ // what the gateway hands renderUsageCard when the broker cache is empty and
34
+ // the live probe threw for every account.
35
+ const NO_DATA_SNAPSHOTS: AccountSnapshot[] = [
36
+ { label: 'alice@example.com', isActive: true, quota: null, quotaError: 'probe failed' },
37
+ { label: 'bob@example.com', isActive: false, quota: null, quotaError: 'probe failed' },
38
+ ]
39
+
40
+ // A broker probe-quota response where every row failed and nothing was served
41
+ // from cache — the exact shape opProbeQuota returns for a failed live probe
42
+ // against an empty cache.
43
+ const ALL_FAILED_NO_CACHE: ProbeQuotaResultRow[] = [
44
+ { label: 'alice@example.com', result: { ok: false, reason: 'network error' }, served: 'live' },
45
+ { label: 'bob@example.com', result: { ok: false, reason: 'network error' }, served: 'live' },
46
+ ]
47
+
48
+ const EXHAUSTED = new Map<string, boolean>()
49
+
50
+ describe('deriveUsageFooterFreshness (F1)', () => {
51
+ it('flags probeFailed when every row is ok:false and there is no cache', () => {
52
+ const opts = deriveUsageFooterFreshness(ALL_FAILED_NO_CACHE, undefined, NOW.getTime())
53
+ // Would have been { liveProbedAtMs } under the old `.length > 0` gate.
54
+ expect(opts).toEqual({ probeFailed: true })
55
+ expect(opts.liveProbedAtMs).toBeUndefined()
56
+ })
57
+
58
+ it('stamps liveProbedAtMs when at least one row carries usable data', () => {
59
+ const rows: ProbeQuotaResultRow[] = [
60
+ ALL_FAILED_NO_CACHE[0],
61
+ {
62
+ label: 'bob@example.com',
63
+ result: {
64
+ ok: true,
65
+ data: {
66
+ fiveHourUtilizationPct: 12,
67
+ sevenDayUtilizationPct: 34,
68
+ fiveHourResetAt: null,
69
+ sevenDayResetAt: null,
70
+ representativeClaim: null,
71
+ overageStatus: null,
72
+ overageDisabledReason: null,
73
+ fiveHourUtilPresent: true,
74
+ sevenDayUtilPresent: true,
75
+ },
76
+ },
77
+ served: 'live',
78
+ },
79
+ ]
80
+ const opts = deriveUsageFooterFreshness(rows, undefined, NOW.getTime())
81
+ expect(opts).toEqual({ liveProbedAtMs: NOW.getTime() })
82
+ expect(opts.probeFailed).toBeUndefined()
83
+ })
84
+
85
+ it('gives cache-served data precedence over both live and failed', () => {
86
+ const capturedAt = NOW.getTime() - 60_000
87
+ expect(deriveUsageFooterFreshness(ALL_FAILED_NO_CACHE, capturedAt, NOW.getTime())).toEqual({
88
+ staleCachedAtMs: capturedAt,
89
+ })
90
+ })
91
+
92
+ it('flags probeFailed for a totally empty results array', () => {
93
+ expect(deriveUsageFooterFreshness([], undefined, NOW.getTime())).toEqual({ probeFailed: true })
94
+ })
95
+ })
96
+
97
+ describe('/usage card footer end-to-end (F2)', () => {
98
+ it('renders "probe failed", NOT "Live", when no row carries live data', () => {
99
+ const freshness = deriveUsageFooterFreshness(ALL_FAILED_NO_CACHE, undefined, NOW.getTime())
100
+ const card = renderUsageCard(NO_DATA_SNAPSHOTS, EXHAUSTED, { now: NOW, ...freshness })
101
+ expect(card).toContain('probe failed — no live data')
102
+ // The honesty invariant: no "Live" stamp when every row shows no data.
103
+ expect(card).not.toContain('Live')
104
+ // And the rows themselves are the no-data warning, not a fake 0% bar.
105
+ expect(card).toContain('no data — probe failed')
106
+ })
107
+
108
+ it('renders a "Live" footer when a row carries usable data', () => {
109
+ const liveSnapshots: AccountSnapshot[] = [
110
+ {
111
+ label: 'bob@example.com',
112
+ isActive: true,
113
+ quota: {
114
+ fiveHourUtilizationPct: 12,
115
+ sevenDayUtilizationPct: 34,
116
+ fiveHourResetAt: null,
117
+ sevenDayResetAt: null,
118
+ representativeClaim: null,
119
+ overageStatus: null,
120
+ overageDisabledReason: null,
121
+ fiveHourUtilPresent: true,
122
+ sevenDayUtilPresent: true,
123
+ },
124
+ },
125
+ ]
126
+ const freshness = deriveUsageFooterFreshness(
127
+ [
128
+ {
129
+ label: 'bob@example.com',
130
+ result: { ok: true, data: liveSnapshots[0].quota! },
131
+ served: 'live',
132
+ },
133
+ ],
134
+ undefined,
135
+ NOW.getTime(),
136
+ )
137
+ const card = renderUsageCard(liveSnapshots, EXHAUSTED, { now: NOW, ...freshness })
138
+ expect(card).toContain('Live · refreshed')
139
+ expect(card).not.toContain('probe failed')
140
+ })
141
+ })
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Adversarial-review F4 — `/usage` account-label masking policy.
3
+ *
4
+ * A group with an empty `allowFrom` authorizes every member, so the quota
5
+ * card would leak per-account email labels to a broadly shared group unless
6
+ * masked. `shouldMaskUsageLabels` (gateway/usage-mask.ts) is the pure
7
+ * decision the gateway feeds into the existing demo-mask machinery.
8
+ */
9
+
10
+ import { describe, it, expect } from 'vitest'
11
+ import { shouldMaskUsageLabels } from '../gateway/usage-mask.js'
12
+
13
+ describe('shouldMaskUsageLabels (F4)', () => {
14
+ it('never masks in a private (operator DM) chat', () => {
15
+ expect(shouldMaskUsageLabels('private', undefined)).toBe(false)
16
+ expect(shouldMaskUsageLabels('private', [])).toBe(false)
17
+ expect(shouldMaskUsageLabels('private', ['123'])).toBe(false)
18
+ })
19
+
20
+ it('masks in a group with an EMPTY allowFrom (open membership)', () => {
21
+ expect(shouldMaskUsageLabels('group', [])).toBe(true)
22
+ expect(shouldMaskUsageLabels('group', undefined)).toBe(true)
23
+ expect(shouldMaskUsageLabels('supergroup', [])).toBe(true)
24
+ })
25
+
26
+ it('does NOT mask in a group with a curated non-empty allowFrom', () => {
27
+ expect(shouldMaskUsageLabels('group', ['123'])).toBe(false)
28
+ expect(shouldMaskUsageLabels('supergroup', ['123', '456'])).toBe(false)
29
+ })
30
+
31
+ it('masks defensively for any other chat type reaching a quota render', () => {
32
+ expect(shouldMaskUsageLabels('channel', undefined)).toBe(true)
33
+ expect(shouldMaskUsageLabels(undefined, undefined)).toBe(true)
34
+ })
35
+ })
@@ -72,6 +72,33 @@ describe('resolveWorkerFeedDispatch (#2002 regression pin)', () => {
72
72
  expect(resolveWorkerFeedDispatch(null, 'sub-agent').feedModel).toBeNull()
73
73
  expect(resolveWorkerFeedDispatch(makeSub({ model: null }), 'sub-agent').feedModel).toBeNull()
74
74
  })
75
+
76
+ // F3 (progress-card fork model) — outcome coverage. The dispatch-time seed is
77
+ // suppressed for forks at the hook (subagent-tracker-pretool.mjs), so a fork
78
+ // row carries model=null and the card omits the model until the watcher writes
79
+ // the transcript model. resolveWorkerFeedDispatch simply surfaces the row's
80
+ // CURRENT model, so these assert the feed-level outcomes on top of that.
81
+ it('(b) a fork row carries no dispatch-time model → feedModel null (card omits it until transcript)', () => {
82
+ // What the hook now writes for a fork dispatch: agent_type 'fork', model null
83
+ // (the ignored override is not persisted).
84
+ const forkRow = makeSub({ background: true, agent_type: 'fork', model: null })
85
+ expect(resolveWorkerFeedDispatch(forkRow, 'sub-agent').feedModel).toBeNull()
86
+ })
87
+
88
+ it('(a) once the watcher overwrites the row model from the transcript, feedModel reflects the transcript model', () => {
89
+ // Transcript wins: the watcher (recordSubagentModel) overwrites the row's
90
+ // model column in place, so the resolved feedModel is the transcript value —
91
+ // even for a fork that started with model=null.
92
+ const afterTranscript = makeSub({ background: true, agent_type: 'fork', model: 'claude-opus-4-8' })
93
+ expect(resolveWorkerFeedDispatch(afterTranscript, 'sub-agent').feedModel).toBe('claude-opus-4-8')
94
+ })
95
+
96
+ it('(c) a mid-run model switch is reflected: feedModel tracks the row’s latest recorded model', () => {
97
+ // The watcher writes model-on-change; the row holds the LATEST model, so a
98
+ // later substantive tick resolves the switched-to model.
99
+ const switchedTo = makeSub({ background: true, model: 'sr-glm-5' })
100
+ expect(resolveWorkerFeedDispatch(switchedTo, 'sub-agent').feedModel).toBe('sr-glm-5')
101
+ })
75
102
  })
76
103
 
77
104
  describe('gateway onFinish — fix #1: resultText-driven handback fallback (model)', () => {
@@ -5,7 +5,12 @@ import {
5
5
  type BotApiForWorkerFeed,
6
6
  } from '../worker-activity-feed.js'
7
7
  import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
8
- import type { PinState, DesiredPin } from '../status-pin.js'
8
+ import { decidePinAction, type PinState, type DesiredPin } from '../status-pin.js'
9
+ import {
10
+ reconcileAndPersistStatusPin,
11
+ loadStatusPins,
12
+ type StatusPinStoreFsSeam,
13
+ } from '../gateway/status-pin-store.js'
9
14
 
10
15
  /**
11
16
  * Outcome tests for the invisible-worker-cards fix (2026-07-15).
@@ -119,6 +124,131 @@ async function flush(): Promise<void> {
119
124
  for (let i = 0; i < 8; i++) await new Promise((r) => setImmediate(r))
120
125
  }
121
126
 
127
+ /** In-memory fs seam with atomic rename, mirroring status-pin-store.test.ts. */
128
+ function memFs(): { fs: StatusPinStoreFsSeam; files: Map<string, string> } {
129
+ const files = new Map<string, string>()
130
+ const fs: StatusPinStoreFsSeam = {
131
+ readFileSync: (p) => {
132
+ if (!files.has(p)) throw new Error(`ENOENT ${p}`)
133
+ return files.get(p)!
134
+ },
135
+ writeFileSync: (p, d) => {
136
+ files.set(p, d)
137
+ },
138
+ renameSync: (a, b) => {
139
+ if (!files.has(a)) throw new Error(`ENOENT ${a}`)
140
+ files.set(b, files.get(a)!)
141
+ files.delete(a)
142
+ },
143
+ existsSync: (p) => files.has(p),
144
+ }
145
+ return { fs, files }
146
+ }
147
+
148
+ /**
149
+ * A pin harness that routes the feed's `reconcilePin` hook through the REAL
150
+ * persistence path (`reconcileAndPersistStatusPin`) against a memFs-backed
151
+ * status-pins.json — exactly as the gateway wires it: read prev claim → decide
152
+ * → map to a persist op → reconcileAndPersistStatusPin(applyPin=reconcilePin).
153
+ * This lets a test INSPECT THE FILE across steady-state edits (F1).
154
+ */
155
+ function makePersistingPinHarness(path: string) {
156
+ const { fs } = memFs()
157
+ const claims = new Map<string, PinState>()
158
+ const pinCalls: number[] = []
159
+ const unpinCalls: number[] = []
160
+ const api: PinBotApi = {
161
+ pinChatMessage: async (_c, id) => {
162
+ pinCalls.push(id)
163
+ },
164
+ unpinChatMessage: async (_c, id) => {
165
+ unpinCalls.push(id)
166
+ },
167
+ }
168
+ async function reconcile(key: string, chatId: string, desired: DesiredPin): Promise<void> {
169
+ const prev = claims.get(key) ?? null
170
+ const action = decidePinAction(prev, desired)
171
+ const op = action.kind === 'pin'
172
+ ? ({ kind: 'pin', messageId: action.messageId } as const)
173
+ : ({ kind: 'clear' } as const)
174
+ const next = await reconcileAndPersistStatusPin({
175
+ path,
176
+ fs,
177
+ pinKey: key,
178
+ chatId,
179
+ op,
180
+ applyPin: () => reconcilePin({ api, chatId, prevState: prev, desired }),
181
+ log: () => {},
182
+ })
183
+ if (next == null) claims.delete(key)
184
+ else claims.set(key, next)
185
+ }
186
+ const reconcilePinFn = (args: {
187
+ feedKey: string
188
+ chatId: string
189
+ threadId?: number
190
+ messageId: number | null
191
+ }): void => {
192
+ const key = `wk:group:${args.feedKey}`
193
+ if (args.messageId != null) void reconcile(key, args.chatId, { pinned: true, messageId: args.messageId })
194
+ else void reconcile(key, args.chatId, { pinned: false })
195
+ }
196
+ return {
197
+ reconcilePinFn,
198
+ fs,
199
+ pinCalls,
200
+ unpinCalls,
201
+ rows: () => loadStatusPins(path, fs),
202
+ }
203
+ }
204
+
205
+ describe('worker-feed pin persistence — durable status-pins.json survives steady-state edits (F1)', () => {
206
+ const PATH = '/state/agent/telegram/status-pins.json'
207
+
208
+ it('preserves the wk:group row across many steady-state edits (noop-clear must NOT delete it)', async () => {
209
+ const bot = makeFakeBot()
210
+ const pin = makePersistingPinHarness(PATH)
211
+ let clock = 0
212
+ const feed = createWorkerActivityFeed({
213
+ bot,
214
+ now: () => clock,
215
+ firstPaintMinMs: 0,
216
+ minEditIntervalMs: 0,
217
+ reconcilePin: pin.reconcilePinFn,
218
+ })
219
+
220
+ // First paint → message posted, pinned once, durable row written.
221
+ clock = 1000
222
+ await feed.update('w1', 'chat', view({ elapsedMs: 1000, toolCount: 1 }))
223
+ await flush()
224
+ const msgId = bot.sent[0].messageId
225
+ expect(pin.pinCalls).toEqual([msgId])
226
+ expect(pin.rows()).toHaveLength(1)
227
+ const pinKey = pin.rows()[0].pinKey
228
+ expect(pinKey).toMatch(/^wk:group:/)
229
+ expect(pin.rows()).toEqual([
230
+ { pinKey, chatId: 'chat', messageId: msgId },
231
+ ])
232
+
233
+ // Many steady-state edits — the feed calls syncPin on EVERY edit, each a
234
+ // re-pin of the SAME id → noop → a `clear` op with a LIVE claim. Pre-fix the
235
+ // clear branch deleted the durable row here; the fix preserves it.
236
+ for (let i = 2; i <= 8; i++) {
237
+ clock = i * 1000
238
+ await feed.update('w1', 'chat', view({ elapsedMs: i * 1000, toolCount: i }))
239
+ await flush()
240
+ }
241
+
242
+ // The durable row is STILL on disk (inspect the file), and no extra pin/
243
+ // unpin API call was issued across all those steady-state edits.
244
+ expect(pin.rows()).toEqual([
245
+ { pinKey, chatId: 'chat', messageId: msgId },
246
+ ])
247
+ expect(pin.pinCalls).toEqual([msgId]) // exactly one pin, ever
248
+ expect(pin.unpinCalls).toEqual([]) // never unpinned
249
+ })
250
+ })
251
+
122
252
  describe('worker-feed pin persistence — steady-state re-pin (invisible-worker-cards)', () => {
123
253
  it('re-pins on a steady-state edit when the claim was lost, and does NOT re-pin when already correct (no storm)', async () => {
124
254
  const bot = makeFakeBot()
@@ -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