switchroom 0.19.38 → 0.19.40

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 (45) hide show
  1. package/dist/agent-scheduler/index.js +10 -1
  2. package/dist/auth-broker/index.js +15 -6
  3. package/dist/cli/notion-write-pretool.mjs +10 -1
  4. package/dist/cli/switchroom.js +1141 -566
  5. package/dist/host-control/main.js +220 -8
  6. package/dist/vault/approvals/kernel-server.js +15 -6
  7. package/dist/vault/broker/server.js +15 -6
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +10 -0
  10. package/telegram-plugin/dist/bridge/bridge.js +8 -0
  11. package/telegram-plugin/dist/gateway/gateway.js +450 -206
  12. package/telegram-plugin/dist/server.js +8 -0
  13. package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
  14. package/telegram-plugin/gateway/compaction-marker.ts +84 -0
  15. package/telegram-plugin/gateway/gateway.ts +3 -3
  16. package/telegram-plugin/gateway/handback-preturn-signal.ts +16 -0
  17. package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
  18. package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
  19. package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
  20. package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
  21. package/telegram-plugin/gateway/stream-render.ts +277 -18
  22. package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
  23. package/telegram-plugin/hooks/hooks.json +11 -0
  24. package/telegram-plugin/hooks/tool-label-pretool.mjs +119 -1
  25. package/telegram-plugin/session-tail.ts +20 -0
  26. package/telegram-plugin/silence-poke.ts +28 -0
  27. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +19 -12
  28. package/telegram-plugin/tests/fixtures/pretool-main-2.1.219.json +15 -0
  29. package/telegram-plugin/tests/fixtures/pretool-subagent-2.1.219.json +16 -0
  30. package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
  31. package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
  32. package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
  33. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
  34. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
  35. package/telegram-plugin/tests/queued-card-surface.test.ts +262 -0
  36. package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
  37. package/telegram-plugin/tests/sidechain-label-filter-pretool.test.ts +272 -0
  38. package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
  39. package/vendor/hindsight-memory/scripts/lib/client.py +7 -4
  40. package/vendor/hindsight-memory/scripts/lib/config.py +151 -0
  41. package/vendor/hindsight-memory/scripts/retain.py +19 -15
  42. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +14 -1
  43. package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +192 -3
  44. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +18 -2
  45. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +57 -4
