switchroom 0.19.34 → 0.19.36

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 (35) hide show
  1. package/dist/auth-broker/index.js +7 -1
  2. package/dist/cli/skill-validate-pretool.mjs +15 -2
  3. package/dist/cli/switchroom.js +1005 -482
  4. package/dist/host-control/main.js +156 -4
  5. package/dist/vault/approvals/kernel-server.js +7 -1
  6. package/dist/vault/broker/server.js +7 -1
  7. package/package.json +4 -2
  8. package/profiles/_base/start.sh.hbs +37 -12
  9. package/telegram-plugin/dist/gateway/gateway.js +476 -116
  10. package/telegram-plugin/format.ts +70 -15
  11. package/telegram-plugin/gateway/gateway.ts +27 -31
  12. package/telegram-plugin/gateway/ipc-server.ts +18 -15
  13. package/telegram-plugin/gateway/model-command.ts +279 -23
  14. package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
  15. package/telegram-plugin/operator-events.ts +40 -16
  16. package/telegram-plugin/secret-detect/db-uri.ts +90 -0
  17. package/telegram-plugin/secret-detect/index.ts +24 -1
  18. package/telegram-plugin/secret-detect/inert-values.ts +147 -0
  19. package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
  20. package/telegram-plugin/secret-detect/patterns.ts +24 -4
  21. package/telegram-plugin/tests/format-consistency.test.ts +93 -0
  22. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
  23. package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
  24. package/telegram-plugin/tests/model-command.test.ts +415 -4
  25. package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
  26. package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
  27. package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
  28. package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
  29. package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
  30. package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
  31. package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
  32. package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
  33. package/vendor/hindsight-memory/scripts/subagent_retain.py +103 -7
  34. package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +377 -0
