switchroom 0.19.42 → 0.19.44
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 +284 -124
- package/dist/cli/notion-write-pretool.mjs +4 -1
- package/dist/cli/switchroom.js +4094 -2711
- package/dist/host-control/main.js +472 -155
- package/dist/vault/approvals/kernel-server.js +231 -71
- package/dist/vault/broker/server.js +293 -133
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +93 -8
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1682 -936
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +49 -51
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +5 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- 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
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A reply that was rate-limited and then DELIVERED on retry must not raise a
|
|
3
|
+
* fleet-health delivery-failure alert (#3931).
|
|
4
|
+
*
|
|
5
|
+
* ── The bug ──────────────────────────────────────────────────────────────
|
|
6
|
+
* `installTgPostLogger` is a grammy API transformer, so it runs INSIDE the
|
|
7
|
+
* function the retry policy calls — it observes one POST ATTEMPT, never the
|
|
8
|
+
* logical send's outcome. A 429 that `createRetryApiCall` slept and retried
|
|
9
|
+
* successfully therefore left a `tg-post method=sendRichMessage … status=err`
|
|
10
|
+
* line in the gateway log, and fleet-health's `reply-delivery-failure`
|
|
11
|
+
* signature (`src/fleet-health/detect.ts`) matches per LINE. Result: a
|
|
12
|
+
* severity-3 "the answer never reached the principal" escalation for an answer
|
|
13
|
+
* the operator had already read. Under flood pressure (the class of event that
|
|
14
|
+
* produces 429s in the first place) that is the alarm that fires most.
|
|
15
|
+
*
|
|
16
|
+
* ── The fix these tests pin ──────────────────────────────────────────────
|
|
17
|
+
* The retry policy publishes its attempt context; the transformer labels an
|
|
18
|
+
* attempt the policy is about to repeat `status=retry`. `status=err` now means
|
|
19
|
+
* the logical send is OVER and nothing landed. Nothing is suppressed — the
|
|
20
|
+
* retry lines are still written, they just no longer masquerade as outcomes.
|
|
21
|
+
*
|
|
22
|
+
* These tests drive a REAL grammy `Bot` (a mock-`api` harness sits above the
|
|
23
|
+
* transformer layer, so it would be a false guard) through the REAL retry
|
|
24
|
+
* policy, and feed the REAL emitted lines to the REAL detector.
|
|
25
|
+
*/
|
|
26
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
27
|
+
import { Bot } from 'grammy'
|
|
28
|
+
import { installTgPostLogger } from '../shared/bot-runtime.js'
|
|
29
|
+
import { createRetryApiCall, willRetryTelegramFailure } from '../retry-api-call.js'
|
|
30
|
+
import { detectGatewayFindings, scanAgent } from '../../src/fleet-health/detect.js'
|
|
31
|
+
|
|
32
|
+
/** A real Bot whose transport answers with `envelopes[i]` for the i-th call. */
|
|
33
|
+
function makeBot(envelopes: Array<Record<string, unknown>>): Bot {
|
|
34
|
+
let i = 0
|
|
35
|
+
const fakeFetch = (async () => {
|
|
36
|
+
const envelope = envelopes[Math.min(i, envelopes.length - 1)]
|
|
37
|
+
i++
|
|
38
|
+
// A Bot API rejection is an HTTP 200 carrying `ok:false` — which is
|
|
39
|
+
// precisely why it RESOLVES the transformer chain (see #3927).
|
|
40
|
+
return { ok: true, status: 200, json: async () => envelope } as unknown as Response
|
|
41
|
+
}) as unknown as typeof fetch
|
|
42
|
+
|
|
43
|
+
const bot = new Bot('123456:TEST_TOKEN', {
|
|
44
|
+
botInfo: {
|
|
45
|
+
id: 123456,
|
|
46
|
+
is_bot: true,
|
|
47
|
+
first_name: 'Test',
|
|
48
|
+
username: 'test_bot',
|
|
49
|
+
can_join_groups: false,
|
|
50
|
+
can_read_all_group_messages: false,
|
|
51
|
+
supports_inline_queries: false,
|
|
52
|
+
can_connect_to_business: false,
|
|
53
|
+
has_main_web_app: false,
|
|
54
|
+
},
|
|
55
|
+
client: { fetch: fakeFetch },
|
|
56
|
+
})
|
|
57
|
+
installTgPostLogger(bot)
|
|
58
|
+
return bot
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const OK_ENVELOPE = {
|
|
62
|
+
ok: true,
|
|
63
|
+
result: { message_id: 7, date: 0, chat: { id: 4242, type: 'private' } },
|
|
64
|
+
}
|
|
65
|
+
const FLOOD_429 = {
|
|
66
|
+
ok: false,
|
|
67
|
+
error_code: 429,
|
|
68
|
+
description: 'Too Many Requests: retry after 3',
|
|
69
|
+
parameters: { retry_after: 3 },
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Send one user-facing reply through the production composition
|
|
74
|
+
* (retry policy → transformer → wire) and return the gateway log lines it
|
|
75
|
+
* produced, shaped exactly as `gateway-supervisor.log` carries them.
|
|
76
|
+
*/
|
|
77
|
+
async function gatewayLinesForReply(
|
|
78
|
+
envelopes: Array<Record<string, unknown>>,
|
|
79
|
+
): Promise<{ lines: string[]; threw: boolean }> {
|
|
80
|
+
const written: string[] = []
|
|
81
|
+
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
|
|
82
|
+
written.push(String(chunk))
|
|
83
|
+
return true
|
|
84
|
+
}) as unknown as typeof process.stderr.write)
|
|
85
|
+
let threw = false
|
|
86
|
+
try {
|
|
87
|
+
const bot = makeBot(envelopes)
|
|
88
|
+
// The real policy, with sleeping stubbed out so a 3s flood-wait doesn't
|
|
89
|
+
// cost the suite 3 seconds. Everything else is production behaviour.
|
|
90
|
+
const robustApiCall = createRetryApiCall({ sleep: async () => {} })
|
|
91
|
+
await robustApiCall(() =>
|
|
92
|
+
bot.api.sendRichMessage(4242, { markdown: 'the answer' }),
|
|
93
|
+
).catch(() => {
|
|
94
|
+
threw = true
|
|
95
|
+
})
|
|
96
|
+
} finally {
|
|
97
|
+
spy.mockRestore()
|
|
98
|
+
}
|
|
99
|
+
const lines = written
|
|
100
|
+
.filter((l) => l.startsWith('tg-post '))
|
|
101
|
+
.map((l) => `2026-08-01T12:00:00Z gateway: ${l.trimEnd()}`)
|
|
102
|
+
return { lines, threw }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
vi.restoreAllMocks()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('#3931 — a retried-and-delivered reply raises no delivery-failure alert', () => {
|
|
110
|
+
it('429-then-ok: fleet-health sees zero reply-delivery-failures and does not escalate', async () => {
|
|
111
|
+
const { lines, threw } = await gatewayLinesForReply([FLOOD_429, OK_ENVELOPE])
|
|
112
|
+
|
|
113
|
+
// Ground truth: the reply WAS delivered. If this ever fails, the fixture
|
|
114
|
+
// stopped modelling the scenario and the assertions below mean nothing.
|
|
115
|
+
expect(threw).toBe(false)
|
|
116
|
+
expect(lines).toHaveLength(2)
|
|
117
|
+
expect(lines[1]).toContain('status=ok')
|
|
118
|
+
|
|
119
|
+
// THE outcome: the detector raises nothing, and the agent is not escalated.
|
|
120
|
+
const { gw_hits, findings } = detectGatewayFindings('alpha', lines.join('\n'))
|
|
121
|
+
expect(gw_hits['reply-delivery-failure']).toBe(0)
|
|
122
|
+
expect(findings.filter((f) => f.signal === 'reply-delivery-failure')).toHaveLength(0)
|
|
123
|
+
expect(scanAgent('alpha', '', lines.join('\n')).escalate).toBe(false)
|
|
124
|
+
|
|
125
|
+
// The evidence is not hidden — the rate-limit attempt is still on the log,
|
|
126
|
+
// honestly labelled as an attempt rather than an outcome.
|
|
127
|
+
expect(lines[0]).toContain('status=retry')
|
|
128
|
+
expect(lines[0]).toContain('code=429')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('an ok:true reply is untouched — no retry tier leaks onto the happy path', async () => {
|
|
132
|
+
const { lines, threw } = await gatewayLinesForReply([OK_ENVELOPE])
|
|
133
|
+
expect(threw).toBe(false)
|
|
134
|
+
expect(lines).toHaveLength(1)
|
|
135
|
+
expect(lines[0]).toContain('status=ok')
|
|
136
|
+
expect(lines[0]).toContain('err=- code=- desc=-')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('#3931 — a genuinely undelivered reply still escalates', () => {
|
|
141
|
+
it('a terminal 403 escalates', async () => {
|
|
142
|
+
const { lines, threw } = await gatewayLinesForReply([
|
|
143
|
+
{ ok: false, error_code: 403, description: 'Forbidden: bot was blocked by the user' },
|
|
144
|
+
])
|
|
145
|
+
expect(threw).toBe(true)
|
|
146
|
+
expect(lines).toHaveLength(1)
|
|
147
|
+
expect(lines[0]).toContain('status=err')
|
|
148
|
+
|
|
149
|
+
expect(detectGatewayFindings('alpha', lines.join('\n')).gw_hits['reply-delivery-failure']).toBe(1)
|
|
150
|
+
expect(scanAgent('alpha', '', lines.join('\n')).escalate).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('a 429 on EVERY attempt (retries exhausted, nothing landed) escalates exactly once', async () => {
|
|
154
|
+
const { lines, threw } = await gatewayLinesForReply([FLOOD_429])
|
|
155
|
+
expect(threw).toBe(true)
|
|
156
|
+
// Three attempts: two survivable, the last one terminal.
|
|
157
|
+
expect(lines).toHaveLength(3)
|
|
158
|
+
expect(lines.filter((l) => l.includes('status=retry'))).toHaveLength(2)
|
|
159
|
+
expect(lines.filter((l) => l.includes('status=err'))).toHaveLength(1)
|
|
160
|
+
|
|
161
|
+
// One alert for one lost answer — not one per attempt.
|
|
162
|
+
expect(detectGatewayFindings('alpha', lines.join('\n')).gw_hits['reply-delivery-failure']).toBe(1)
|
|
163
|
+
expect(scanAgent('alpha', '', lines.join('\n')).escalate).toBe(true)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('willRetryTelegramFailure — the predicate mirrors the retry loop', () => {
|
|
168
|
+
const ctx = { attempt: 0, maxRetries: 3, maxFloodSleepMs: 120_000 }
|
|
169
|
+
|
|
170
|
+
it('is false with no enclosing retry policy (a raw bot.api.* call is its own outcome)', () => {
|
|
171
|
+
expect(willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 3 }, undefined)).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('is false on the last attempt — the loop falls through to give-up', () => {
|
|
175
|
+
expect(
|
|
176
|
+
willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 3 }, { ...ctx, attempt: 2 }),
|
|
177
|
+
).toBe(false)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('is false for a 429 over the in-process sleep ceiling (FLOOD_WAIT_ACTIVE, not retried)', () => {
|
|
181
|
+
expect(willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 15908 }, ctx)).toBe(false)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('is true for a 429 under the ceiling and for a transient transport error', () => {
|
|
185
|
+
expect(willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 3 }, ctx)).toBe(true)
|
|
186
|
+
expect(willRetryTelegramFailure({ message: 'ECONNRESET' }, ctx)).toBe(true)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('is false for the error classes the loop rethrows immediately', () => {
|
|
190
|
+
expect(willRetryTelegramFailure({ errorCode: 400, message: 'Bad Request' }, ctx)).toBe(false)
|
|
191
|
+
expect(willRetryTelegramFailure({ errorCode: 403 }, ctx)).toBe(false)
|
|
192
|
+
// Local disk exhaustion must NOT look retryable — retrying it is what
|
|
193
|
+
// trips the flood ban in the first place (#2923).
|
|
194
|
+
expect(willRetryTelegramFailure({ message: 'ENOSPC: no space left on device' }, ctx)).toBe(false)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -14,7 +14,13 @@ import {
|
|
|
14
14
|
STEP_TIMER_MIN_MS,
|
|
15
15
|
type SessionActivityHeader,
|
|
16
16
|
} from "../tool-activity-summary.js";
|
|
17
|
-
import { STATUS_ROLLING_LINES, STATUS_LINE_MAX } from "../status-no-truncate.js";
|
|
17
|
+
import { STATUS_ROLLING_LINES, STATUS_LINE_MAX, NESTED_PREFIX } from "../status-no-truncate.js";
|
|
18
|
+
|
|
19
|
+
// The nested (foreground sub-agent) child-block indent. Asserted through the
|
|
20
|
+
// constant, never as a literal: #3668 replaced its ASCII spaces with U+2800
|
|
21
|
+
// (Telegram left-trims a leading ASCII/Zs run off a content line), and a test
|
|
22
|
+
// that hardcodes the bytes pins the defect instead of the behaviour.
|
|
23
|
+
const NP = NESTED_PREFIX;
|
|
18
24
|
import { COLLAPSE_SAFE_SEPARATOR } from "../card-format.js";
|
|
19
25
|
|
|
20
26
|
/** The pinned-card collapse separator (#3666) every status-card line now
|
|
@@ -352,29 +358,29 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
352
358
|
expect(out).toContain("~~_✓ Delegating: review the migration_~~");
|
|
353
359
|
expect(out).not.toContain("**→ Delegating");
|
|
354
360
|
// The live → step is the newest nested child line; earlier child = italic.
|
|
355
|
-
expect(out).toContain("
|
|
356
|
-
expect(out).toContain("
|
|
361
|
+
expect(out).toContain(NP + "~~_Reading schema.ts_~~");
|
|
362
|
+
expect(out).toContain(NP + "**→ Looking for foreign keys**");
|
|
357
363
|
});
|
|
358
364
|
|
|
359
365
|
it("windows the nested block to STATUS_ROLLING_LINES with a '↳ +N earlier…' header", () => {
|
|
360
366
|
const total = STATUS_ROLLING_LINES + 3;
|
|
361
367
|
const child = Array.from({ length: total }, (_, i) => `step ${i + 1}`);
|
|
362
368
|
const out = renderActivityFeedWithNested(["Delegating: x"], child)!;
|
|
363
|
-
expect(out).toContain(
|
|
369
|
+
expect(out).toContain(`${NP}_+${total - STATUS_ROLLING_LINES} earlier…_`);
|
|
364
370
|
// newest nested line is the live → step
|
|
365
|
-
expect(out).toContain(
|
|
371
|
+
expect(out).toContain(`${NP}**→ step ${total}**`);
|
|
366
372
|
// the oldest (collapsed) lines are not rendered verbatim
|
|
367
373
|
expect(out).not.toContain("step 1<");
|
|
368
374
|
});
|
|
369
375
|
|
|
370
376
|
it("renders the child block even when the parent feed is empty", () => {
|
|
371
377
|
const out = renderActivityFeedWithNested([], ["Reading a.ts"]);
|
|
372
|
-
expect(out).toBe("
|
|
378
|
+
expect(out).toBe(NP + "**→ Reading a.ts**");
|
|
373
379
|
});
|
|
374
380
|
|
|
375
381
|
it("markdown-escapes nested child text (emphasis specials only)", () => {
|
|
376
382
|
const out = renderActivityFeedWithNested(["Delegating: x"], ["touch a_b & 2 * 3"])!;
|
|
377
|
-
expect(out).toContain("
|
|
383
|
+
expect(out).toContain(NP + "**→ touch a\\_b & 2 \\* 3**");
|
|
378
384
|
});
|
|
379
385
|
|
|
380
386
|
it("final=true: the nested newest step renders done (✓), not in-progress (→)", () => {
|
|
@@ -383,7 +389,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
383
389
|
["Reading schema.ts", "Looking for foreign keys"],
|
|
384
390
|
true,
|
|
385
391
|
)!;
|
|
386
|
-
expect(out).toContain("
|
|
392
|
+
expect(out).toContain(NP + "~~_Looking for foreign keys_~~"); // newest now struck italic done
|
|
387
393
|
expect(out).not.toContain("→"); // no in-progress arrow in the finalized feed
|
|
388
394
|
});
|
|
389
395
|
|
|
@@ -400,7 +406,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
400
406
|
false,
|
|
401
407
|
" · 22s",
|
|
402
408
|
)!;
|
|
403
|
-
expect(out).toContain("
|
|
409
|
+
expect(out).toContain(NP + "**→ Looking for foreign keys · 22s**");
|
|
404
410
|
expect(out).not.toContain("Reading schema.ts · "); // only the newest line ticks
|
|
405
411
|
});
|
|
406
412
|
|
|
@@ -567,7 +573,7 @@ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
|
|
|
567
573
|
expect(out).not.toContain(`Parent ${i}`);
|
|
568
574
|
}
|
|
569
575
|
expect(out).toContain(`_✓ +${totalParent - STATUS_ROLLING_LINES} earlier…_`);
|
|
570
|
-
expect(out).toContain("
|
|
576
|
+
expect(out).toContain(NP + "**→ Child step B**");
|
|
571
577
|
});
|
|
572
578
|
|
|
573
579
|
it("many child lines → only the last STATUS_ROLLING_LINES child lines render with a ↳ +N earlier header", () => {
|
|
@@ -582,8 +588,8 @@ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
|
|
|
582
588
|
for (let i = 1; i < firstVisible; i++) {
|
|
583
589
|
expect(out).not.toContain(`Child ${i}`);
|
|
584
590
|
}
|
|
585
|
-
expect(out).toContain(
|
|
586
|
-
expect(out).toContain(
|
|
591
|
+
expect(out).toContain(`${NP}_+${totalChild - STATUS_ROLLING_LINES} earlier…_`);
|
|
592
|
+
expect(out).toContain(`${NP}**→ Child ${totalChild}**`);
|
|
587
593
|
});
|
|
588
594
|
|
|
589
595
|
it("STATUS_LINE_MAX=200: a 250-char child line is clipped to 200 (both surfaces)", () => {
|
|
@@ -600,7 +606,7 @@ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
|
|
|
600
606
|
const child = Array.from({ length: STATUS_ROLLING_LINES }, () => bigLine);
|
|
601
607
|
const out = renderActivityFeedWithNested(parent, child)!;
|
|
602
608
|
expect(out.length).toBeLessThanOrEqual(4096);
|
|
603
|
-
expect(out).toContain(
|
|
609
|
+
expect(out).toContain(NP);
|
|
604
610
|
});
|
|
605
611
|
});
|
|
606
612
|
|
|
@@ -678,11 +684,11 @@ describe("extreme-edge: single oversized line with markdown specials & && _ *",
|
|
|
678
684
|
expect(out.length).toBeLessThanOrEqual(4000);
|
|
679
685
|
expect(isValidMarkdown(out)).toBe(true);
|
|
680
686
|
// Must contain the nested prefix.
|
|
681
|
-
expect(out).toContain(
|
|
687
|
+
expect(out).toContain(NP);
|
|
682
688
|
// Regression guard: an operator-precedence bug made wrapperOverhead a string
|
|
683
689
|
// (NESTED_PREFIX + n) → NaN budget → slice(0, NaN) === "" → the child content
|
|
684
|
-
// was silently discarded, leaving only
|
|
685
|
-
// output still satisfies the toContain(
|
|
690
|
+
// was silently discarded, leaving only `${NESTED_PREFIX}**→ **`. The empty-wrapper
|
|
691
|
+
// output still satisfies the toContain(NP) check above, so assert that
|
|
686
692
|
// real child content actually survives.
|
|
687
693
|
expect(out).toContain("Child step");
|
|
688
694
|
expect(out.length).toBeGreaterThan(100);
|
|
@@ -1025,7 +1031,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
|
|
|
1025
1031
|
// Parent step is done-styled (child is the live step).
|
|
1026
1032
|
expect(out).toContain("~~_✓ Delegating: review_~~");
|
|
1027
1033
|
// Child step is the in-progress step.
|
|
1028
|
-
expect(out).toContain("
|
|
1034
|
+
expect(out).toContain(NP + "**→ Reading schema.ts**");
|
|
1029
1035
|
});
|
|
1030
1036
|
});
|
|
1031
1037
|
|
|
@@ -389,13 +389,14 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
389
389
|
* an already-escaped markdown escape is never sliced.
|
|
390
390
|
*/
|
|
391
391
|
const deepest = Math.max(1, escapedBody.length)
|
|
392
|
+
/** The collapsed parent trail, kept at every shrink level below level 0. */
|
|
393
|
+
const markerSection: CardSection = {
|
|
394
|
+
steps: [],
|
|
395
|
+
window: 1,
|
|
396
|
+
placeholder: parentMarker ?? undefined,
|
|
397
|
+
}
|
|
392
398
|
return fitCardToBudget((level) => {
|
|
393
399
|
if (level === 0) return { spec: fullSpec() }
|
|
394
|
-
const markerSection: CardSection = {
|
|
395
|
-
steps: [],
|
|
396
|
-
window: 1,
|
|
397
|
-
placeholder: parentMarker ?? undefined,
|
|
398
|
-
}
|
|
399
400
|
if (level < escapedBody.length) {
|
|
400
401
|
return {
|
|
401
402
|
spec: {
|
|
@@ -427,39 +428,66 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
427
428
|
}
|
|
428
429
|
}, deepest)
|
|
429
430
|
|
|
431
|
+
/** The deepest shrink level's spec, for a given already-wrapped body line. */
|
|
432
|
+
function deepestSpec(bodyLine: string): CardSpec {
|
|
433
|
+
return {
|
|
434
|
+
chrome,
|
|
435
|
+
sections: [markerSection, { steps: [], window: 1, placeholder: bodyLine }],
|
|
436
|
+
footer,
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Wrap an ALREADY-ESCAPED newest step in its bullet chrome. */
|
|
441
|
+
function wrapNewest(escaped: string): string {
|
|
442
|
+
return final
|
|
443
|
+
? `${bodyIndent}_✓ ${escaped}_`
|
|
444
|
+
: `${bodyIndent}**→ ${escaped}${liveSuffix}**`
|
|
445
|
+
}
|
|
446
|
+
|
|
430
447
|
/**
|
|
431
|
-
* The deepest shrink level's single bullet
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
448
|
+
* The deepest shrink level's single bullet: clip the RAW newest text to
|
|
449
|
+
* whatever the fixed chrome/footer leaves of `STATUS_CARD_CHAR_BUDGET`,
|
|
450
|
+
* escape, then re-measure — escaping can expand the string (`&` → `&`).
|
|
451
|
+
*
|
|
452
|
+
* Everything is measured by RENDERING the level's real spec (#3833). The
|
|
453
|
+
* previous arithmetic estimate charged `[...chrome, ...footer].join('\n')`,
|
|
454
|
+
* but the renderer is `stackCard`, which emits a collapse separator plus a
|
|
455
|
+
* two-space GFM hard break at every line boundary — 4 chars, not 1 — and it
|
|
456
|
+
* also charges the marker line and the section boundaries the estimate only
|
|
457
|
+
* partly counted. That under-count let a card render OVER the wire budget.
|
|
458
|
+
* Measuring with the renderer cannot drift from it by construction.
|
|
435
459
|
*/
|
|
436
460
|
function truncatedNewestLine(): string {
|
|
437
|
-
const fixedCost = [...chrome, ...footer].join('\n').length
|
|
438
461
|
const rawNewest = rawBody.length > 0 ? cleanStepLine(rawBody[rawBody.length - 1]) : ''
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
let
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
raw.length
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
newest = escapeMarkdown(raw)
|
|
462
|
+
const empty = wrapNewest('')
|
|
463
|
+
const fixedCost = renderCardSpec(deepestSpec(empty)).length
|
|
464
|
+
const budget = STATUS_CARD_CHAR_BUDGET - fixedCost
|
|
465
|
+
// Chrome alone is over budget (a 40k-char worker description, #3682). No
|
|
466
|
+
// amount of body clipping helps; `fitCardToBudget`'s hard-truncate backstop
|
|
467
|
+
// is what keeps the card under the wire limit in that case.
|
|
468
|
+
if (budget <= 0) return empty
|
|
469
|
+
let raw = clipRaw(rawNewest, budget)
|
|
470
|
+
let line = wrapNewest(escapeMarkdown(raw))
|
|
471
|
+
for (;;) {
|
|
472
|
+
const excess = renderCardSpec(deepestSpec(line)).length - STATUS_CARD_CHAR_BUDGET
|
|
473
|
+
if (excess <= 0 || raw.length === 0) return line
|
|
474
|
+
// Dropping k RAW chars drops at least k escaped chars (escaping never
|
|
475
|
+
// shrinks), so this strictly converges.
|
|
476
|
+
raw = clipRaw(raw, Math.max(0, raw.length - excess))
|
|
477
|
+
line = wrapNewest(escapeMarkdown(raw))
|
|
456
478
|
}
|
|
457
|
-
return final
|
|
458
|
-
? `${bodyIndent}_✓ ${newest}_`
|
|
459
|
-
: `${bodyIndent}**→ ${newest}${liveSuffix}**`
|
|
460
479
|
}
|
|
461
480
|
}
|
|
462
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Slice raw (pre-escape) text to `n` chars without leaving a dangling lone
|
|
484
|
+
* high surrogate — an unpaired surrogate is invalid UTF-16 on the wire.
|
|
485
|
+
*/
|
|
486
|
+
function clipRaw(text: string, n: number): string {
|
|
487
|
+
const out = text.slice(0, Math.max(0, n))
|
|
488
|
+
return /[\uD800-\uDBFF]$/.test(out) ? out.slice(0, -1) : out
|
|
489
|
+
}
|
|
490
|
+
|
|
463
491
|
/** Subtle horizontal rule between the running feed and the finished result. */
|
|
464
492
|
const WORKER_RESULT_RULE = '─────'
|
|
465
493
|
/** Hard cap on the terminal result paragraph. */
|
|
@@ -51,8 +51,9 @@ const FG_DISPATCH_PROMPT =
|
|
|
51
51
|
`one-line summary." Wait for the foreground sub-agent to finish, then send ` +
|
|
52
52
|
`me a brief reply telling me it's done.`;
|
|
53
53
|
|
|
54
|
-
// The nested foreground block renders each child line prefixed with
|
|
55
|
-
//
|
|
54
|
+
// The nested foreground block renders each child line prefixed with a three
|
|
55
|
+
// U+2800 indent then "↳" (NESTED_PREFIX; #3668 replaced the old ASCII spaces,
|
|
56
|
+
// which Telegram left-trims), newest as a bold "→ …" current step. Telegram
|
|
56
57
|
// strips the bold but keeps the literal ↳ / → glyphs in message text.
|
|
57
58
|
const NESTED_RE = /↳/;
|
|
58
59
|
|
|
@@ -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",
|