@@ -0,0 +1,222 @@
1
+ /**
2
+ * #4058 — mid-turn auto-compaction must not trip the 300s silence fallback.
3
+ *
4
+ * The bug: during compaction the model emits zero output and runs zero tools,
5
+ * so the gateway saw pure silence and fired the framework fallback ("⚠️ no
6
+ * output for 5 min — the framework ended that stalled turn") on a healthy
7
+ * turn that completed correctly right after compaction. Observed live:
8
+ * ~1m50s of tools + ~3m25s compacting = 304s silence → false fire.
9
+ *
10
+ * The fix: the PreCompact hook writes a compaction marker; silence-poke gets
11
+ * an `isCompactionInFlight` dep consulted in the SAME `underCeiling` defer
12
+ * branch as the #1292/#3519 in-flight-tool defers; the transcript's
13
+ * `compact_boundary` record (compaction END) clears the marker and counts as
14
+ * production. These tests drive the REAL tick loop and assert outcomes:
15
+ * - a compaction gap past 300s but under the hard ceiling → NO fire;
16
+ * - a genuinely-wedged turn (no compaction) → STILL fires at 300s;
17
+ * - a compaction stuck past the hard ceiling → STILL fires (bounded defer).
18
+ */
19
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
20
+ import { mkdtempSync, rmSync, writeFileSync, existsSync, utimesSync } from 'node:fs'
21
+ import { tmpdir } from 'node:os'
22
+ import { join } from 'node:path'
23
+ import { execFileSync } from 'node:child_process'
24
+ import { projectTranscriptLine } from '../session-tail.js'
25
+ import { applySilencePokeSessionEvent } from '../gateway/silence-poke-session-event.js'
26
+ import {
27
+ COMPACTION_MARKER_FILE,
28
+ readCompactionMarkerAgeMs,
29
+ removeCompactionMarker,
30
+ } from '../gateway/compaction-marker.js'
31
+ import * as silencePoke from '../silence-poke.js'
32
+ import * as pendingProgress from '../pending-work-progress.js'
33
+ import {
34
+ startTurn,
35
+ __tickForTests,
36
+ __setDepsForTests,
37
+ __getStateForTests,
38
+ __resetAllForTests,
39
+ DEFAULT_THRESHOLDS,
40
+ type SilencePokeMetric,
41
+ type FrameworkFallbackContext,
42
+ } from '../silence-poke.js'
43
+
44
+ const HARD_CEILING = 900_000 // SILENCE_FALLBACK_HARD_MS default
45
+
46
+ interface TestFixtures {
47
+ emitted: SilencePokeMetric[]
48
+ fallbacks: FrameworkFallbackContext[]
49
+ }
50
+
51
+ function setupDeps(opts?: {
52
+ isCompactionInFlight?: (key: string) => boolean
53
+ isLegitimatelyWorking?: (key: string) => boolean
54
+ }): TestFixtures {
55
+ const fixtures: TestFixtures = { emitted: [], fallbacks: [] }
56
+ __setDepsForTests({
57
+ emitMetric: (e) => fixtures.emitted.push(e),
58
+ onFrameworkFallback: (ctx) => { fixtures.fallbacks.push(ctx) },
59
+ thresholdsMs: { ...DEFAULT_THRESHOLDS, fallbackHardCeiling: HARD_CEILING },
60
+ // Mirror production wiring: the callback path is active (liveness-wiring
61
+ // always wires isLegitimatelyWorking), returning false = "no tool work".
62
+ isLegitimatelyWorking: opts?.isLegitimatelyWorking ?? (() => false),
63
+ ...(opts?.isCompactionInFlight != null
64
+ ? { isCompactionInFlight: opts.isCompactionInFlight }
65
+ : {}),
66
+ })
67
+ return fixtures
68
+ }
69
+
70
+ beforeEach(() => {
71
+ __resetAllForTests()
72
+ delete process.env.SWITCHROOM_DISABLE_SILENCE_POKE
73
+ })
74
+
75
+ afterEach(() => {
76
+ __resetAllForTests()
77
+ })
78
+
79
+ describe('silence-poke — #4058 compaction defer (outcome)', () => {
80
+ it('a compaction gap longer than 300s but under the hard ceiling does NOT fire the fallback', () => {
81
+ // Real observed shape: tools until t=110s, compaction t=110s..415s (305s
82
+ // of pure silence — past the 300s window), turn completes after.
83
+ let compacting = false
84
+ const fx = setupDeps({ isCompactionInFlight: () => compacting })
85
+ startTurn('chat:1', 0)
86
+ // Tools produced feed renders until 110s → production reset at 110s.
87
+ silencePoke.noteProduction('chat:1', 110_000)
88
+ compacting = true // PreCompact hook fired
89
+ __tickForTests(300_000) // 190s silent — under threshold anyway
90
+ __tickForTests(415_000) // 305s silent — WOULD fire without the fix
91
+ __tickForTests(500_000) // 390s silent — still compacting
92
+ expect(fx.fallbacks).toHaveLength(0)
93
+ expect(fx.emitted).toHaveLength(0)
94
+ // Compaction ends: the compact_boundary handler clears the marker AND
95
+ // counts the boundary as production (fresh window for the resumed turn).
96
+ compacting = false
97
+ silencePoke.noteProduction('chat:1', 505_000)
98
+ __tickForTests(510_000)
99
+ expect(fx.fallbacks).toHaveLength(0) // healthy turn: never fired
100
+ })
101
+
102
+ it('a genuinely-wedged turn with no compaction STILL fires at 300s', () => {
103
+ const fx = setupDeps({ isCompactionInFlight: () => false })
104
+ startTurn('chat:2', 0)
105
+ __tickForTests(300_000)
106
+ expect(fx.fallbacks).toHaveLength(1)
107
+ expect(fx.emitted.at(-1)).toMatchObject({ kind: 'silence_fallback_sent' })
108
+ })
109
+
110
+ it('a turn that goes silent AFTER compaction ends still fires one window later', () => {
111
+ let compacting = true
112
+ const fx = setupDeps({ isCompactionInFlight: () => compacting })
113
+ startTurn('chat:3', 0)
114
+ __tickForTests(310_000)
115
+ expect(fx.fallbacks).toHaveLength(0) // deferred while compacting
116
+ compacting = false
117
+ silencePoke.noteProduction('chat:3', 320_000) // compact_boundary landed
118
+ __tickForTests(325_000)
119
+ expect(fx.fallbacks).toHaveLength(0)
120
+ // …but the model never resumes → real wedge → fires 300s after boundary.
121
+ __tickForTests(620_000)
122
+ expect(fx.fallbacks).toHaveLength(1)
123
+ })
124
+
125
+ it('a compaction stuck past the hard ceiling STILL fires (bounded defer)', () => {
126
+ const fx = setupDeps({ isCompactionInFlight: () => true })
127
+ startTurn('chat:4', 0)
128
+ __tickForTests(899_000)
129
+ expect(fx.fallbacks).toHaveLength(0) // deferred under the ceiling
130
+ __tickForTests(900_000) // silence ≥ fallbackHardCeiling → defer expires
131
+ expect(fx.fallbacks).toHaveLength(1)
132
+ })
133
+
134
+ it('absent dep (legacy fixtures) leaves behaviour unchanged — fires at 300s', () => {
135
+ const fx = setupDeps()
136
+ startTurn('chat:5', 0)
137
+ __tickForTests(300_000)
138
+ expect(fx.fallbacks).toHaveLength(1)
139
+ })
140
+ })
141
+
142
+ describe('session-tail — compact_boundary projection', () => {
143
+ it('projects a real transcript compact_boundary line', () => {
144
+ // Verbatim shape from a live agent transcript (trimmed to the fields the
145
+ // projection reads).
146
+ const line = JSON.stringify({
147
+ parentUuid: null,
148
+ isSidechain: false,
149
+ type: 'system',
150
+ subtype: 'compact_boundary',
151
+ content: 'Conversation compacted',
152
+ level: 'info',
153
+ compactMetadata: { trigger: 'auto', preTokens: 283797, postTokens: 224551, durationMs: 111959 },
154
+ })
155
+ expect(projectTranscriptLine(line)).toEqual([
156
+ { kind: 'compact_boundary', trigger: 'auto', compactDurationMs: 111959 },
157
+ ])
158
+ })
159
+
160
+ it('tolerates missing compactMetadata', () => {
161
+ const line = JSON.stringify({ type: 'system', subtype: 'compact_boundary', content: 'Conversation compacted' })
162
+ expect(projectTranscriptLine(line)).toEqual([
163
+ { kind: 'compact_boundary', trigger: null, compactDurationMs: null },
164
+ ])
165
+ })
166
+ })
167
+
168
+ describe('compaction-marker + session-event wiring', () => {
169
+ let dir: string
170
+ const envBefore = process.env.TELEGRAM_STATE_DIR
171
+
172
+ beforeEach(() => {
173
+ dir = mkdtempSync(join(tmpdir(), 'sp-compact-'))
174
+ process.env.TELEGRAM_STATE_DIR = dir
175
+ })
176
+
177
+ afterEach(() => {
178
+ if (envBefore != null) process.env.TELEGRAM_STATE_DIR = envBefore
179
+ else delete process.env.TELEGRAM_STATE_DIR
180
+ rmSync(dir, { recursive: true, force: true })
181
+ })
182
+
183
+ it('readCompactionMarkerAgeMs: absent → null; present → mtime age', () => {
184
+ expect(readCompactionMarkerAgeMs(dir, 1_000)).toBeNull()
185
+ const p = join(dir, COMPACTION_MARKER_FILE)
186
+ writeFileSync(p, '{"ts":1}\n')
187
+ const at = new Date(Date.now() - 10_000)
188
+ utimesSync(p, at, at)
189
+ const age = readCompactionMarkerAgeMs(dir)
190
+ expect(age).not.toBeNull()
191
+ expect(age!).toBeGreaterThanOrEqual(9_000)
192
+ expect(age!).toBeLessThan(60_000)
193
+ removeCompactionMarker(dir)
194
+ expect(readCompactionMarkerAgeMs(dir)).toBeNull()
195
+ removeCompactionMarker(dir) // idempotent
196
+ })
197
+
198
+ it('compact_boundary session event clears the marker and resets the silence clock', () => {
199
+ writeFileSync(join(dir, COMPACTION_MARKER_FILE), '{"ts":1}\n')
200
+ startTurn('c:9', 0)
201
+ setupDeps()
202
+ applySilencePokeSessionEvent(silencePoke, pendingProgress, 'c:9', {
203
+ kind: 'compact_boundary',
204
+ trigger: 'auto',
205
+ compactDurationMs: 111_959,
206
+ })
207
+ expect(existsSync(join(dir, COMPACTION_MARKER_FILE))).toBe(false)
208
+ // Clock reset: lastOutboundAt stamped by noteProduction.
209
+ expect(__getStateForTests('c:9')?.lastOutboundAt).not.toBeNull()
210
+ })
211
+
212
+ it('PreCompact hook writes the marker from its stdin payload', () => {
213
+ const hook = join(__dirname, '..', 'hooks', 'compaction-marker-precompact.mjs')
214
+ execFileSync('node', [hook], {
215
+ env: { ...process.env, TELEGRAM_STATE_DIR: dir },
216
+ input: JSON.stringify({ session_id: 's-1', trigger: 'auto', hook_event_name: 'PreCompact' }),
217
+ })
218
+ const age = readCompactionMarkerAgeMs(dir)
219
+ expect(age).not.toBeNull()
220
+ expect(age!).toBeLessThan(30_000)
221
+ })
222
+ })
@@ -220,15 +220,18 @@ class HindsightClient:
220
220
  tags: Optional[list] = None,