@@ -0,0 +1,397 @@
1
+ {
2
+ "_purpose": "Shared behaviour vectors for the secret redactor. The TypeScript engine (telegram-plugin/secret-detect/redact.ts) and the Python engine (vendor/hindsight-memory/scripts/lib/secret_redact.py) must produce IDENTICAL output for every case here. Asserted by telegram-plugin/tests/secret-detect-write-path.test.ts and vendor/hindsight-memory/scripts/tests/test_secret_redact.py.",
3
+ "_secrets_note": "Every value below is synthetic. Token-shaped strings are assembled from repeated filler so no real credential is committed and GitHub Push Protection has nothing to match.",
4
+ "vectors": [
5
+ {
6
+ "name": "postgres connection string password is masked",
7
+ "input": "connect with postgres://appuser:HunterTwo99@db.internal:5432/prod",
8
+ "expected": "connect with postgres://appuser:[REDACTED:db_uri_password]@db.internal:5432/prod"
9
+ },
10
+ {
11
+ "name": "mysql connection string password is masked",
12
+ "input": "mysql://root:t0psecretpw@127.0.0.1:3306/app",
13
+ "expected": "mysql://root:[REDACTED:db_uri_password]@127.0.0.1:3306/app"
14
+ },
15
+ {
16
+ "name": "mongodb+srv connection string password is masked",
17
+ "input": "MONGO=mongodb+srv://svc:aB3dEf9hJ2@cluster0.example.net/db",
18
+ "expected": "MONGO=mongodb+srv://svc:[REDACTED:db_uri_password]@cluster0.example.net/db"
19
+ },
20
+ {
21
+ "name": "redis connection string password is masked",
22
+ "input": "redis://default:cachePass42@redis.internal:6379/0",
23
+ "expected": "redis://default:[REDACTED:db_uri_password]@redis.internal:6379/0"
24
+ },
25
+ {
26
+ "name": "postgres URI without credentials is untouched",
27
+ "input": "postgres://db.internal:5432/prod is the read replica",
28
+ "expected": "postgres://db.internal:5432/prod is the read replica"
29
+ },
30
+ {
31
+ "name": "already-masked connection string is stable",
32
+ "input": "postgres://appuser:[REDACTED:db_uri_password]@db.internal:5432/prod",
33
+ "expected": "postgres://appuser:[REDACTED:db_uri_password]@db.internal:5432/prod"
34
+ },
35
+ {
36
+ "name": "human-memorable password after a colon is masked",
37
+ "input": "password: FluffyBarnaby1998",
38
+ "expected": "password: [REDACTED:memorable_password]"
39
+ },
40
+ {
41
+ "name": "human-memorable password in prose is masked",
42
+ "input": "the wifi password is MangoTreeHouse77 if you need it",
43
+ "expected": "the wifi password is [REDACTED:memorable_password] if you need it"
44
+ },
45
+ {
46
+ "name": "prefixed password identifier is masked",
47
+ "input": "db_password=Rutherford2024x",
48
+ "expected": "db_password=[REDACTED:memorable_password]"
49
+ },
50
+ {
51
+ "name": "passphrase label is covered",
52
+ "input": "passphrase: CorrectHorse9Battery",
53
+ "expected": "passphrase: [REDACTED:memorable_password]"
54
+ },
55
+ {
56
+ "name": "single-class prose after 'password is' is NOT masked",
57
+ "input": "the password is rotated quarterly by the platform team",
58
+ "expected": "the password is rotated quarterly by the platform team"
59
+ },
60
+ {
61
+ "name": "password policy prose is NOT masked",
62
+ "input": "The password policy requires rotation every 90 days.",
63
+ "expected": "The password policy requires rotation every 90 days."
64
+ },
65
+ {
66
+ "name": "shell variable reference is NOT masked",
67
+ "input": "password: ${DB_PASSWORD}",
68
+ "expected": "password: ${DB_PASSWORD}"
69
+ },
70
+ {
71
+ "name": "angle-bracket placeholder is NOT masked",
72
+ "input": "password=<your-password-here>",
73
+ "expected": "password=<your-password-here>"
74
+ },
75
+ {
76
+ "name": "vault reference is NOT masked",
77
+ "input": "password: vault:postgres/app_password",
78
+ "expected": "password: vault:postgres/app_password"
79
+ },
80
+ {
81
+ "name": "already-masked memorable password is stable",
82
+ "input": "password: [REDACTED:memorable_password]",
83
+ "expected": "password: [REDACTED:memorable_password]"
84
+ },
85
+ {
86
+ "name": "https url credentials are stripped by the url pass",
87
+ "input": "see https://alice:s3cretpw@api.example.com/v1/things",
88
+ "expected": "see https://***@api.example.com/v1/things"
89
+ },
90
+ {
91
+ "name": "sensitive query param is masked",
92
+ "input": "hit https://api.example.com/v1?api_key=abcdef123456&trace=42",
93
+ "expected": "hit https://api.example.com/v1?api_key=***&trace=42"
94
+ },
95
+ {
96
+ "name": "plain url is untouched",
97
+ "input": "docs at https://example.com/guide?page=2",
98
+ "expected": "docs at https://example.com/guide?page=2"
99
+ },
100
+ {
101
+ "name": "ordinary prose is untouched",
102
+ "input": "I told him the answer was probably Gandalf the Grey.",
103
+ "expected": "I told him the answer was probably Gandalf the Grey."
104
+ },
105
+ {
106
+ "name": "empty string passes through",
107
+ "input": "",
108
+ "expected": ""
109
+ },
110
+ {
111
+ "name": "multi-secret line masks every hit",
112
+ "input": "db postgres://u:PineappleRoof8@h/d and wifi password: SparrowKettle31",
113
+ "expected": "db postgres://u:[REDACTED:db_uri_password]@h/d and wifi password: [REDACTED:memorable_password]"
114
+ },
115
+ {
116
+ "name": "anthropic api key is masked",
117
+ "input": "the key is \u0073k-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJ ok",
118
+ "expected": "the key is [REDACTED:anthropic_api_key] ok"
119
+ },
120
+ {
121
+ "name": "anthropic api key adjacent to CJK text is masked",
122
+ "input": "トークンは\u0073k-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJです",
123
+ "expected": "トークンは[REDACTED:anthropic_api_key]です"
124
+ },
125
+ {
126
+ "name": "openai style key is masked",
127
+ "input": "OPENAI sk-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKK",
128
+ "expected": "OPENAI [REDACTED:openai_api_key]"
129
+ },
130
+ {
131
+ "name": "github pat is masked next to Cyrillic text",
132
+ "input": "ключ \u0067hp_AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIк",
133
+ "expected": "ключ [REDACTED:github_pat_classic]к"
134
+ },
135
+ {
136
+ "name": "aws access key id is masked",
137
+ "input": "\u0041KIAAAAABBBBCCCCDDDD is the id",
138
+ "expected": "[REDACTED:aws_access_key] is the id"
139
+ },
140
+ {
141
+ "name": "google api key is masked",
142
+ "input": "\u0041IzaAAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIII",
143
+ "expected": "[REDACTED:google_api_key]"
144
+ },
145
+ {
146
+ "name": "slack bot token is masked",
147
+ "input": "\u0078oxb-111111111111-AAAABBBBCCCCDDDDEEEEFFFF",
148
+ "expected": "[REDACTED:slack_token]"
149
+ },
150
+ {
151
+ "name": "jwt is masked next to accented latin text",
152
+ "input": "jetón eyJAAAABBBBCCCC.DDDDEEEEFFFF.GGGGHHHHIIIIé",
153
+ "expected": "jetón [REDACTED:jwt]é"
154
+ },
155
+ {
156
+ "name": "telegram bot token is masked",
157
+ "input": "bot1234567:AAAABBBBCCCCDDDDEEEEFFFFGGGGHH",
158
+ "expected": "bot[REDACTED:telegram_bot_token_prefixed]"
159
+ },
160
+ {
161
+ "name": "sanctum style token is masked",
162
+ "input": "17|AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJ",
163
+ "expected": "[REDACTED:laravel_sanctum_token]"
164
+ },
165
+ {
166
+ "name": "uppercase env assignment is masked",
167
+ "input": "API_TOKEN=zQ7xVb2nKd9wRt4y",
168
+ "expected": "API_TOKEN=[REDACTED]"
169
+ },
170
+ {
171
+ "name": "uppercase env assignment adjacent to Thai text is masked",
172
+ "input": "กุญ API_TOKEN=zQ7xVb2nKd9wRt4yก",
173
+ "expected": "กุญ API_TOKEN=[REDACTED]"
174
+ },
175
+ {
176
+ "name": "authorization bearer header is masked",
177
+ "input": "Authorization: Bearer AAAABBBBCCCCDDDDEEEEFFFF",
178
+ "expected": "Authorization: Bearer [REDACTED:bearer_auth_header]"
179
+ },
180
+ {
181
+ "name": "lowercase authorization bearer header is masked",
182
+ "input": "authorization: bearer AAAABBBBCCCCDDDDEEEEFFFF",
183
+ "expected": "authorization: bearer [REDACTED:bearer_auth_header]"
184
+ },
185
+ {
186
+ "name": "authorization basic header is masked",
187
+ "input": "Authorization: Basic YWxpY2U6c3VwZXJzZWNyZXQ=",
188
+ "expected": "Authorization: Basic [REDACTED:basic_auth_header]"
189
+ },
190
+ {
191
+ "name": "json secret field is masked",
192
+ "input": "{\"apiKey\": \"zQ7xVb2nKd9wRt4y\"}",
193
+ "expected": "{\"apiKey\": \"[REDACTED:json_secret_field]\"}"
194
+ },
195
+ {
196
+ "name": "cli flag value is masked",
197
+ "input": "run --api-key zQ7xVb2nKd9wRt4y",
198
+ "expected": "run --api-key [REDACTED:cli_flag]"
199
+ },
200
+ {
201
+ "name": "no-break-space (U+00A0) separator after the key still masks",
202
+ "input": "API_TOKEN: zQ7xVb2nKd9wRt4y",
203
+ "expected": "API_TOKEN: [REDACTED]"
204
+ },
205
+ {
206
+ "name": "pem private key block is masked",
207
+ "input": "-----BEGIN RSA PRIVATE KEY-----\nAAAABBBBCCCC\n-----END RSA PRIVATE KEY-----",
208
+ "expected": "[REDACTED:pem_private_key]"
209
+ },
210
+ {
211
+ "name": "sentence-final 'required' after 'password is' is NOT masked",
212
+ "input": "The password is required.",
213
+ "expected": "The password is required."
214
+ },
215
+ {
216
+ "name": "sentence-final 'incorrect' after 'password is' is NOT masked",
217
+ "input": "The password is incorrect.",
218
+ "expected": "The password is incorrect."
219
+ },
220
+ {
221
+ "name": "sentence-final 'unchanged' after 'password is' is NOT masked",
222
+ "input": "Ken confirmed the password is unchanged.",
223
+ "expected": "Ken confirmed the password is unchanged."
224
+ },
225
+ {
226
+ "name": "comma-terminated prose after a password label is NOT masked",
227
+ "input": "password: required, minimum twelve characters, mixed case",
228
+ "expected": "password: required, minimum twelve characters, mixed case"
229
+ },
230
+ {
231
+ "name": "uppercase env vault reference is NOT masked",
232
+ "input": "POSTGRES_PASSWORD: vault:pg/password",
233
+ "expected": "POSTGRES_PASSWORD: vault:pg/password"
234
+ },
235
+ {
236
+ "name": "lowercase env vault reference is NOT masked",
237
+ "input": "postgres_password: vault:pg/password",
238
+ "expected": "postgres_password: vault:pg/password"
239
+ },
240
+ {
241
+ "name": "uppercase env shell placeholder is NOT masked",
242
+ "input": "PASSWORD: ${DB_PASSWORD}",
243
+ "expected": "PASSWORD: ${DB_PASSWORD}"
244
+ },
245
+ {
246
+ "name": "uppercase env angle placeholder is NOT masked",
247
+ "input": "JWT_SECRET=<generate-with-openssl-rand>",
248
+ "expected": "JWT_SECRET=<generate-with-openssl-rand>"
249
+ },
250
+ {
251
+ "name": "api key vault reference is NOT masked",
252
+ "input": "ANTHROPIC_API_KEY: vault:anthropic/api_key",
253
+ "expected": "ANTHROPIC_API_KEY: vault:anthropic/api_key"
254
+ },
255
+ {
256
+ "name": "process.env reference is NOT masked",
257
+ "input": "const API_KEY = process.env.ANTHROPIC_API_KEY",
258
+ "expected": "const API_KEY = process.env.ANTHROPIC_API_KEY"
259
+ },
260
+ {
261
+ "name": "cli flag placeholder in help text is NOT masked",
262
+ "input": "--token <value> the API token",
263
+ "expected": "--token <value> the API token"
264
+ },
265
+ {
266
+ "name": "changeme placeholder is NOT masked",
267
+ "input": "DB_PASSWORD=changeme-in-production",
268
+ "expected": "DB_PASSWORD=changeme-in-production"
269
+ },
270
+ {
271
+ "name": "json doc string is NOT masked",
272
+ "input": "{\"token\": \"the bearer token to use\"}",
273
+ "expected": "{\"token\": \"the bearer token to use\"}"
274
+ },
275
+ {
276
+ "name": "ssh public key is NOT masked",
277
+ "input": "ssh-rsa AAAAB3NzaAAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH deploy@host",
278
+ "expected": "ssh-rsa AAAAB3NzaAAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH deploy@host"
279
+ },
280
+ {
281
+ "name": "dsn password containing an unencoded at-sign is fully masked",
282
+ "input": "postgres://appuser:p@ssW0rd123@db.internal:5432/prod",
283
+ "expected": "postgres://appuser:[REDACTED:db_uri_password]@db.internal:5432/prod"
284
+ },
285
+ {
286
+ "name": "memorable password at the end of a sentence is masked",
287
+ "input": "The wifi password is MangoTreeHouse77.",
288
+ "expected": "The wifi password is [REDACTED:memorable_password]"
289
+ },
290
+ {
291
+ "name": "inert angle wrapper does NOT smuggle a value past env_key_value",
292
+ "input": "POSTGRES_PASSWORD: <a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb>",
293
+ "expected": "POSTGRES_PASSWORD: [REDACTED]"
294
+ },
295
+ {
296
+ "name": "inert angle wrapper does NOT smuggle a value past json_secret_field",
297
+ "input": "{\"token\": \"<tok_Zq7xVb2nKd9wRt4y>\"}",
298
+ "expected": "{\"token\": \"[REDACTED:json_secret_field]\"}"
299
+ },
300
+ {
301
+ "name": "inert angle wrapper does NOT smuggle a value past cli_flag",
302
+ "input": "--token <a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb>",
303
+ "expected": "--token [REDACTED:cli_flag]"
304
+ },
305
+ {
306
+ "name": "handlebars wrapper does NOT smuggle a value through",
307
+ "input": "POSTGRES_PASSWORD: {{a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb}}",
308
+ "expected": "POSTGRES_PASSWORD: [REDACTED]"
309
+ },
310
+ {
311
+ "name": "handlebars wrapper does NOT smuggle a value past cli_flag",
312
+ "input": "--token {{tok_Zq7xVb2nKd9wRt4y}}",
313
+ "expected": "--token [REDACTED:cli_flag]"
314
+ },
315
+ {
316
+ "name": "vault: prefix does NOT smuggle a trailing value through",
317
+ "input": "POSTGRES_PASSWORD: vault:pg/password<a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb>",
318
+ "expected": "POSTGRES_PASSWORD: [REDACTED]"
319
+ },
320
+ {
321
+ "name": "vault: key slot does NOT smuggle a value through",
322
+ "input": "{\"token\": \"vault:pg/a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb\"}",
323
+ "expected": "{\"token\": \"[REDACTED:json_secret_field]\"}"
324
+ },
325
+ {
326
+ "name": "[REDACTED] marker prefix does NOT smuggle a trailing value through",
327
+ "input": "POSTGRES_PASSWORD: [REDACTED]a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb",
328
+ "expected": "POSTGRES_PASSWORD: [REDACTED]"
329
+ },
330
+ {
331
+ "name": "[REDACTED:rule] marker prefix does NOT smuggle a trailing value through",
332
+ "input": "--token [REDACTED:cli_flag]a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb",
333
+ "expected": "--token [REDACTED:cli_flag]"
334
+ },
335
+ {
336
+ "name": "process.env prefix does NOT smuggle a trailing value through",
337
+ "input": "POSTGRES_PASSWORD: process.env.a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb",
338
+ "expected": "POSTGRES_PASSWORD: [REDACTED]"
339
+ },
340
+ {
341
+ "name": "changeme prefix does NOT smuggle a trailing value through",
342
+ "input": "{\"token\": \"changeme-a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb\"}",
343
+ "expected": "{\"token\": \"[REDACTED:json_secret_field]\"}"
344
+ },
345
+ {
346
+ "name": "todo prefix does NOT smuggle a trailing value through",
347
+ "input": "--token todo_a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb",
348
+ "expected": "--token [REDACTED:cli_flag]"
349
+ },
350
+ {
351
+ "name": "handlebars placeholder is NOT masked",
352
+ "input": "POSTGRES_PASSWORD: {{ db_password }}",
353
+ "expected": "POSTGRES_PASSWORD: {{ db_password }}"
354
+ },
355
+ {
356
+ "name": "os.environ subscript reference is NOT masked",
357
+ "input": "API_KEY = os.environ[\"ANTHROPIC_API_KEY\"]",
358
+ "expected": "API_KEY = os.environ[\"ANTHROPIC_API_KEY\"]"
359
+ },
360
+ {
361
+ "name": "import.meta.env reference is NOT masked",
362
+ "input": "VITE_API_KEY = import.meta.env.VITE_API_KEY",
363
+ "expected": "VITE_API_KEY = import.meta.env.VITE_API_KEY"
364
+ },
365
+ {
366
+ "name": "already-masked env value is stable under a second pass",
367
+ "input": "POSTGRES_PASSWORD: [REDACTED]",
368
+ "expected": "POSTGRES_PASSWORD: [REDACTED]"
369
+ },
370
+ {
371
+ "name": "already-masked rule-tagged cli flag is stable under a second pass",
372
+ "input": "--token [REDACTED:cli_flag]",
373
+ "expected": "--token [REDACTED:cli_flag]"
374
+ },
375
+ {
376
+ "name": "bare todo placeholder is NOT masked",
377
+ "input": "POSTGRES_PASSWORD: todo-before-launch",
378
+ "expected": "POSTGRES_PASSWORD: todo-before-launch"
379
+ },
380
+ {
381
+ "name": "single-class 16-char run in an angle wrapper is still masked",
382
+ "input": "POSTGRES_PASSWORD: <abcdefghijklmnop>",
383
+ "expected": "POSTGRES_PASSWORD: [REDACTED]"
384
+ },
385
+ {
386
+ "name": "single-class 16-char run in a handlebars wrapper is still masked",
387
+ "input": "--token {{abcdefghijklmnop}}",
388
+ "expected": "--token [REDACTED:cli_flag]"
389
+ },
390
+ {
391
+ "name": "a 10-letter word inside a placeholder is NOT masked",
392
+ "input": "DB_PASSWORD=changeme-in-production",
393
+ "expected": "DB_PASSWORD=changeme-in-production"
394
+ }
395
+ ],
396
+ "_coverage": "Two classes of vector live here. (a) The IMPERATIVE gates that cannot be generated: the entropy floors, the inert-value list, the memorable-password character-class rule, the connection-URI scanner. (b) The GENERATED pattern table itself -- one vector per credential family, several of them deliberately ADJACENT to non-ASCII text. (b) exists because #3982's review found the two engines forking on Unicode word boundaries (Python re is Unicode-aware, JS RegExp without /u is not) while the byte-compare parity guard stayed green: the SOURCES were identical, the BEHAVIOUR was not, and no vector touched a generated rule. Keep at least one non-ASCII-adjacent vector per new family. Every credential here is synthetic. JSON has no runtime string concatenation, so token-shaped prefixes are broken up with \\uXXXX escapes instead -- json.load / JSON.parse restore the intended bytes, while the file on disk holds no contiguous token literal (CLAUDE.md 'Secrets in tests', scripts/check-no-pii-secrets.mjs, and GitHub Push Protection all require that). The WRAPPER vectors (#3982 review MAJOR 7) pin the other half of the inert gate: a value is skipped only when the WHOLE value is inert, so an inert-looking prefix or bracket pair left around a real credential must NOT suppress the mask."
397
+ }
@@ -3,11 +3,24 @@
3
3
 
