switchroom 0.19.43 → 0.19.45
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 +4 -1
- package/dist/auth-broker/index.js +254 -119
- package/dist/cli/notion-write-pretool.mjs +4 -1
- package/dist/cli/switchroom.js +3995 -3196
- package/dist/host-control/main.js +270 -130
- package/dist/vault/approvals/kernel-server.js +201 -66
- package/dist/vault/broker/server.js +263 -128
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +11 -4
- package/telegram-plugin/dist/gateway/gateway.js +945 -638
- package/telegram-plugin/flushed-turn-supersede.ts +190 -16
- package/telegram-plugin/gateway/cron-session.ts +31 -0
- package/telegram-plugin/gateway/gateway.ts +66 -65
- package/telegram-plugin/gateway/outbound-send-path.ts +60 -8
- package/telegram-plugin/gateway/reply-owner-wiring.ts +128 -0
- package/telegram-plugin/gateway/stream-render.ts +91 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +49 -9
- package/telegram-plugin/gateway/subagent-reply-authority.ts +147 -0
- package/telegram-plugin/gateway/turn-end.ts +9 -1
- package/telegram-plugin/reply-owner-resolve.ts +102 -26
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +110 -8
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +297 -27
- package/telegram-plugin/tests/reply-quote-wire.test.ts +2 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +567 -5
- package/telegram-plugin/tests/stream-render-golden.test.ts +204 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -25,6 +25,8 @@ import { tmpdir } from 'node:os'
|
|
|
25
25
|
import {
|
|
26
26
|
handleSessionEvent,
|
|
27
27
|
__resetParkedTurnStartsForTest,
|
|
28
|
+
flushCompletionTracker,
|
|
29
|
+
closeFlushCompletionWindows,
|
|
28
30
|
type StreamRenderDeps,
|
|
29
31
|
} from '../gateway/stream-render.js'
|
|
30
32
|
import {
|
|
@@ -33,7 +35,7 @@ import {
|
|
|
33
35
|
type SendReplyRequest,
|
|
34
36
|
} from '../gateway/outbound-send-path.js'
|
|
35
37
|
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
36
|
-
import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
38
|
+
import { FlushedTurnSupersedeRegistry, SUPERSEDE_COMPLETED_GRACE_MS } from '../flushed-turn-supersede.js'
|
|
37
39
|
import { BackstopDeliveryLedger } from '../gateway/backstop-delivery.js'
|
|
38
40
|
import { redact } from '../secret-detect/redact.js'
|
|
39
41
|
import { createTurnTypingLoop } from '../gateway/turn-typing-loop.js'
|
|
@@ -279,6 +281,9 @@ function makeSendReplyDeps(dedup: OutboundDedupCache, sharedSupersede?: FlushedT
|
|
|
279
281
|
streamKey: key,
|
|
280
282
|
resolveReplyOwnerTurn: () => ownerRes(null, 'none'),
|
|
281
283
|
getLastSubagentHandbackAt: () => null,
|
|
284
|
+
// #4176 — no sub-agent live (these suites drive the parent session's own
|
|
285
|
+
// flush→reply collapse). The gate is exercised in send-reply-golden.test.ts.
|
|
286
|
+
subagentReplyAuthority: { subagentCouldOwnReply: () => false },
|
|
282
287
|
findTurnByOriginId: () => null,
|
|
283
288
|
findTurnByQuotedMessageId: () => null,
|
|
284
289
|
resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
|
|
@@ -698,3 +703,201 @@ describe('#3544 — turn-start typing is unconditional at the enqueue seam', ()
|
|
|
698
703
|
rig.emitter.reset()
|
|
699
704
|
})
|
|
700
705
|
})
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
// ── #4173 — the turn-completion window wiring, driven through the REAL paths ──
|
|
709
|
+
//
|
|
710
|
+
// The pure three-phase rule is pinned in flushed-turn-supersede.test.ts /
|
|
711
|
+
// reply-owner-resolve.test.ts; THESE tests pin the stream-render WIRING — the
|
|
712
|
+
// quiescence flush OPENING the window and the real turn_end CLOSING it — which
|
|
713
|
+
// nothing else covers (deleting either hook leaves every pure test green).
|
|
714
|
+
describe('#4173 — quiescence flush opens the completion window; the real turn_end closes it', () => {
|
|
715
|
+
const settleFlush = () => new Promise((r) => setTimeout(r, 650))
|
|
716
|
+
const ANSWER =
|
|
717
|
+
'The composed terminal answer that the model never sent via reply, long enough to be a ' +
|
|
718
|
+
'genuine substantive flush delivery for the completion-window wiring tests.'
|
|
719
|
+
|
|
720
|
+
beforeEach(() => {
|
|
721
|
+
// drain module-scope tracker state left by other suites
|
|
722
|
+
flushCompletionTracker.closeAll(Date.now())
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
it('END-TO-END: the synthetic quiescence turn_end OPENS the window (atom + record ' +
|
|
726
|
+
'claimable far past any fixed TTL); the REAL turn_end then CLOSES both to the ' +
|
|
727
|
+
'replay grace', async () => {
|
|
728
|
+
const supersede = new FlushedTurnSupersedeRegistry()
|
|
729
|
+
// Pre-stamp a bogus value so the OPEN assertion proves the flush branch
|
|
730
|
+
// actively nulled it (not that it was merely never written).
|
|
731
|
+
const turn = makeTurn({ capturedText: [ANSWER], realEndObservedAt: 111 } as Partial<CurrentTurn>)
|
|
732
|
+
const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
|
|
733
|
+
|
|
734
|
+
// The answer-ready quiescence flush: a SYNTHETIC turn_end.
|
|
735
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: -1, reason: 'answer-ready-quiescence' })
|
|
736
|
+
await settleFlush()
|
|
737
|
+
expect(sh.delivered).toContain(ANSWER)
|
|
738
|
+
|
|
739
|
+
// OPEN: the atom's window was re-opened at fire time…
|
|
740
|
+
expect((turn as unknown as { realEndObservedAt: number | null }).realEndObservedAt).toBeNull()
|
|
741
|
+
// …and the record is claimable 20 minutes out (a /compact longer than any
|
|
742
|
+
// tuned TTL still collapses to one bubble — the #4166 unbounded case).
|
|
743
|
+
// Removing the flush branch's `flushCompletionTracker.open(turn)` leaves
|
|
744
|
+
// the record born-completed via turn-end stamping in prod; in this harness
|
|
745
|
+
// the observable red is the atom assertion above.
|
|
746
|
+
expect(
|
|
747
|
+
supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() + 20 * 60_000 }).reason,
|
|
748
|
+
).toBe('supersede')
|
|
749
|
+
|
|
750
|
+
// The session's REAL turn_end lands later — the completion signal.
|
|
751
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 })
|
|
752
|
+
await settleFlush()
|
|
753
|
+
|
|
754
|
+
// CLOSED: the atom is stamped…
|
|
755
|
+
const stamped = (turn as unknown as { realEndObservedAt: number | null }).realEndObservedAt
|
|
756
|
+
expect(stamped).not.toBeNull()
|
|
757
|
+
// …the record still honours the bounded tool-call-replay grace…
|
|
758
|
+
expect(
|
|
759
|
+
supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() + 1_000 }).reason,
|
|
760
|
+
).toBe('supersede')
|
|
761
|
+
// …and EXPIRES past it: a decoupled same-bridge reply (the Task-sub-agent-
|
|
762
|
+
// after-parent-turn-end shape) can no longer edit over the flushed answer.
|
|
763
|
+
// Removing the close hook (`closeFlushCompletionWindows` in case 'turn_end')
|
|
764
|
+
// turns THIS red — the record would stay open for 30 minutes.
|
|
765
|
+
expect(
|
|
766
|
+
supersede.peek(CHAT, undefined, {
|
|
767
|
+
liveTurnId: turn.turnId,
|
|
768
|
+
now: Date.now() + SUPERSEDE_COMPLETED_GRACE_MS + 5_000,
|
|
769
|
+
}).reason,
|
|
770
|
+
).toBe('expired')
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
it('the REAL-turn_end backstop variant does NOT re-open the window ' +
|
|
774
|
+
'(the `durationMs === -1` conditional is load-bearing)', async () => {
|
|
775
|
+
const supersede = new FlushedTurnSupersedeRegistry()
|
|
776
|
+
const turn = makeTurn({ capturedText: [ANSWER], realEndObservedAt: 999 } as Partial<CurrentTurn>)
|
|
777
|
+
const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
|
|
778
|
+
|
|
779
|
+
// The turn-end BACKSTOP: the flush fires ON a real turn_end. The session
|
|
780
|
+
// already stopped, so the window must stay closed (born completed).
|
|
781
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 })
|
|
782
|
+
await settleFlush()
|
|
783
|
+
expect(sh.delivered).toContain(ANSWER)
|
|
784
|
+
|
|
785
|
+
// Unconditionally opening in the flush branch (dropping the -1 check)
|
|
786
|
+
// nulls this and turns the test red.
|
|
787
|
+
expect((turn as unknown as { realEndObservedAt: number | null }).realEndObservedAt).not.toBeNull()
|
|
788
|
+
// The record was born completed (inherited from the atom), so it expires
|
|
789
|
+
// on the grace, never the 30-minute open cap.
|
|
790
|
+
expect(
|
|
791
|
+
supersede.peek(CHAT, undefined, {
|
|
792
|
+
liveTurnId: turn.turnId,
|
|
793
|
+
now: Date.now() + SUPERSEDE_COMPLETED_GRACE_MS + 5_000,
|
|
794
|
+
}).reason,
|
|
795
|
+
).toBe('expired')
|
|
796
|
+
})
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
// ── LOW #2 (#4167 review) — the two CLOSE PROXIES, which nothing covered ──────
|
|
800
|
+
//
|
|
801
|
+
// `closeFlushCompletionWindows` has three call sites: the real turn_end (pinned
|
|
802
|
+
// by the #4173 block above) and two PROXIES — a new turn minting on the serial
|
|
803
|
+
// session (`case 'enqueue'`), and the main bridge dying (gateway.ts). Both were
|
|
804
|
+
// untested: deleting the `beginTurn` call left all 18 stream-render tests green,
|
|
805
|
+
// and the bridge-death sweep had no coverage at all. A silently-dropped proxy
|
|
806
|
+
// leaves a flushed turn's window OPEN for the full 30-minute crash cap, which is
|
|
807
|
+
// exactly the tail that makes an edit-over of a delivered answer possible.
|
|
808
|
+
describe('#4173 close proxies — a new turn mint and bridge death both close open windows', () => {
|
|
809
|
+
const settleFlush = () => new Promise((r) => setTimeout(r, 650))
|
|
810
|
+
const ANSWER =
|
|
811
|
+
'The composed terminal answer that the model never sent via reply, long enough to be a ' +
|
|
812
|
+
'genuine substantive flush delivery for the close-proxy wiring tests.'
|
|
813
|
+
const CLOSED_BY_GRACE = SUPERSEDE_COMPLETED_GRACE_MS + 5_000
|
|
814
|
+
// Well inside the 30-minute OPEN cap: if the window did NOT close, the record
|
|
815
|
+
// is still claimable here — which is what makes these assertions bite.
|
|
816
|
+
const STILL_OPEN_AT = 10 * 60_000
|
|
817
|
+
|
|
818
|
+
beforeEach(() => {
|
|
819
|
+
__resetParkedTurnStartsForTest()
|
|
820
|
+
flushCompletionTracker.closeAll(Date.now())
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
async function flushWithOpenWindow(supersede: FlushedTurnSupersedeRegistry) {
|
|
824
|
+
const turn = makeTurn({ capturedText: [ANSWER], realEndObservedAt: 111 } as Partial<CurrentTurn>)
|
|
825
|
+
const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
|
|
826
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: -1, reason: 'answer-ready-quiescence' })
|
|
827
|
+
await settleFlush()
|
|
828
|
+
expect(sh.delivered).toContain(ANSWER)
|
|
829
|
+
// Precondition: the window is genuinely OPEN (claimable 10 minutes out).
|
|
830
|
+
expect((turn as unknown as { realEndObservedAt: number | null }).realEndObservedAt).toBeNull()
|
|
831
|
+
expect(
|
|
832
|
+
supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() + STILL_OPEN_AT }).reason,
|
|
833
|
+
).toBe('supersede')
|
|
834
|
+
return { turn, sh }
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
it('PROXY A: a NEW TURN minting (case `enqueue`) closes the prior flush window — ' +
|
|
838
|
+
'the record then expires on the replay grace, not the 30-minute open cap', async () => {
|
|
839
|
+
const supersede = new FlushedTurnSupersedeRegistry()
|
|
840
|
+
const { turn, sh } = await flushWithOpenWindow(supersede)
|
|
841
|
+
|
|
842
|
+
// The serial session moved on: the CLI enqueues the next turn. The harness's
|
|
843
|
+
// fake `endCurrentTurnAtomic` does not null the turn, so clear it here — an
|
|
844
|
+
// idle session is what makes `enqueue` MINT rather than park.
|
|
845
|
+
;(sh.deps as unknown as { setCurrentTurn: (t: CurrentTurn | null) => void }).setCurrentTurn(null)
|
|
846
|
+
handleSessionEvent(sh.deps, {
|
|
847
|
+
kind: 'enqueue',
|
|
848
|
+
chatId: CHAT,
|
|
849
|
+
messageId: null,
|
|
850
|
+
threadId: null,
|
|
851
|
+
rawContent: 'the next user message',
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
// The atom is stamped…
|
|
855
|
+
expect((turn as unknown as { realEndObservedAt: number | null }).realEndObservedAt).not.toBeNull()
|
|
856
|
+
// …the bounded replay grace still honours the flushed turn's own late reply…
|
|
857
|
+
expect(
|
|
858
|
+
supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() + 1_000 }).reason,
|
|
859
|
+
).toBe('supersede')
|
|
860
|
+
// …and past the grace it is EXPIRED. Deleting `closeFlushCompletionWindows`
|
|
861
|
+
// from `beginTurn` turns BOTH of the assertions below red (the record would
|
|
862
|
+
// still read 'supersede' out to the 30-minute cap).
|
|
863
|
+
expect(
|
|
864
|
+
supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() + CLOSED_BY_GRACE }).reason,
|
|
865
|
+
).toBe('expired')
|
|
866
|
+
expect(
|
|
867
|
+
supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() + STILL_OPEN_AT }).reason,
|
|
868
|
+
).toBe('expired')
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
it('PROXY B: the bridge-death sweep (`closeFlushCompletionWindows`) closes an open ' +
|
|
872
|
+
'window to the grace and reports how many atoms it stamped', async () => {
|
|
873
|
+
const supersede = new FlushedTurnSupersedeRegistry()
|
|
874
|
+
const { turn } = await flushWithOpenWindow(supersede)
|
|
875
|
+
|
|
876
|
+
// The main claude bridge disconnected: the exact call gateway.ts makes.
|
|
877
|
+
const closed = closeFlushCompletionWindows(supersede, Date.now())
|
|
878
|
+
expect(closed).toBe(1) // the open atom was stamped, not silently skipped
|
|
879
|
+
|
|
880
|
+
expect((turn as unknown as { realEndObservedAt: number | null }).realEndObservedAt).not.toBeNull()
|
|
881
|
+
expect(
|
|
882
|
+
supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() + CLOSED_BY_GRACE }).reason,
|
|
883
|
+
).toBe('expired')
|
|
884
|
+
// Idempotent: a second disconnect sweep stamps nothing further.
|
|
885
|
+
expect(closeFlushCompletionWindows(supersede, Date.now())).toBe(0)
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
it('PROXY B wiring: gateway.ts calls the sweep on MAIN-bridge disconnect and the ' +
|
|
889
|
+
'#4176 sub-agent liveness reset alongside it (gateway.ts is not importable)', () => {
|
|
890
|
+
const src = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
|
|
891
|
+
const call = 'const closedWindows = closeFlushCompletionWindows(flushedTurnSupersede, Date.now())'
|
|
892
|
+
expect(src).toContain(call)
|
|
893
|
+
// The sweep must sit under the main-bridge guard: a CRON bridge dying says
|
|
894
|
+
// nothing about the main session's flush windows, so closing there would
|
|
895
|
+
// shorten a live window on an unrelated session.
|
|
896
|
+
const idx = src.indexOf(call)
|
|
897
|
+
expect(idx).toBeGreaterThan(0)
|
|
898
|
+
const before = src.slice(Math.max(0, idx - 1200), idx)
|
|
899
|
+
expect(before).toContain('isCronIdentity')
|
|
900
|
+
// #4176 — the sub-agents die with the session; the reset is co-located.
|
|
901
|
+
expect(before).toContain('subagentReplyAuthority.reset()')
|
|
902
|
+
})
|
|
903
|
+
})
|
|
@@ -25,9 +25,24 @@ OBSERVATION_SCOPE_STRATEGIES = ("curated", "shared", "combined", "off")
|
|
|
25
25
|
#: value that, if left in scope, defeats cross-session dedup on the dominant
|
|
26
26
|
#: (sidechain) observation path. A tag matching ANY of these is dropped from the
|
|
27
27
|
#: consolidation scope but LEFT on the source fact.
|
|
28
|
+
#:
|
|
29
|
+
#: ``^source:`` (switchroom provenance tagging) is in the list for a different
|
|
30
|
+
#: reason than the other two, and the name of the constant undersells it: what
|
|
31
|
+
#: this list really means is "tags excluded from the CONSOLIDATION SCOPE, kept
|
|
32
|
+
#: on the source fact". ``source:transcript`` is the opposite of volatile — it
|
|
33
|
+
#: is stamped identically on every auto-retain, forever. That is exactly why it
|
|
34
|
+
#: must not reach the scope: a stable tag flips ``curated`` from the bank-wide
|
|
35
|
+
#: ``"shared"`` scope to an explicit ``[["source:transcript"]]`` partition, so
|
|
36
|
+
#: every observation consolidated after the tag ships would be isolated from
|
|
37
|
+
#: every observation consolidated before it. The tag is a RECALL-side filter
|
|
38
|
+
#: handle (``metadata`` is not filterable, tags are), not a consolidation
|
|
39
|
+
#: partition, so scope computation must stay byte-identical to before it
|
|
40
|
+
#: existed. Pinned against ``RETAIN_PROVENANCE_TAG_SCOPE_PATTERN`` in
|
|
41
|
+
#: ``src/memory/hindsight-retain-provenance.ts``.
|
|
28
42
|
DEFAULT_VOLATILE_SCOPE_PATTERNS = (
|
|
29
43
|
r"^parent_session:",
|
|
30
44
|
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-.+)?$",
|
|
45
|
+
r"^source:",
|
|
31
46
|
)
|
|
32
47
|
|
|
33
48
|
DEFAULTS = {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Provenance on auto-retained transcript content (switchroom fix/retain-provenance).
|
|
2
|
+
|
|
3
|
+
The defect: the Stop-hook auto-retain posts the recent transcript window to
|
|
4
|
+
Hindsight, which extracts facts from it — including from the ASSISTANT's own
|
|
5
|
+
synthesis. Those fabrications land as ordinary memory units and recall back next
|
|
6
|
+
session as though a human had asserted them. Nothing on the stored unit said
|
|
7
|
+
"this came out of a transcript": ``tags`` held only the raw session UUID and the
|
|
8
|
+
only other signal, ``session_id``, lived in ``metadata`` — which Hindsight's
|
|
9
|
+
best-practices page states is NOT filterable, while tags ARE.
|
|
10
|
+
|
|
11
|
+
The fix stamps a stable, semantic ``source:transcript`` tag via switchroom's
|
|
12
|
+
``retainTags`` default, so recall/reflect can address it (tag filters, tag
|
|
13
|
+
weights). This module asserts the two OUTCOMES that matter at the retain seam:
|
|
14
|
+
|
|
15
|
+
1. The tag actually reaches the wire payload, alongside the session tag and
|
|
16
|
+
any detected lesson tags (i.e. it composes, it does not clobber).
|
|
17
|
+
2. It does NOT change the observation consolidation scope. This is the
|
|
18
|
+
non-obvious half: ``observationScopeStrategy: "curated"`` builds the scope
|
|
19
|
+
from a retain's STABLE tags, so a permanently-stamped tag would flip every
|
|
20
|
+
bank from the bank-wide ``"shared"`` scope to a ``[["source:transcript"]]``
|
|
21
|
+
partition, isolating every new observation from every observation
|
|
22
|
+
consolidated before the tag shipped. ``^source:`` is therefore excluded
|
|
23
|
+
from the scope in ``DEFAULT_VOLATILE_SCOPE_PATTERNS`` — the tag is a
|
|
24
|
+
recall-side filter handle, not a consolidation partition.
|
|
25
|
+
|
|
26
|
+
Stdlib-only; runs under ``python3 -m unittest discover tests/``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import unittest
|
|
32
|
+
|
|
33
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
34
|
+
if SCRIPTS_DIR not in sys.path:
|
|
35
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
36
|
+
|
|
37
|
+
from lib.config import ( # noqa: E402
|
|
38
|
+
DEFAULTS,
|
|
39
|
+
DEFAULT_VOLATILE_SCOPE_PATTERNS,
|
|
40
|
+
compute_observation_scopes,
|
|
41
|
+
)
|
|
42
|
+
from retain import build_retain_payload # noqa: E402
|
|
43
|
+
|
|
44
|
+
#: What switchroom's ``renderHindsightSettingsOverrides`` stamps into every
|
|
45
|
+
#: agent's deployed settings.json (src/memory/hindsight-retain-provenance.ts).
|
|
46
|
+
PROVENANCE_TAG = "source:transcript"
|
|
47
|
+
SESSION_ID = "4c386b32-ddfd-40d1-b557-da8135b294af"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _cfg(**over):
|
|
51
|
+
c = {
|
|
52
|
+
"retainRoles": ["user", "assistant"],
|
|
53
|
+
"retainToolCalls": True,
|
|
54
|
+
"retainContext": "claude-code",
|
|
55
|
+
"retainMetadata": {},
|
|
56
|
+
"retainTags": ["{session_id}", PROVENANCE_TAG],
|
|
57
|
+
"lessonTagging": DEFAULTS["lessonTagging"],
|
|
58
|
+
"lessonTagMarkers": DEFAULTS["lessonTagMarkers"],
|
|
59
|
+
"observationScopeStrategy": "curated",
|
|
60
|
+
}
|
|
61
|
+
c.update(over)
|
|
62
|
+
return c
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _build(transcript_text="Edited the login handler and ran tests.", **cfg_over):
|
|
66
|
+
msgs = [
|
|
67
|
+
{"role": "user", "content": "what did we change?", "uuid": "u1"},
|
|
68
|
+
{"role": "assistant", "content": transcript_text, "uuid": "a1"},
|
|
69
|
+
]
|
|
70
|
+
built = build_retain_payload(
|
|
71
|
+
_cfg(**cfg_over),
|
|
72
|
+
SESSION_ID,
|
|
73
|
+
msgs,
|
|
74
|
+
msgs,
|
|
75
|
+
bank_id="bank",
|
|
76
|
+
api_url="http://x",
|
|
77
|
+
api_token=None,
|
|
78
|
+
)
|
|
79
|
+
assert built is not None
|
|
80
|
+
return built["payload"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ProvenanceTagReachesTheWire(unittest.TestCase):
|
|
84
|
+
def test_tag_is_on_the_retain_payload(self):
|
|
85
|
+
self.assertIn(PROVENANCE_TAG, _build()["tags"])
|
|
86
|
+
|
|
87
|
+
def test_session_tag_still_templated_and_kept(self):
|
|
88
|
+
tags = _build()["tags"]
|
|
89
|
+
self.assertIn(SESSION_ID, tags)
|
|
90
|
+
self.assertNotIn("{session_id}", tags)
|
|
91
|
+
|
|
92
|
+
def test_composes_with_detected_lesson_tags(self):
|
|
93
|
+
tags = _build("anti-pattern: narrate without executing")["tags"]
|
|
94
|
+
self.assertIn(PROVENANCE_TAG, tags)
|
|
95
|
+
self.assertIn("anti-pattern", tags)
|
|
96
|
+
self.assertIn(SESSION_ID, tags)
|
|
97
|
+
|
|
98
|
+
def test_absent_when_retain_tags_config_omits_it(self):
|
|
99
|
+
# The tag is data, not hardcoded behaviour: retain.py has no knowledge
|
|
100
|
+
# of it beyond honouring the configured retainTags. That keeps the
|
|
101
|
+
# vendored plugin generic and the switchroom stamp the single source.
|
|
102
|
+
tags = _build(retainTags=["{session_id}"])["tags"]
|
|
103
|
+
self.assertNotIn(PROVENANCE_TAG, tags)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ProvenanceTagIsScopeNeutral(unittest.TestCase):
|
|
107
|
+
"""The tag must not silently re-partition observation consolidation."""
|
|
108
|
+
|
|
109
|
+
def test_curated_scope_stays_shared_with_the_provenance_tag(self):
|
|
110
|
+
payload = _build()
|
|
111
|
+
self.assertEqual(payload["observation_scopes"], "shared")
|
|
112
|
+
|
|
113
|
+
def test_scope_is_byte_identical_to_a_bank_without_the_tag(self):
|
|
114
|
+
with_tag = _build()["observation_scopes"]
|
|
115
|
+
without = _build(retainTags=["{session_id}"])["observation_scopes"]
|
|
116
|
+
self.assertEqual(with_tag, without)
|
|
117
|
+
|
|
118
|
+
def test_a_stable_tag_that_is_not_excluded_would_have_changed_the_scope(self):
|
|
119
|
+
# Guards the guard: proves the "shared" result above is caused by the
|
|
120
|
+
# exclusion pattern and not by curated ignoring stable tags generally.
|
|
121
|
+
scope, err = compute_observation_scopes(
|
|
122
|
+
[SESSION_ID, "project:switchroom"], _cfg()
|
|
123
|
+
)
|
|
124
|
+
self.assertIsNone(err)
|
|
125
|
+
self.assertEqual(scope, [["project:switchroom"]])
|
|
126
|
+
|
|
127
|
+
def test_exclusion_pattern_is_present_and_matches_the_tag(self):
|
|
128
|
+
self.assertIn(r"^source:", DEFAULT_VOLATILE_SCOPE_PATTERNS)
|
|
129
|
+
scope, err = compute_observation_scopes([SESSION_ID, PROVENANCE_TAG], _cfg())
|
|
130
|
+
self.assertIsNone(err)
|
|
131
|
+
self.assertEqual(scope, "shared")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__": # pragma: no cover
|
|
135
|
+
unittest.main()
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"hindsightApiUrl": "",
|
|
3
3
|
"bankId": "claude_code",
|
|
4
4
|
"bankMission": "You are a Claude Code AI assistant. Focus on technical discussions, decisions, and context relevant to the user's projects.",
|
|
5
|
-
"retainMission": "Extract durable facts that will still be true and useful weeks from now, once this session is forgotten.\n\nDURABILITY GATE. Emit a candidate ONLY if it is one of these five classes:\n- PREFERENCE — what the user likes, wants, or always does; a standing rule or correction.\n- DECISION — a settled choice that changes how future work is done, including a choice NOT to do something. A decision about the mechanics of the CURRENT task (which worker to dispatch, which branch to rebase, which PR to merge now, what to do next) is process narration, not a durable decision — drop it unless it establishes a standing rule or permanently changes a system.\n- FINDING — a root cause, a measurement, or verified behaviour of a system. Include the number.\n- OUTCOME — a completed result that changed the world: what shipped, what a thing turned out to be. Not the act of shipping it.\n- RELATIONSHIP — who a person is, what a project or tool is, and how they connect.\nIf a candidate fits none of the five, it is not a memory. Drop it; do not reword it into one.\n\nA preference revealed by a request is durable — record the preference (what the user likes, wants, or always does), not the request itself.\n\nA TOOL RESULT IS NOT A FACT. Before extracting, ask: is the subject of this\ncandidate a file path, a command/process/agent/session id, a temp directory, or\nthe location where some output was written? If yes, drop it — it is transcript\nexhaust, not memory.\n\nNEVER extract:\n- Tool results verbatim or paraphrased. Concretely, never produce a fact whose\n text resembles any of these: \"File created successfully at /path/to/file\",\n \"A background command with ID bctz4yskm is running, and its output will be\n written to /tmp/...\", \"Async agent a745598ba84e71df1 was launched successfully\n and is running in the background\", \"User executed a Bash command to sleep for\n 200 seconds\", \"The assistant used grep to locate 'truncateSync' in src/foo.ts\".\n- Anything mentioning a path under /tmp, a scratchpad directory, or a .tmp file.\n- Agent tool-use traces or narration of what the assistant did (e.g. \"the\n assistant used X to query Y\", \"ran a search\", \"sent the message\").\n- The act of delegating, dispatching, spawning, launching, steering or merging\n work — including when it succeeded. \"X was dispatched and completed\" is the\n session describing itself. Record only what the work LEARNED or CHANGED.\n- In-flight workflow/process narration (a sub-task started, paused, or is still\n running) — retain the outcome only once the task completes or a decision is made.\n- Operation, request, batch, agent, command or session IDs, UUIDs, hashes, or error codes.\n- Slash commands the user typed and their effects (e.g. \"User issued /clear to\n reset assistant state\").\n- Hindsight's own errors, retries, backlogs, or internal state — the memory\n system's self-reports are not memories.\n- Restatements of the user's current request or the task in progress.\n- Volatile state written as a timeless assertion. A version, count, size,\n backlog, status, or any \"X is running Y\" / \"X is at Y\" / \"X is currently Y\"\n claim is true only at the instant it was said. Concretely, never produce a\n fact whose text resembles any of these: \"Switchroom fleet is running image\n version v0.18.19\", \"The switchroom repo is at /path/to/fleet, version\n v0.19.5\", \"Bank overlord has 43155 pending consolidations\", \"The build is\n currently green\". If the claim is worth keeping, put the date INSIDE the\n fact text (\"As of 2026-07-19 the fleet was running v0.18.19\"); if you\n cannot date it, drop it. An undated one is recalled forever as though it\n were still true, which is worse than not remembering it at all.\n- Transient state (unread counts, build status, what is running right now) unless\n the fact is explicitly dated, in which case record it as a dated observation.\n- Greetings, acknowledgements, and routine operational chatter.\n\nWrite each fact so it stands alone: name the thing, the number, and the date. A\nsentence that only makes sense while reading this transcript is not durable.\n\nIf a candidate fact matches an exclusion, drop it rather than rewording it. If\nnothing durable remains, return an empty facts list.\n",
|
|
5
|
+
"retainMission": "Extract durable facts that will still be true and useful weeks from now, once this session is forgotten.\n\nDURABILITY GATE. Emit a candidate ONLY if it is one of these five classes:\n- PREFERENCE — what the user likes, wants, or always does; a standing rule or correction.\n- DECISION — a settled choice that changes how future work is done, including a choice NOT to do something. A decision about the mechanics of the CURRENT task (which worker to dispatch, which branch to rebase, which PR to merge now, what to do next) is process narration, not a durable decision — drop it unless it establishes a standing rule or permanently changes a system.\n- FINDING — a root cause, a measurement, or verified behaviour of a system. Include the number.\n- OUTCOME — a completed result that changed the world: what shipped, what a thing turned out to be. Not the act of shipping it.\n- RELATIONSHIP — who a person is, what a project or tool is, and how they connect.\nIf a candidate fits none of the five, it is not a memory. Drop it; do not reword it into one.\n\nA preference revealed by a request is durable — record the preference (what the user likes, wants, or always does), not the request itself.\n\nA TOOL RESULT IS NOT A FACT. Before extracting, ask: is the subject of this\ncandidate a file path, a command/process/agent/session id, a temp directory, or\nthe location where some output was written? If yes, drop it — it is transcript\nexhaust, not memory.\n\nNEVER extract:\n- Tool results verbatim or paraphrased. Concretely, never produce a fact whose\n text resembles any of these: \"File created successfully at /path/to/file\",\n \"A background command with ID bctz4yskm is running, and its output will be\n written to /tmp/...\", \"Async agent a745598ba84e71df1 was launched successfully\n and is running in the background\", \"User executed a Bash command to sleep for\n 200 seconds\", \"The assistant used grep to locate 'truncateSync' in src/foo.ts\".\n- Anything mentioning a path under /tmp, a scratchpad directory, or a .tmp file.\n- Agent tool-use traces or narration of what the assistant did (e.g. \"the\n assistant used X to query Y\", \"ran a search\", \"sent the message\").\n- The act of delegating, dispatching, spawning, launching, steering or merging\n work — including when it succeeded. \"X was dispatched and completed\" is the\n session describing itself. Record only what the work LEARNED or CHANGED.\n- In-flight workflow/process narration (a sub-task started, paused, or is still\n running) — retain the outcome only once the task completes or a decision is made.\n- Operation, request, batch, agent, command or session IDs, UUIDs, hashes, or error codes.\n- Slash commands the user typed and their effects (e.g. \"User issued /clear to\n reset assistant state\").\n- Hindsight's own errors, retries, backlogs, or internal state — the memory\n system's self-reports are not memories.\n- Restatements of the user's current request or the task in progress.\n- The assistant's own answers, summaries, recaps, or reflect output. Before\n extracting, ask: does anything in this transcript support this claim OTHER\n than the assistant having asserted it? If not, drop it — a model's own\n synthesis re-extracted as a fact is how a guess becomes permanent.\n Concretely, never produce a fact whose only support is the assistant\n stating a date, a version, an attribution, a total, or a decision that the\n user never confirmed and no tool output shows. An unverified claim recalls\n later as though it had been established, which is worse than not\n remembering it at all.\n- Volatile state written as a timeless assertion. A version, count, size,\n backlog, status, or any \"X is running Y\" / \"X is at Y\" / \"X is currently Y\"\n claim is true only at the instant it was said. Concretely, never produce a\n fact whose text resembles any of these: \"Switchroom fleet is running image\n version v0.18.19\", \"The switchroom repo is at /path/to/fleet, version\n v0.19.5\", \"Bank overlord has 43155 pending consolidations\", \"The build is\n currently green\". If the claim is worth keeping, put the date INSIDE the\n fact text (\"As of 2026-07-19 the fleet was running v0.18.19\"); if you\n cannot date it, drop it. An undated one is recalled forever as though it\n were still true, which is worse than not remembering it at all.\n- Transient state (unread counts, build status, what is running right now) unless\n the fact is explicitly dated, in which case record it as a dated observation.\n- Greetings, acknowledgements, and routine operational chatter.\n\nWrite each fact so it stands alone: name the thing, the number, and the date. A\nsentence that only makes sense while reading this transcript is not durable.\n\nIf a candidate fact matches an exclusion, drop it rather than rewording it. If\nnothing durable remains, return an empty facts list.\n",
|
|
6
6
|
"autoRecall": true,
|
|
7
7
|
"autoRetain": true,
|
|
8
8
|
"retainMode": "full-session",
|