221
221
  timeout: int = 15,
222
222
  async_processing: bool = True,
223
- observation_scopes: Optional[str] = None,
223
+ observation_scopes: Optional[object] = None,
224
224
  ) -> dict:
225
225
  """Retain content into a bank's memory.
226
226
 
227
227
  ``observation_scopes`` is a per-row Hindsight field controlling which
228
228
  observation scope consolidation writes this item's observations into.
229
229
  ``"shared"`` puts them in ONE global untagged scope instead of a scope
230
- per tag. ``None`` (the default) omits the field entirely, so the
231
- engine's own default stands and the wire body is byte-identical to a
230
+ per tag; an explicit ``list[list[str]]`` (e.g. ``[["lesson"]]``, what
231
+ the ``curated`` strategy emits) declares the exact scope(s) to
232
+ consolidate into, JSON-serialised onto the wire as a nested array.
233
+ ``None`` (a falsy value) omits the field entirely, so the engine's own
234
+ default stands and the wire body is byte-identical to a
232
235
  pre-``observation_scopes`` client.
233
236
 
234
237
  By default posts with ``async=true`` so the server processes extraction
@@ -336,7 +339,7 @@ class HindsightClient:
336
339
  tags: Optional[list],
337
340
  timeout: int,
338
341
  async_processing: bool,
339
- observation_scopes: Optional[str] = None,
342
+ observation_scopes: Optional[object] = None,
340
343
  ) -> dict:
341
344
  """POST exactly one retain item. Raises on any HTTP/transport error."""
342
345
  path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories"
@@ -6,8 +6,30 @@ variable overrides. Full config schema matching Openclaw's 30+ options.
6
6
 
7
7
  import json
8
8
  import os
9
+ import re
9
10
  import sys
10
11
 
12
+ #: Switchroom-local: strategies for the per-row curated observation scope.
13
+ #: ``curated`` (default) strips volatile provenance tags from the consolidation
14
+ #: scope, keeping stable semantic ones; ``shared`` pools every retain into one
15
+ #: bank-wide untagged scope; ``combined`` / ``off`` emit no per-row scope (the
16
+ #: pre-feature engine default). See ``compute_observation_scopes``.
17
+ OBSERVATION_SCOPE_STRATEGIES = ("curated", "shared", "combined", "off")
18
+
19
+ #: Tag patterns treated as VOLATILE per-session provenance by ``curated``:
20
+ #: ``parent_session:<id>`` and a bare RFC-4122 UUID (what the ``{session_id}``
21
+ #: retain tag resolves to on the parent path). The UUID pattern also matches
22
+ #: the sidechain-derived ``<uuid>-sub-<agent_id>`` form, because
23
+ #: ``subagent_retain.py`` sets ``sub_session_id = f"{session_id}-sub-{agent_id}"``
24
+ #: and the ``{session_id}`` retain tag resolves to that — a per-invocation-unique
25
+ #: value that, if left in scope, defeats cross-session dedup on the dominant
26
+ #: (sidechain) observation path. A tag matching ANY of these is dropped from the
27
+ #: consolidation scope but LEFT on the source fact.
28
+ DEFAULT_VOLATILE_SCOPE_PATTERNS = (
29
+ r"^parent_session:",
30
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(-sub-.+)?$",
31
+ )
32
+
11
33
  DEFAULTS = {
12
34
  # Recall
13
35
  "autoRecall": True,
@@ -181,6 +203,37 @@ DEFAULTS = {
181
203
  # through `defaults.memory.observation_scopes`) via
182
204
  # HINDSIGHT_OBSERVATION_SCOPES, exported ONLY when the operator opted in.
183
205
  "observationScopes": None,
206
+ # Switchroom hindsight-leverage — CURATED observation scopes (default ON).
207
+ # `combined` (the pre-feature engine default) makes consolidation dedup an
208
+ # observation only against others carrying the IDENTICAL tag set. Because
209
+ # switchroom stamps volatile per-session provenance on every retain
210
+ # (`{session_id}` → a bare UUID tag, and `parent_session:<uuid>` /
211
+ # `sidechain` / `agent_type:*` on sub-agent retains), that turns every
212
+ # session into its own dedup island — cross-session dedup never happens.
213
+ #
214
+ # `curated` (default) strips ONLY the volatile provenance tags
215
+ # (`observationScopeVolatilePatterns`) from the CONSOLIDATION scope, keeping
216
+ # the stable semantic ones (`lesson`, `anti-pattern`, `agent_type:*`,
217
+ # `sidechain`, entities). The stripped tags STAY on the source fact (still
218
+ # queryable / recall-filterable) — only the observation's dedup scope is
219
+ # narrowed. Non-empty stable set → the explicit scope `[[stable…]]`; empty
220
+ # stable set (e.g. a retain tagged only with a session id) → `"shared"`,
221
+ # one bank-wide untagged scope. This preserves recall tag-weighting on the
222
+ # observation layer (the kept tags still ride the observation) while giving
223
+ # cross-session dedup. Docs: https://hindsight.vectorize.io/developer/api/retain
224
+ # ("shared") and /developer/observations (scope isolation via all_strict).
225
+ #
226
+ # Opt-out: set `observationScopeStrategy` to `combined` (or `off`) — both
227
+ # emit no per-row scope, restoring the exact pre-feature engine default. A
228
+ # manually-pinned `observationScopes` (memory.observation_scopes) STILL wins
229
+ # over the strategy, so operators who set `per_tag` / `all_combinations` /
230
+ # `shared` keep that behaviour unchanged.
231
+ "observationScopeStrategy": "curated",
232
+ # Tag patterns treated as VOLATILE (stripped from the curated scope, kept on
233
+ # the source fact). Defaults: `parent_session:<id>` and a bare RFC-4122
234
+ # UUID (what `{session_id}` resolves to). Overridable via settings.json /
235
+ # ~/.hindsight/claude-code.json for a bank with an unusual provenance tag.
236
+ "observationScopeVolatilePatterns": list(DEFAULT_VOLATILE_SCOPE_PATTERNS),
184
237
  # Switchroom hindsight-leverage E2 / PR9 (#398) — lesson & anti-pattern
185
238
  # tagging at retain time. When on (default), build_retain_payload scans the
186
239
  # formatted transcript slice for explicit lesson / anti-pattern markers and
@@ -333,6 +386,11 @@ ENV_OVERRIDES = {
333
386
  # defaults.memory.observation_scopes) ONLY when the operator set it; unset
334
387
  # leaves `observationScopes` None and the field off the wire entirely.
335
388
  "HINDSIGHT_OBSERVATION_SCOPES": ("observationScopes", str),
389
+ # Opt-out / override the curated default. `combined` or `off` restore the
390
+ # pre-feature engine default; `curated` (the shipped default) / `shared`
391
+ # select the computed strategies. Off-list values fall back to `curated`
392
+ # (shouted, never raised) — see compute_observation_scopes.
393
+ "HINDSIGHT_OBSERVATION_SCOPE_STRATEGY": ("observationScopeStrategy", str),
336
394
  # Switchroom hindsight-leverage E2 / PR9 (#398) — lesson/anti-pattern tagging
337
395
  # + recall demotion toggles and overrides.
338
396
  "HINDSIGHT_LESSON_TAGGING": ("lessonTagging", bool),
@@ -513,6 +571,99 @@ def resolve_observation_scopes(config: dict):
513
571
  return value
514
572
 
515
573
 
574
+ def _volatile_scope_matchers(config: dict):
575
+ """Compile ``observationScopeVolatilePatterns`` to regexes, skipping bad ones.
576
+
577
+ NEVER raises: a malformed pattern is dropped (and shouted about) rather than
578
+ allowed to kill a retain. Falls back to the built-in defaults when the
579
+ config value is absent or not a list.
580
+ """
581
+ raw = config.get("observationScopeVolatilePatterns")
582
+ if not isinstance(raw, (list, tuple)):
583
+ raw = DEFAULT_VOLATILE_SCOPE_PATTERNS
584
+ matchers = []
585
+ for pat in raw:
586
+ if not isinstance(pat, str):
587
+ continue
588
+ try:
589
+ matchers.append(re.compile(pat))
590
+ except re.error as e:
591
+ print(
592
+ f"[Hindsight] observationScopeVolatilePatterns entry {pat!r} is not "
593
+ f"a valid regex ({e}); ignoring it for scope curation.",
594
+ file=sys.stderr,
595
+ )
596
+ return matchers
597
+
598
+
599
+ def _is_volatile_scope_tag(tag: str, matchers) -> bool:
600
+ return any(m.search(tag) for m in matchers)
601
+
602
+
603
+ def compute_observation_scopes(tags, config: dict):
604
+ """Resolve the per-row ``observation_scopes`` value for one retain.
605
+
606
+ Returns ``(value, error)`` — exactly like :func:`classify_observation_scopes`
607
+ — and MUST NEVER RAISE (a bad config must degrade the SCOPE, never lose the
608
+ turn; see ``retain.build_retain_payload``). ``value`` is what goes on the
609
+ wire: ``None`` (omit the field entirely → engine default), a bare string
610
+ (``"shared"`` / an operator-pinned Hindsight scope), or an explicit
611
+ ``list[list[str]]`` tag matrix. The wire body is byte-identical to the
612
+ pre-feature client whenever ``value`` is ``None``.
613
+
614
+ Precedence:
615
+
616
+ 1. A manually-pinned ``observationScopes`` (``memory.observation_scopes``)
617
+ WINS — its classified value (or its degrade-to-None-with-error on a typo)
618
+ is returned unchanged, so existing operator overrides keep working.
619
+ 2. Otherwise ``observationScopeStrategy`` decides:
620
+
621
+ * ``combined`` / ``off`` → ``None`` (pre-feature engine default; opt-out).
622
+ * ``shared`` → ``"shared"`` uniformly.
623
+ * ``curated`` (default) → strip volatile provenance tags from the scope
624
+ (keeping them on the source fact); non-empty stable set → ``[[stable…]]``
625
+ (deterministically sorted), empty stable set → ``"shared"``.
626
+ * anything else → treated as ``curated`` and shouted about.
627
+
628
+ Docs: https://hindsight.vectorize.io/developer/api/retain (``shared`` == the
629
+ explicit ``[[]]`` scope; a custom ``list[list[str]]`` is consolidated with
630
+ ``all_strict`` matching so scopes stay isolated) and /developer/observations.
631
+ """
632
+ # 1. Operator-pinned scope wins (back-compat). Only fall through to the
633
+ # strategy when observationScopes is genuinely UNSET — a typo'd pin
634
+ # degrades to the engine default WITH its error, never silently curated.
635
+ manual, manual_error = classify_observation_scopes(config)
636
+ if manual is not None or manual_error is not None:
637
+ return manual, manual_error
638
+
639
+ strategy_raw = config.get("observationScopeStrategy")
640
+ strategy = strategy_raw.strip().lower() if isinstance(strategy_raw, str) else ""
641
+ if not strategy:
642
+ strategy = "curated"
643
+
644
+ error = None
645
+ if strategy not in OBSERVATION_SCOPE_STRATEGIES:
646
+ error = (
647
+ f"observationScopeStrategy={strategy_raw!r} is not one of "
648
+ f"{', '.join(OBSERVATION_SCOPE_STRATEGIES)}; using 'curated'."
649
+ )
650
+ strategy = "curated"
651
+
652
+ if strategy in ("combined", "off"):
653
+ return None, error
654
+ if strategy == "shared":
655
+ return "shared", error
656
+
657
+ # curated
658
+ matchers = _volatile_scope_matchers(config)
659
+ stable = sorted(
660
+ {t for t in (tags or []) if isinstance(t, str) and t and not _is_volatile_scope_tag(t, matchers)}
661
+ )
662
+ if stable:
663
+ return [stable], error
664
+ return "shared", error
665
+
666
+
516
667
  def _cast_env(value: str, typ):
517
668
  """Cast environment variable string to target type. Returns None on failure."""
518
669
  try:
@@ -28,7 +28,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
28
  from lib import watermark
29
29
  from lib.bank import derive_bank_id, ensure_bank_mission
30
30
  from lib.client import HindsightClient
31
- from lib.config import classify_observation_scopes, debug_log, load_config
31
+ from lib.config import compute_observation_scopes, debug_log, load_config
32
32
  from lib.content import (
33
33
  prepare_retention_transcript,
34
34
  slice_last_turns_by_user_boundary,
@@ -267,13 +267,13 @@ def build_retain_payload(
267
267
  Returns ``{payload, document_id, message_count, last_uuid, ordered_uuids,
268
268
  transcript}`` or ``None`` when the slice formats to nothing.
269
269
 
270
- NEVER raises on a bad ``observationScopes``. Every retain producer builds
271
- its payload here, so this seam sees the typo — but it is also the seam the
272
- memory itself is made at, and a config typo must not be able to destroy
273
- one. An off-list value is dropped from the payload (so the engine's own
274
- default stands, exactly as before this feature existed) and shouted about
275
- on stderr; the memory is still built, still POSTed, still queued on
276
- failure. See ``lib.config.classify_observation_scopes``.
270
+ NEVER raises on a bad ``observationScopes`` / ``observationScopeStrategy``.
271
+ Every retain producer builds its payload here, so this seam sees the typo —
272
+ but it is also the seam the memory itself is made at, and a config typo must
273
+ not be able to destroy one. An off-list value degrades the SCOPE (the
274
+ engine's own default stands for a bad pin; ``curated`` stands for a bad
275
+ strategy) and is shouted about on stderr; the memory is still built, still
276
+ POSTed, still queued on failure. See ``lib.config.compute_observation_scopes``.
277
277
  """