4
4
  Delegated (sub-agent / Task-tool) work is the biggest systematic memory hole:
5
5
  the main-session Stop retain only ever reads the parent ``transcript_path``, so
6
- a worker's hours of process work — the paths it touched, the commands that
7
- worked, the dead ends — reach memory only as the terse final report the parent
6
+ a worker's hours of reasoning — the constraints it discovered, the structural
7
+ dead ends it ruled out — reach memory only as the terse final report the parent
8
8
  transcript captures. This hook closes that hole by retaining a bounded window of
9
9
  the *sidechain* transcript when a sub-agent terminates.
10
10
 
11
+ Scope (Ken, 2026-07-29): LEARNINGS, not raw transcripts and tools. The window is
12
+ retained on the TEXT-ONLY formatting path (``run_subagent_retain`` forces
13
+ ``retainToolCalls = False`` on its config copy), so what reaches memory is the
14
+ sub-agent's own prose — reasoning, findings, final report — and NOT tool_use
15
+ inputs, tool_result bodies, file contents or diffs.
16
+
17
+ The text-only path cannot format to nothing here: the volume gate already
18
+ requires ``MIN_HUMAN_TURNS`` GENUINE human turns (tool_result-only user messages
19
+ are explicitly not counted, ``count_human_turns``), and every such turn is a
20
+ plain-string user message that ``_extract_text_content`` returns verbatim. So a
21
+ window that clears the gate always carries at least its instruction turns, and
22
+ ``build_retain_payload`` never returns None on this path for volume reasons.
23
+
11
24
  Probe result (Claude Code 2.1.215, PR5 Task 0 — recorded in the PR body):
