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.
- package/dist/agent-scheduler/index.js +10 -1
- package/dist/auth-broker/index.js +15 -6
- package/dist/cli/notion-write-pretool.mjs +10 -1
- package/dist/cli/switchroom.js +1141 -566
- package/dist/host-control/main.js +220 -8
- package/dist/vault/approvals/kernel-server.js +15 -6
- package/dist/vault/broker/server.js +15 -6
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +10 -0
- package/telegram-plugin/dist/bridge/bridge.js +8 -0
- package/telegram-plugin/dist/gateway/gateway.js +450 -206
- package/telegram-plugin/dist/server.js +8 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
- package/telegram-plugin/gateway/compaction-marker.ts +84 -0
- package/telegram-plugin/gateway/gateway.ts +3 -3
- package/telegram-plugin/gateway/handback-preturn-signal.ts +16 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
- package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +277 -18
- package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
- package/telegram-plugin/hooks/hooks.json +11 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +119 -1
- package/telegram-plugin/session-tail.ts +20 -0
- package/telegram-plugin/silence-poke.ts +28 -0
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +19 -12
- package/telegram-plugin/tests/fixtures/pretool-main-2.1.219.json +15 -0
- package/telegram-plugin/tests/fixtures/pretool-subagent-2.1.219.json +16 -0
- package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
- package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
- package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
- package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
- package/telegram-plugin/tests/queued-card-surface.test.ts +262 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
- package/telegram-plugin/tests/sidechain-label-filter-pretool.test.ts +272 -0
- package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +7 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +151 -0
- package/vendor/hindsight-memory/scripts/retain.py +19 -15
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +14 -1
- package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +192 -3
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +18 -2
- 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[
|
|
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
|
|
231
|
-
|
|
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[
|
|
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
|
|
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
|
|
271
|
-
its payload here, so this seam sees the typo —
|
|
272
|
-
memory itself is made at, and a config typo must
|
|
273
|
-
one. An off-list value
|
|
274
|
-
default stands
|
|
275
|
-
on stderr; the memory is still built, still
|
|
276
|
-
failure. See ``lib.config.
|
|
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).
|
|
371
|
-
#
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
#
|
|
375
|
-
#
|
|
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 =
|
|
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
|
|
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
|