278
278
  retain_roles = config.get("retainRoles", ["user", "assistant"])
279
279
  include_tool_calls = config.get("retainToolCalls", True)
@@ -367,12 +367,16 @@ def build_retain_payload(
367
367
  except Exception:
368
368
  pass
369
369
 
370
- # Per-row observation scope (switchroom). None unless the operator set
371
- # memory.observation_scopes and a None is dropped at the wire by
372
- # HindsightClient._retain_one, so the default request body is unchanged.
373
- # Carried ON THE PAYLOAD so it survives the pending-retains queue: a retain
374
- # that fails and drains hours later must land in the SAME scope it would
375
- # have landed in inline.
370
+ # Per-row observation scope (switchroom). Default `curated`: volatile
371
+ # per-session provenance tags are stripped from the CONSOLIDATION scope
372
+ # (kept on the source fact) so observations dedup across sessions instead of
373
+ # per-session a non-empty stable tag set yields an explicit `[[stable…]]`
374
+ # scope, an all-volatile/empty set yields `"shared"`. A None (opt-out via
375
+ # observationScopeStrategy=combined/off, or a manual observationScopes typo)
376
+ # is dropped at the wire by HindsightClient._retain_one, so that request
377
+ # body is byte-identical to the pre-feature default. Carried ON THE PAYLOAD
378
+ # so it survives the pending-retains queue: a retain that fails and drains
379
+ # hours later must land in the SAME scope it would have landed in inline.
376
380
  #
377
381
  # CLASSIFIED, NOT VALIDATED. This is the one seam every retain producer
378
382
  # funnels through, which makes it the tempting place to reject a typo — and
@@ -388,7 +392,7 @@ def build_retain_payload(
388
392
  # So a bad value degrades to the PRE-FEATURE behaviour (field omitted, the
389
393
  # engine's own default scope stands) and is shouted about on stderr. Wrong
390
394
  # scope is recoverable; a lost turn is not.
391
- scope, scope_error = classify_observation_scopes(config)
395
+ scope, scope_error = compute_observation_scopes(tags, config)
392
396
  if scope_error:
393
397
  print(
394
398
  f"[Hindsight] observation_scopes IGNORED for this retain: {scope_error} "
@@ -172,12 +172,25 @@ class TestBackfill(BackfillTestBase):
172
172
  self.assertTrue(all(async_flag is False for _, async_flag in self.daemon.posts))
173
173
 
174
174
  # -- switchroom: per-row observation scope on the backfill path ---------
175
- def test_backfill_omits_the_scope_when_unconfigured(self):
175
+ def test_backfill_omits_the_scope_when_opted_out(self):
176
+ # The curated default now emits a scope on every path; the opt-out
177
+ # (observationScopeStrategy=combined) must stay byte-identical to the
178
+ # pre-feature body — no scope on the wire, engine default in force.
179
+ os.environ["HINDSIGHT_OBSERVATION_SCOPE_STRATEGY"] = "combined"
180
+ self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPE_STRATEGY", None)
176
181
  self._transcript("clerk", "sess-plain", 4)
177
182
  bf.Backfill(self._config(), commit=True, delay_ms=0).run()
178
183
  self.assertTrue(self.daemon.observation_scopes_seen)
179
184
  self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
180
185
 
186
+ def test_backfill_curates_the_scope_by_default(self):
187
+ # Default ON: every recovered slice carries a curated scope; a miss here
188
+ # would silently drop the backfill path back to the pre-feature default.
189
+ self._transcript("clerk", "sess-plain", 4)
190
+ bf.Backfill(self._config(), commit=True, delay_ms=0).run()
191
+ self.assertTrue(self.daemon.observation_scopes_seen)
192
+ self.assertTrue(all(s is not None for s in self.daemon.observation_scopes_seen))
193
+
181
194
  def test_backfill_posts_the_configured_scope(self):
182
195
  # The backfill enumerates its own retain kwargs; a miss here would
183
196
  # scatter every recovered historical slice into per-tag scopes while