12
25
  the ``SubagentStop`` hook input carries BOTH the parent ``transcript_path`` AND
13
26
  a first-class ``agent_transcript_path`` pointing straight at the sidechain
@@ -62,8 +75,11 @@ from lib.pacing import inflight_lock
62
75
  # stays byte-identical to the main path where it matters (dedup ids, formatting).
63
76
  from retain import build_retain_payload, read_transcript
64
77
 
65
- # Retain the last N human turns of the sidechain. Tool-result bodies inside the
66
- # window are truncated by _extract_message_blocks (content.py) already.
78
+ # Retain the last N human turns of the sidechain. The window is formatted on the
79
+ # TEXT-ONLY path (``retainToolCalls`` is forced False for the sidechain — see
80
+ # ``run_subagent_retain``), so tool_use inputs and tool_result bodies are dropped
81
+ # entirely rather than passed through / truncated. ``_extract_message_blocks`` is
82
+ # still imported here, but only for the volume gate's char count.
67
83
  SIDECHAIN_WINDOW_TURNS = 40
68
84
 
69
85
  # Volume gate floors — SubagentStop fires for every Task, so skip trivial forks.
@@ -256,6 +272,14 @@ def non_tool_result_char_count(messages: list, stop_at: int | None = None) -> in
256
272
  the floor even if it emitted a large tool_result, while a real worker's
257
273
  commands and decisions count.
258
274
 
275
+ KNOWN MISMATCH (accepted, tracked as a follow-up): this counts ``tool_use``
276
+ inputs, but those are no longer RETAINED — the sidechain payload is built on
277
+ the text-only path. So the gate can clear on tool volume that contributes
278
+ nothing to the stored memory. It cannot produce an EMPTY retain (the
279
+ ``MIN_HUMAN_TURNS`` floor guarantees prose-bearing user turns — see the
280
+ module docstring), so this is a precision issue in the gate, not a
281
+ correctness bug. Tightening it to count only text is a separate change.
282
+
259
283
  ``stop_at`` (review finding 4 — early short-circuit): return as soon as the
260
284
  running total reaches this many chars. The gate only needs to know whether
261
285
  the floor is CLEARED, not the exact size — so on a large worker transcript
@@ -396,9 +420,32 @@ def run_subagent_retain(hook_input: dict) -> dict:
396
420
  # main-session retains: reuse retain.py's ``slice_document_id`` recipe via a
397
421
  # composite session key so (a) re-fires of the SAME sub-agent window upsert
398
422
  # server-side, and (b) it never collides with the parent's own
399
- # ``{session_id}-r...`` documents. Client-side diffing against the parent's
400
- # final-report retain is deliberately NOT attempted — overlap is fine, and
401
- # hindsight's consolidation dedups (design item 4).
423
+ # ``{session_id}-r...`` documents.
424
+ #
425
+ # Client-side diffing against the parent's final-report retain is still NOT
426
+ # attempted, but NOT because "hindsight's consolidation dedups" — the
427
+ # original claim here (design item 4) was FALSE and is the assumption that
428
+ # licensed the sidechain volume. Verified against the engine source
429
+ # (upstream image ``ghcr.io/vectorize-io/hindsight``):
430
+ # * The only SEMANTIC dedup lives in
431
+ # ``hindsight_api/engine/consolidation/consolidator.py`` and is a guard
432
+ # on the consolidator's OWN OUTPUT: ``_dedup_adjudicate`` probes a newly
433
+ # created/updated ``observation`` against existing ones, passing the
434
+ # literal fact-type list ``["observation"]`` to
435
+ # ``retrieve_semantic_bm25_combined``.
436
+ # * ``world`` and ``experience`` are the raw extracted facts that FEED the
437
+ # consolidator (it selects ``fact_type IN ('experience', 'world')`` for
438
+ # unconsolidated rows) — they never traverse the observation dedup path.
439
+ # * Grepping ``hindsight_api/engine/retain/*.py`` for dedup finds only
440
+ # content-hash CHUNK dedup (``chunk_storage.compute_chunk_hash``), i.e.
441
+ # byte-identical-chunk skipping. There is no semantic dedup on the
442
+ # retain path at all.
443
+ # So overlap between a sidechain retain and the parent's own retain persists
444
+ # as extra ``world``/``experience`` rows forever. Volume control has to come
445
+ # from retaining LESS (see ``retainToolCalls`` below), not from a downstream
446
+ # dedup that does not exist. Diffing is still skipped here because the
447
+ # deterministic document_id already makes re-fires of the SAME window upsert,
448
+ # which is the duplicate class this path can actually create.
402
449
  sub_session_id = f"{session_id}-sub-{agent_id}"
403
450
 
404
451
  # Sidechain tags + a topic-friendly parent link. Reuse the config-driven tag
@@ -407,6 +454,55 @@ def run_subagent_retain(hook_input: dict) -> dict:
407
454
  # (recallTagWeights); ``parent_session:<id>`` lets a fresh session pull a
408
455
  # worker's process facts by parent.
409
456
  sub_config = dict(config)
457
+
458
+ # Learnings, not raw transcripts and tools (Ken, 2026-07-29): "I don't want
459
+ # sub-agents' transcripts but definitely their learnings should be captured
460
+ # but not raw transcripts and tools."
461
+ #
462
+ # With ``retainToolCalls`` on, ``build_retain_payload`` formats the window
463
+ # via ``_prepare_json_transcript`` → ``_extract_message_blocks``, which emits
464
+ # every ``tool_use.input`` verbatim (an entire ``Write.content``, a full
465
+ # ``Edit`` diff, a full ``Bash.command``) plus every ``tool_result`` at up to
466
+ # 2,000 chars per block.
467
+ #
468
+ # The cost mechanism is DOCUMENT FAN-OUT, not one oversized payload: a large
469
+ # payload is not truncated, it is CAPPED and SPLIT by
470
+ # ``lib.retain_split.split_retain_content`` at ``retain_content_limit()``
471
+ # (33,000 chars on the shipped inputs) into ``{base}-p{i}of{n}`` parts
472
+ # (``client.py`` ~:235). Real sidechain retains have been observed splitting
473
+ # into 38 parts / 1,022 messages, and every part is separately chunked and
474
+ # LLM-extracted into ``world``/``experience`` rows. Shrinking the content is
475
+ # therefore the lever that reduces rows.
476
+ #
477
+ # Forcing it OFF for the sidechain path routes the window through
478
+ # ``_prepare_text_transcript`` → ``_extract_text_content``, keeping assistant
479
+ # ``text`` blocks and channel-message tool_use text. Measured on real fleet
480
+ # sidechains: 5.5x smaller over 30 sampled transcripts (2,012,376 → 367,796
481
+ # chars), and independently 6.4x over the 84 most recent GATE-PASSING ones
482
+ # (26,278,261 → 4,085,678 chars) — of which 0 formatted to empty and 0 came
483
+ # out under 500 chars.
484
+ #
485
+ # ACCEPTED LOSS — this is not a clean "tool noise only" filter.
486
+ # ``_extract_text_content`` also drops image blocks, ALL ``tool_result``
487
+ # content, and sub-agent Task report bodies. So a fact that existed ONLY in
488
+ # tool output is unrecoverable: if a query returned ``43442`` and the agent
489
+ # merely replied "checked, it's the backlog", the number is gone. That trade
490
+ # is deliberate and is what Ken asked for; sub-agent prose was separately
491
+ # measured to carry durable learnings at essentially the main-session rate
492
+ # (5.24% vs 6.87%), so the learnings themselves survive.
493
+ #
494
+ # Mid-session safety: ``slice_document_id`` derives the BASE id from the
495
+ # slice's first/last uuids, not from the formatted text, so a re-fire of the
496
+ # same window still targets the same base id. Note the part SUFFIX embeds the
497
+ # part total, so a document that used to split into n parts and now splits
498
+ # into m < n leaves parts m+1..n in place — they are not overwritten and not
499
+ # duplicated. Historical sidechain rows are untouched by this change; this
500
+ # fixes INTAKE going forward only.
501
+ #
502
+ # Deliberately set on the COPY, not on ``config``: the parent session's own
503
+ # Stop retain keeps whatever the operator configured.
504
+ sub_config["retainToolCalls"] = False
505
+
410
506
  base_tags = list(config.get("retainTags") or [])
411
507
  extra_tags = ["sidechain", f"parent_session:{session_id}"]
412
508
  if agent_type: