switchroom 0.20.9 → 0.20.11
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/bin/handoff-briefing.sh +57 -5
- package/bin/working-state-reload-hook.sh +262 -0
- package/dist/agent-scheduler/index.js +65 -2
- package/dist/auth-broker/index.js +204 -24
- package/dist/cli/notion-write-pretool.mjs +65 -2
- package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
- package/dist/cli/self-improve-stop.mjs +889 -7
- package/dist/cli/skill-validate-pretool.mjs +82 -3
- package/dist/cli/switchroom.js +3699 -2110
- package/dist/host-control/main.js +67 -4
- package/dist/vault/approvals/kernel-server.js +66 -3
- package/dist/vault/broker/server.js +66 -3
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +49 -0
- package/profiles/_shared/agent-self-service.md.hbs +15 -22
- package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
- package/profiles/_shared/dev-protocol.md.hbs +1 -1
- package/profiles/_shared/execution-discipline.md.hbs +4 -4
- package/profiles/_shared/vault-protocol.md.hbs +2 -18
- package/profiles/default/CLAUDE.md.hbs +3 -5
- package/telegram-plugin/auto-fallback-fleet.ts +37 -2
- package/telegram-plugin/dist/gateway/gateway.js +1414 -918
- package/telegram-plugin/fallback-card-collapse.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +11 -1
- package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
- package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
- package/telegram-plugin/gateway/gateway.ts +85 -90
- package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
- package/telegram-plugin/gateway/ipc-server.ts +28 -0
- package/telegram-plugin/gateway/narrative-lane.ts +33 -2
- package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
- package/telegram-plugin/gateway/privacy-reset.ts +87 -0
- package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
- package/telegram-plugin/gateway/privacy-state.ts +206 -0
- package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
- package/telegram-plugin/gateway/stale-pin-sweep.ts +48 -32
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
- package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
- package/telegram-plugin/throttle-tier.ts +12 -3
- package/telegram-plugin/turn-flush-safety.ts +97 -0
- package/vendor/hindsight-memory/CHANGELOG.md +31 -0
- package/vendor/hindsight-memory/hooks/hooks.json +2 -1
- package/vendor/hindsight-memory/scripts/retain.py +306 -0
- package/vendor/hindsight-memory/scripts/session_start.py +35 -8
- package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
- package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
- package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_durability.py +107 -0
|
@@ -573,6 +573,48 @@ export interface PostSkillProposalMessage {
|
|
|
573
573
|
evidence: string;
|
|
574
574
|
/** Full drafted skill bundle (SKILL.md + optional files). */
|
|
575
575
|
draft: Record<string, string>;
|
|
576
|
+
/**
|
|
577
|
+
* Provenance of the proposal — `"skill-synthesis"` (the weekly cron) or
|
|
578
|
+
* `"failure-synthesis"` (a skill drafted from an observed failure). Absent
|
|
579
|
+
* ⇒ the store defaults it to `"skill-synthesis"` (back-compat). Provenance
|
|
580
|
+
* is orthogonal to tier routing.
|
|
581
|
+
*/
|
|
582
|
+
origin?: "skill-synthesis" | "failure-synthesis";
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* `switchroom self-improve add-eval-case` asks the caller agent's gateway to
|
|
587
|
+
* persist an eval-case proposal and post a one-tap Approve/Dismiss card (RFC
|
|
588
|
+
* amendment §"corrections as eval cases"). Unlike a skill proposal, the
|
|
589
|
+
* Approve tap does NOT inject a model turn — the gateway runs the
|
|
590
|
+
* DETERMINISTIC `apply-eval-case` applier so the case lands byte-exact.
|
|
591
|
+
*
|
|
592
|
+
* Trust model identical to post_skill_proposal: per-agent socket, agentName
|
|
593
|
+
* validated, chat fenced to the agent's own chat.
|
|
594
|
+
*/
|
|
595
|
+
export interface PostEvalCaseProposalMessage {
|
|
596
|
+
type: "post_eval_case_proposal";
|
|
597
|
+
agentName: string;
|
|
598
|
+
/** Agent's own chat id (fenced server-side). */
|
|
599
|
+
chatId: string;
|
|
600
|
+
threadId?: number;
|
|
601
|
+
/** Target skill slug. */
|
|
602
|
+
skillSlug: string;
|
|
603
|
+
/** Absolute path to the owned skill bundle dir (resolved by the CLI). */
|
|
604
|
+
skillDir: string;
|
|
605
|
+
/** The eval case to append (prompt + optional expected/expectations/etc). */
|
|
606
|
+
case: {
|
|
607
|
+
prompt: string;
|
|
608
|
+
expected_output?: string;
|
|
609
|
+
files?: string[];
|
|
610
|
+
expectations?: string[];
|
|
611
|
+
source?: string;
|
|
612
|
+
id?: string;
|
|
613
|
+
};
|
|
614
|
+
/** Prompt fingerprint (dedup + provenance). */
|
|
615
|
+
fingerprint: string;
|
|
616
|
+
/** Route the case to the held-out sink instead of evals.json. */
|
|
617
|
+
heldOut: boolean;
|
|
576
618
|
}
|
|
577
619
|
|
|
578
620
|
/**
|
|
@@ -766,6 +808,7 @@ export type ClientToGateway =
|
|
|
766
808
|
| QuotaWallDetectedMessage
|
|
767
809
|
| SendOutboundMessage
|
|
768
810
|
| PostSkillProposalMessage
|
|
811
|
+
| PostEvalCaseProposalMessage
|
|
769
812
|
| RolloutStatusPostMessage
|
|
770
813
|
| RolloutStatusEditMessage
|
|
771
814
|
| QueryPendingPermissionMessage
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
QueryPendingPermissionMessage,
|
|
10
10
|
CheckPreApprovedMessage,
|
|
11
11
|
PostSkillProposalMessage,
|
|
12
|
+
PostEvalCaseProposalMessage,
|
|
12
13
|
OperatorEventForward,
|
|
13
14
|
PermissionRequestForward,
|
|
14
15
|
PtyPartialForward,
|
|
@@ -101,6 +102,12 @@ export interface IpcServerOptions {
|
|
|
101
102
|
* chat. Optional; gateways that don't surface proposals ignore it.
|
|
102
103
|
*/
|
|
103
104
|
onPostSkillProposal?: (client: IpcClient, msg: PostSkillProposalMessage) => void;
|
|
105
|
+
/**
|
|
106
|
+
* RFC amendment §"corrections as eval cases" — `add-eval-case` asks the
|
|
107
|
+
* gateway to persist an eval-case proposal and post its one-tap card.
|
|
108
|
+
* Optional; gateways that don't surface proposals ignore it.
|
|
109
|
+
*/
|
|
110
|
+
onPostEvalCaseProposal?: (client: IpcClient, msg: PostEvalCaseProposalMessage) => void;
|
|
104
111
|
/**
|
|
105
112
|
* RFC E §4.2 Cut 2 — Drive-write PreToolUse hook asks the gateway
|
|
106
113
|
* to register a kernel approval request + post a diff-preview
|
|
@@ -379,6 +386,23 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
|
|
|
379
386
|
&& (typeof m.threadId !== "number" || !Number.isInteger(m.threadId as number))) return false;
|
|
380
387
|
return true;
|
|
381
388
|
}
|
|
389
|
+
case "post_eval_case_proposal": {
|
|
390
|
+
// RFC amendment §"corrections as eval cases" — validate the wire shape;
|
|
391
|
+
// the gateway handler fences chatId to the agent's own chat.
|
|
392
|
+
if (typeof m.agentName !== "string"
|
|
393
|
+
|| !AGENT_NAME_RE.test(m.agentName as string)) return false;
|
|
394
|
+
if (typeof m.chatId !== "string" || (m.chatId as string).length === 0) return false;
|
|
395
|
+
if (typeof m.skillSlug !== "string" || (m.skillSlug as string).length === 0) return false;
|
|
396
|
+
if (typeof m.skillDir !== "string" || (m.skillDir as string).length === 0) return false;
|
|
397
|
+
if (typeof m.fingerprint !== "string" || (m.fingerprint as string).length === 0) return false;
|
|
398
|
+
if (typeof m.heldOut !== "boolean") return false;
|
|
399
|
+
if (typeof m.case !== "object" || m.case === null || Array.isArray(m.case)) return false;
|
|
400
|
+
if (typeof (m.case as Record<string, unknown>).prompt !== "string"
|
|
401
|
+
|| ((m.case as Record<string, unknown>).prompt as string).length === 0) return false;
|
|
402
|
+
if (m.threadId !== undefined
|
|
403
|
+
&& (typeof m.threadId !== "number" || !Number.isInteger(m.threadId as number))) return false;
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
382
406
|
case "quota_wall_detected": {
|
|
383
407
|
// wedge-watchdog detected the /rate-limit-options weekly-quota menu.
|
|
384
408
|
if (typeof m.agentName !== "string"
|
|
@@ -565,6 +589,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
565
589
|
onQueryPendingPermission,
|
|
566
590
|
onCheckPreApproved,
|
|
567
591
|
onPostSkillProposal,
|
|
592
|
+
onPostEvalCaseProposal,
|
|
568
593
|
onRequestDriveApproval,
|
|
569
594
|
onRequestMs365Approval,
|
|
570
595
|
onRequestConfigApproval,
|
|
@@ -733,6 +758,9 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
733
758
|
case "post_skill_proposal":
|
|
734
759
|
if (onPostSkillProposal) onPostSkillProposal(client, msg as PostSkillProposalMessage);
|
|
735
760
|
break;
|
|
761
|
+
case "post_eval_case_proposal":
|
|
762
|
+
if (onPostEvalCaseProposal) onPostEvalCaseProposal(client, msg as PostEvalCaseProposalMessage);
|
|
763
|
+
break;
|
|
736
764
|
case "quota_wall_detected":
|
|
737
765
|
if (onQuotaWallDetected) onQuotaWallDetected(client, msg as QuotaWallDetectedMessage);
|
|
738
766
|
break;
|
|
@@ -56,7 +56,7 @@ import {
|
|
|
56
56
|
appendActivityLabel, clipNarrative, formatStepSuffix, renderActivityFeedWithNested,
|
|
57
57
|
} from '../tool-activity-summary.js'
|
|
58
58
|
import { evaluatePostAnswerLiveness } from '../turn-liveness-floor.js'
|
|
59
|
-
import { isSilentSentinelCardOutcome } from '../turn-flush-safety.js'
|
|
59
|
+
import { isHollowGhostCardOutcome, isSilentSentinelCardOutcome } from '../turn-flush-safety.js'
|
|
60
60
|
import { clearActivityCardRecord, writeActivityCardRecord } from './activity-card-store.js'
|
|
61
61
|
import { chatKeyWithSuffix } from './chat-key.js'
|
|
62
62
|
import {
|
|
@@ -455,10 +455,18 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
|
|
|
455
455
|
// stays in view when the feed scrolls past. Keyed to the same
|
|
456
456
|
// status-key the canonical turn-end (purgeReactionTracking) unpins.
|
|
457
457
|
// Fire-and-forget; the single-owner reconcile keeps state consistent.
|
|
458
|
+
// Pass `thread` as the 4th arg so the persisted row keeps the forum
|
|
459
|
+
// topic (D4): without it a forum-topic foreground card orphaned by a
|
|
460
|
+
// crash lands a threadId-less row, and the sweep cannot aim
|
|
461
|
+
// `unpinAllForumTopicMessages` at the right topic. Mirrors the worker
|
|
462
|
+
// feed's `reconcilePin` (gateway.ts). It does NOT change the pinKey —
|
|
463
|
+
// `statusKey(chat, thread)` already folds the topic into the key; the
|
|
464
|
+
// 4th arg only threads the topic into the claim + durable row.
|
|
458
465
|
void reconcileStatusPin(
|
|
459
466
|
`fg:${statusKey(chat, thread)}`,
|
|
460
467
|
chat,
|
|
461
468
|
{ pinned: true, messageId: sent.message_id },
|
|
469
|
+
thread,
|
|
462
470
|
)
|
|
463
471
|
} else {
|
|
464
472
|
const id = turn.activityMessageId
|
|
@@ -900,7 +908,30 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
|
|
|
900
908
|
capturedText: turn.capturedText,
|
|
901
909
|
finalAnswerEverDelivered: turn.finalAnswerEverDelivered,
|
|
902
910
|
})
|
|
903
|
-
|
|
911
|
+
// #45 — hollow-ghost suppression. The sibling to the #4348 sentinel gate:
|
|
912
|
+
// a turn that ended having done ZERO surfaced tool work, never called
|
|
913
|
+
// reply, delivered no final answer, surfaced NO narration, and emitted no
|
|
914
|
+
// captured/reply text adopted an activity card at turn start that never
|
|
915
|
+
// got any content — the contentless `🤖 Agent · done · 0 tools · Ns`
|
|
916
|
+
// record Ken reported (a card opened by the liveness timer that stayed
|
|
917
|
+
// empty). The sentinel gate can't catch it (there is no NO_REPLY/
|
|
918
|
+
// HEARTBEAT_OK text to match; the flush classifies it `empty-text`, not
|
|
919
|
+
// `silent-marker`), so DELETE the empty card here instead of finalizing
|
|
920
|
+
// it. Deterministic (pure predicate over already-tracked turn fields) and
|
|
921
|
+
// normal-case-safe: any surfaced tool step, any rendered narrative line
|
|
922
|
+
// (`mirrorLines` — content the user actually saw), any reply, any captured
|
|
923
|
+
// text, or a delivered answer keeps the card. The `finalHtmlOverride`
|
|
924
|
+
// finalize path is only taken by the foreground handoff-clear (which fires
|
|
925
|
+
// on a delivered final answer), so this gate never contends with it.
|
|
926
|
+
const hollowGhostTurn = isHollowGhostCardOutcome({
|
|
927
|
+
replyCalled: turn.replyCalled,
|
|
928
|
+
labeledToolCount: turn.labeledToolCount,
|
|
929
|
+
mirrorLines: turn.mirrorLines,
|
|
930
|
+
capturedText: turn.capturedText,
|
|
931
|
+
lastReplyText: turn.lastReplyText,
|
|
932
|
+
finalAnswerEverDelivered: turn.finalAnswerEverDelivered,
|
|
933
|
+
})
|
|
934
|
+
if (CLEAR_STATUS_ON_COMPLETION || silentSentinelTurn || hollowGhostTurn) {
|
|
904
935
|
try {
|
|
905
936
|
await robustApiCall(
|
|
906
937
|
() => bot.api.deleteMessage(chat, id),
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the session-start privacy reset (PR3 of `/private` `/public`).
|
|
3
|
+
*
|
|
4
|
+
* Covers the two collaborating pieces:
|
|
5
|
+
* - `resetPrivacyOnGenuineSessionStart` (privacy-state.ts): always truncates
|
|
6
|
+
* to public; fires `onOpenIntervalReset` ONLY on a private→public
|
|
7
|
+
* transition (a leftover OPEN interval).
|
|
8
|
+
* - `makePrivacyResetForNewSession` (privacy-reset.ts): binds the loud-send
|
|
9
|
+
* primitive so the alert is emitted exactly when — and only when — that
|
|
10
|
+
* transition happened.
|
|
11
|
+
*
|
|
12
|
+
* The three spec scenarios:
|
|
13
|
+
* 1. leftover open interval → file reset to empty AND loud alert emitted
|
|
14
|
+
* 2. already public → file reset, NO alert
|
|
15
|
+
* 3. compact / no-reset path → state PRESERVED unchanged, NO alert
|
|
16
|
+
*
|
|
17
|
+
* Run with: npx vitest run telegram-plugin/gateway/privacy-reset.test.ts
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
21
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
|
|
22
|
+
import { tmpdir } from 'node:os'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import {
|
|
25
|
+
openPrivateInterval,
|
|
26
|
+
readPrivacyState,
|
|
27
|
+
resetPrivacyOnGenuineSessionStart,
|
|
28
|
+
emptyPrivacyState,
|
|
29
|
+
privacyStatePath,
|
|
30
|
+
SESSION_RESET_ALERT,
|
|
31
|
+
} from './privacy-state.js'
|
|
32
|
+
import {
|
|
33
|
+
makePrivacyResetForNewSession,
|
|
34
|
+
bootRestoresTranscript,
|
|
35
|
+
isContinueRestoreBoot,
|
|
36
|
+
} from './privacy-reset.js'
|
|
37
|
+
|
|
38
|
+
describe('privacy session-start reset', () => {
|
|
39
|
+
let dir: string
|
|
40
|
+
let stderrSpy: ReturnType<typeof vi.spyOn>
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
dir = mkdtempSync(join(tmpdir(), 'privacy-reset-'))
|
|
44
|
+
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
stderrSpy.mockRestore()
|
|
49
|
+
rmSync(dir, { recursive: true, force: true })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const onDisk = () => JSON.parse(readFileSync(privacyStatePath(dir), 'utf8'))
|
|
53
|
+
|
|
54
|
+
describe('resetPrivacyOnGenuineSessionStart', () => {
|
|
55
|
+
it('scenario 1: a leftover OPEN interval → file reset to empty AND callback fired', () => {
|
|
56
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'), dir)
|
|
57
|
+
const onReset = vi.fn()
|
|
58
|
+
const result = resetPrivacyOnGenuineSessionStart({ stateDir: dir, onOpenIntervalReset: onReset })
|
|
59
|
+
expect(result.hadOpenInterval).toBe(true)
|
|
60
|
+
expect(onReset).toHaveBeenCalledTimes(1)
|
|
61
|
+
expect(onDisk()).toEqual(emptyPrivacyState())
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('scenario 2: already public → file reset (idempotent), NO callback', () => {
|
|
65
|
+
const onReset = vi.fn()
|
|
66
|
+
const result = resetPrivacyOnGenuineSessionStart({ stateDir: dir, onOpenIntervalReset: onReset })
|
|
67
|
+
expect(result.hadOpenInterval).toBe(false)
|
|
68
|
+
expect(onReset).not.toHaveBeenCalled()
|
|
69
|
+
expect(onDisk()).toEqual(emptyPrivacyState())
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('scenario 2b: a CLOSED-only history is treated as public → NO callback, reset to empty', () => {
|
|
73
|
+
writeFileSync(
|
|
74
|
+
privacyStatePath(dir),
|
|
75
|
+
JSON.stringify({
|
|
76
|
+
version: 1,
|
|
77
|
+
intervals: [{ start: '2026-08-06T02:00:00.000Z', end: '2026-08-06T02:05:00.000Z' }],
|
|
78
|
+
}),
|
|
79
|
+
'utf8',
|
|
80
|
+
)
|
|
81
|
+
const onReset = vi.fn()
|
|
82
|
+
const result = resetPrivacyOnGenuineSessionStart({ stateDir: dir, onOpenIntervalReset: onReset })
|
|
83
|
+
expect(result.hadOpenInterval).toBe(false)
|
|
84
|
+
expect(onReset).not.toHaveBeenCalled()
|
|
85
|
+
expect(onDisk().intervals).toEqual([])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('scenario 3: a NON-reset path (compact / resume) preserves state — no reset is called', () => {
|
|
89
|
+
// The compact/resume paths are EXEMPT by construction: the gateway simply
|
|
90
|
+
// never calls the reset there. This asserts the invariant that, absent a
|
|
91
|
+
// reset, an open private interval survives untouched.
|
|
92
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'), dir)
|
|
93
|
+
const before = readPrivacyState(dir)
|
|
94
|
+
// ...compaction happens here in the real gateway; privacy is not touched...
|
|
95
|
+
const after = readPrivacyState(dir)
|
|
96
|
+
expect(after).toEqual(before)
|
|
97
|
+
expect(after.intervals).toEqual([{ start: '2026-08-06T02:00:00.000Z', end: null }])
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('makePrivacyResetForNewSession (loud-send binding)', () => {
|
|
102
|
+
// The factory uses the DEFAULT state-dir resolver (no stateDir param), so
|
|
103
|
+
// point TELEGRAM_STATE_DIR at the temp dir for the duration of this test.
|
|
104
|
+
let prevEnv: string | undefined
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
prevEnv = process.env.TELEGRAM_STATE_DIR
|
|
107
|
+
process.env.TELEGRAM_STATE_DIR = dir
|
|
108
|
+
})
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
if (prevEnv === undefined) delete process.env.TELEGRAM_STATE_DIR
|
|
111
|
+
else process.env.TELEGRAM_STATE_DIR = prevEnv
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('emits the loud SESSION_RESET_ALERT to the chat only on a private→public transition', () => {
|
|
115
|
+
const send = vi.fn<(chatId: string, threadId: number | undefined, text: string) => void>()
|
|
116
|
+
const reset = makePrivacyResetForNewSession(send)
|
|
117
|
+
|
|
118
|
+
// Already public: no alert.
|
|
119
|
+
reset('chat-1', undefined)
|
|
120
|
+
expect(send).not.toHaveBeenCalled()
|
|
121
|
+
expect(onDisk()).toEqual(emptyPrivacyState())
|
|
122
|
+
|
|
123
|
+
// Now go private, then reset: exactly one loud alert with the pinned text.
|
|
124
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'))
|
|
125
|
+
reset('chat-1', 77)
|
|
126
|
+
expect(send).toHaveBeenCalledTimes(1)
|
|
127
|
+
expect(send).toHaveBeenCalledWith('chat-1', 77, SESSION_RESET_ALERT)
|
|
128
|
+
expect(onDisk()).toEqual(emptyPrivacyState())
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('boot reset gating on --continue transcript-restore (MAJOR fix)', () => {
|
|
133
|
+
it('bootRestoresTranscript: continue/auto restore; handoff/none/undefined do not; force-fresh overrides', () => {
|
|
134
|
+
expect(bootRestoresTranscript({ resumeMode: 'continue', forceFresh: false })).toBe(true)
|
|
135
|
+
expect(bootRestoresTranscript({ resumeMode: 'auto', forceFresh: false })).toBe(true)
|
|
136
|
+
expect(bootRestoresTranscript({ resumeMode: 'handoff', forceFresh: false })).toBe(false)
|
|
137
|
+
expect(bootRestoresTranscript({ resumeMode: 'none', forceFresh: false })).toBe(false)
|
|
138
|
+
expect(bootRestoresTranscript({ resumeMode: undefined, forceFresh: false })).toBe(false)
|
|
139
|
+
// A /new /reset force-fresh boot is genuinely fresh even under continue/auto.
|
|
140
|
+
expect(bootRestoresTranscript({ resumeMode: 'continue', forceFresh: true })).toBe(false)
|
|
141
|
+
expect(bootRestoresTranscript({ resumeMode: 'auto', forceFresh: true })).toBe(false)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('isContinueRestoreBoot reads SWITCHROOM_RESUME_MODE / SWITCHROOM_FORCE_FRESH', () => {
|
|
145
|
+
const saved = { ...process.env }
|
|
146
|
+
try {
|
|
147
|
+
delete process.env.SWITCHROOM_FORCE_FRESH
|
|
148
|
+
process.env.SWITCHROOM_RESUME_MODE = 'continue'
|
|
149
|
+
expect(isContinueRestoreBoot(null)).toBe(true)
|
|
150
|
+
process.env.SWITCHROOM_RESUME_MODE = 'handoff'
|
|
151
|
+
expect(isContinueRestoreBoot(null)).toBe(false)
|
|
152
|
+
// force-fresh env override wins over continue.
|
|
153
|
+
process.env.SWITCHROOM_RESUME_MODE = 'continue'
|
|
154
|
+
process.env.SWITCHROOM_FORCE_FRESH = '1'
|
|
155
|
+
expect(isContinueRestoreBoot(null)).toBe(false)
|
|
156
|
+
} finally {
|
|
157
|
+
process.env = saved
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Emulates the guarded boot call site: `if (target && !isContinueRestoreBoot(dir)) reset(...)`.
|
|
162
|
+
// This is the exact failure scenario the reviewer flagged.
|
|
163
|
+
function guardedBootReset(reset: (c: string, t: number | undefined) => void): void {
|
|
164
|
+
if (!isContinueRestoreBoot(null)) reset('boot-chat', undefined)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
it('continue-restore boot: NO reset, NO alert, open interval PRESERVED', () => {
|
|
168
|
+
const saved = { ...process.env }
|
|
169
|
+
try {
|
|
170
|
+
process.env.TELEGRAM_STATE_DIR = dir
|
|
171
|
+
delete process.env.SWITCHROOM_FORCE_FRESH
|
|
172
|
+
process.env.SWITCHROOM_RESUME_MODE = 'continue'
|
|
173
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'))
|
|
174
|
+
const send = vi.fn<(chatId: string, threadId: number | undefined, text: string) => void>()
|
|
175
|
+
guardedBootReset(makePrivacyResetForNewSession(send))
|
|
176
|
+
expect(send).not.toHaveBeenCalled()
|
|
177
|
+
expect(onDisk().intervals).toEqual([{ start: '2026-08-06T02:00:00.000Z', end: null }])
|
|
178
|
+
} finally {
|
|
179
|
+
process.env = saved
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('fresh (handoff) boot: reset FIRES, loud alert emitted, interval cleared', () => {
|
|
184
|
+
const saved = { ...process.env }
|
|
185
|
+
try {
|
|
186
|
+
process.env.TELEGRAM_STATE_DIR = dir
|
|
187
|
+
delete process.env.SWITCHROOM_FORCE_FRESH
|
|
188
|
+
process.env.SWITCHROOM_RESUME_MODE = 'handoff'
|
|
189
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'))
|
|
190
|
+
const send = vi.fn<(chatId: string, threadId: number | undefined, text: string) => void>()
|
|
191
|
+
guardedBootReset(makePrivacyResetForNewSession(send))
|
|
192
|
+
expect(send).toHaveBeenCalledTimes(1)
|
|
193
|
+
expect(send).toHaveBeenCalledWith('boot-chat', undefined, SESSION_RESET_ALERT)
|
|
194
|
+
expect(onDisk()).toEqual(emptyPrivacyState())
|
|
195
|
+
} finally {
|
|
196
|
+
process.env = saved
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('continue-mode /new force-fresh boot: reset FIRES (genuinely fresh)', () => {
|
|
201
|
+
const saved = { ...process.env }
|
|
202
|
+
try {
|
|
203
|
+
process.env.TELEGRAM_STATE_DIR = dir
|
|
204
|
+
process.env.SWITCHROOM_RESUME_MODE = 'continue'
|
|
205
|
+
process.env.SWITCHROOM_FORCE_FRESH = '1'
|
|
206
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'))
|
|
207
|
+
const send = vi.fn<(chatId: string, threadId: number | undefined, text: string) => void>()
|
|
208
|
+
guardedBootReset(makePrivacyResetForNewSession(send))
|
|
209
|
+
expect(send).toHaveBeenCalledTimes(1)
|
|
210
|
+
expect(onDisk()).toEqual(emptyPrivacyState())
|
|
211
|
+
} finally {
|
|
212
|
+
process.env = saved
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway-owned session-start privacy reset (PR3 of the `/private` `/public`
|
|
3
|
+
* feature).
|
|
4
|
+
*
|
|
5
|
+
* A genuine new session — cold boot / crash / planned restart, and a `/clear`
|
|
6
|
+
* (which starts a new logical session) — must return memory writing to its
|
|
7
|
+
* PUBLIC default. If the previous session ended while still private (an OPEN
|
|
8
|
+
* interval was left behind), the operator is told loudly, so they are never
|
|
9
|
+
* silently surprised that a stretch they thought was private is now being
|
|
10
|
+
* recorded again.
|
|
11
|
+
*
|
|
12
|
+
* The gateway is the SINGLE owner of this reset+announce (the Python
|
|
13
|
+
* `session_start.py` is deliberately NOT wired to it), which eliminates the
|
|
14
|
+
* two-owner clear/announce race. Reconnect paths (`bridge-reconnect`) and the
|
|
15
|
+
* compaction / resume / continue paths are EXEMPT by construction: they
|
|
16
|
+
* reattach to a PERSISTING session, so this is simply never called from them —
|
|
17
|
+
* there is no `source`-string branch to get wrong.
|
|
18
|
+
*
|
|
19
|
+
* This thin factory exists so gateway.ts holds only the loud-send primitive
|
|
20
|
+
* (which needs the bot handle) and the call sites stay one-liners; the
|
|
21
|
+
* reset/announce decision lives in `privacy-state.ts` and is unit-tested there.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync } from 'node:fs'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
import { resetPrivacyOnGenuineSessionStart, SESSION_RESET_ALERT } from './privacy-state.js'
|
|
28
|
+
|
|
29
|
+
/** Posts the loud (notification-ON) reset alert. Provided by the gateway. */
|
|
30
|
+
export type LoudResetSender = (
|
|
31
|
+
chatId: string,
|
|
32
|
+
threadId: number | undefined,
|
|
33
|
+
text: string,
|
|
34
|
+
) => void
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Bind the loud-send primitive into a `(chatId, threadId)` reset function.
|
|
38
|
+
* Calling the returned function always resets privacy to public; it invokes
|
|
39
|
+
* `send` (the loud alert) only when a private→public transition actually
|
|
40
|
+
* happened.
|
|
41
|
+
*/
|
|
42
|
+
export function makePrivacyResetForNewSession(
|
|
43
|
+
send: LoudResetSender,
|
|
44
|
+
): (chatId: string, threadId: number | undefined) => void {
|
|
45
|
+
return (chatId, threadId) => {
|
|
46
|
+
resetPrivacyOnGenuineSessionStart({
|
|
47
|
+
onOpenIntervalReset: () => send(chatId, threadId, SESSION_RESET_ALERT),
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Does this boot RESTORE the previous Claude transcript (via `--continue`)?
|
|
54
|
+
*
|
|
55
|
+
* The boot reset must NOT fire when the transcript persists: a `continue`/`auto`
|
|
56
|
+
* agent whose container restarts mid-`/private` task replays the SAME transcript,
|
|
57
|
+
* so flipping privacy to public would resume storing that very task (the FIX-3
|
|
58
|
+
* mid-task-reset shape). Mirrors `decideBootBriefing` (boot-briefing-builder.ts):
|
|
59
|
+
* `continue` always replays; `auto` MAY replay (only when the JSONL is under
|
|
60
|
+
* `resume_max_bytes`) — the gateway can't see the inner CONTINUE_FLAG, so `auto`
|
|
61
|
+
* is treated conservatively as a restore (privacy persists; safe direction — an
|
|
62
|
+
* over-retained private interval merely excludes more, never leaks). A `/new`
|
|
63
|
+
* `/reset` force-fresh boot is genuinely fresh even under continue/auto.
|
|
64
|
+
*/
|
|
65
|
+
export function bootRestoresTranscript(opts: {
|
|
66
|
+
resumeMode: string | undefined
|
|
67
|
+
forceFresh: boolean
|
|
68
|
+
}): boolean {
|
|
69
|
+
if (opts.forceFresh) return false
|
|
70
|
+
return opts.resumeMode === 'continue' || opts.resumeMode === 'auto'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Env/marker-reading convenience over `bootRestoresTranscript` for the gateway
|
|
75
|
+
* boot site. Reads `SWITCHROOM_RESUME_MODE` and the force-fresh signal
|
|
76
|
+
* (`SWITCHROOM_FORCE_FRESH`, hoisted by start.sh, with the `.force-fresh-session`
|
|
77
|
+
* marker as the non-docker fallback — same pair `boot-briefing-wiring.ts` uses).
|
|
78
|
+
*/
|
|
79
|
+
export function isContinueRestoreBoot(agentDir: string | null): boolean {
|
|
80
|
+
const forceFresh =
|
|
81
|
+
process.env.SWITCHROOM_FORCE_FRESH === '1' ||
|
|
82
|
+
(agentDir != null && existsSync(join(agentDir, '.force-fresh-session')))
|
|
83
|
+
return bootRestoresTranscript({
|
|
84
|
+
resumeMode: process.env.SWITCHROOM_RESUME_MODE,
|
|
85
|
+
forceFresh,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for privacy-state.ts — the gateway side of the `/private`
|
|
3
|
+
* `/public` feature (PR2: open/close/read/reset + the state-file contract).
|
|
4
|
+
*
|
|
5
|
+
* These lock in the on-disk contract shared with the Python retain side
|
|
6
|
+
* (`vendor/hindsight-memory`): the exact schema, `end: null` = open interval,
|
|
7
|
+
* idempotent open/close, atomic writes (no torn file), and best-effort,
|
|
8
|
+
* corrupt-tolerant reads.
|
|
9
|
+
*
|
|
10
|
+
* Run with: npx vitest run telegram-plugin/gateway/privacy-state.test.ts
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
14
|
+
import { mkdtempSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import {
|
|
18
|
+
readPrivacyState,
|
|
19
|
+
openPrivateInterval,
|
|
20
|
+
closePrivateInterval,
|
|
21
|
+
resetToPublic,
|
|
22
|
+
isPrivate,
|
|
23
|
+
privacyStatePath,
|
|
24
|
+
emptyPrivacyState,
|
|
25
|
+
type PrivacyState,
|
|
26
|
+
} from './privacy-state.js'
|
|
27
|
+
|
|
28
|
+
describe('privacy-state', () => {
|
|
29
|
+
let dir: string
|
|
30
|
+
let stderrSpy: ReturnType<typeof vi.spyOn>
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
dir = mkdtempSync(join(tmpdir(), 'privacy-state-'))
|
|
34
|
+
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
stderrSpy.mockRestore()
|
|
39
|
+
rmSync(dir, { recursive: true, force: true })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const path = () => privacyStatePath(dir)
|
|
43
|
+
const onDisk = (): PrivacyState => JSON.parse(readFileSync(path(), 'utf8'))
|
|
44
|
+
|
|
45
|
+
describe('readPrivacyState — best-effort, corrupt-tolerant', () => {
|
|
46
|
+
it('returns the public default when the file is missing', () => {
|
|
47
|
+
expect(readPrivacyState(dir)).toEqual(emptyPrivacyState())
|
|
48
|
+
expect(isPrivate(readPrivacyState(dir))).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('does not throw and returns the default on corrupt JSON', () => {
|
|
52
|
+
writeFileSync(path(), '{ this is not json', 'utf8')
|
|
53
|
+
expect(() => readPrivacyState(dir)).not.toThrow()
|
|
54
|
+
expect(readPrivacyState(dir)).toEqual(emptyPrivacyState())
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns the default when intervals is not an array', () => {
|
|
58
|
+
writeFileSync(path(), JSON.stringify({ version: 1, intervals: 'nope' }), 'utf8')
|
|
59
|
+
expect(readPrivacyState(dir).intervals).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('filters out malformed intervals but keeps valid ones', () => {
|
|
63
|
+
writeFileSync(
|
|
64
|
+
path(),
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
version: 1,
|
|
67
|
+
intervals: [
|
|
68
|
+
{ start: '2026-08-06T02:00:00.000Z', end: '2026-08-06T02:05:00.000Z' },
|
|
69
|
+
{ start: 42, end: null }, // bad start
|
|
70
|
+
{ end: null }, // missing start
|
|
71
|
+
{ start: '2026-08-06T02:10:00.000Z', end: null }, // valid open
|
|
72
|
+
],
|
|
73
|
+
}),
|
|
74
|
+
'utf8',
|
|
75
|
+
)
|
|
76
|
+
const state = readPrivacyState(dir)
|
|
77
|
+
expect(state.intervals).toHaveLength(2)
|
|
78
|
+
expect(state.intervals[1]).toEqual({ start: '2026-08-06T02:10:00.000Z', end: null })
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('openPrivateInterval', () => {
|
|
83
|
+
it('appends a single open interval and writes the exact contract schema', () => {
|
|
84
|
+
const now = new Date('2026-08-06T02:00:25.558Z')
|
|
85
|
+
openPrivateInterval(now, dir)
|
|
86
|
+
expect(onDisk()).toEqual({
|
|
87
|
+
version: 1,
|
|
88
|
+
intervals: [{ start: '2026-08-06T02:00:25.558Z', end: null }],
|
|
89
|
+
})
|
|
90
|
+
expect(isPrivate(readPrivacyState(dir))).toBe(true)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('is idempotent — a second /private while open does not stack', () => {
|
|
94
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'), dir)
|
|
95
|
+
openPrivateInterval(new Date('2026-08-06T02:03:00.000Z'), dir)
|
|
96
|
+
const state = readPrivacyState(dir)
|
|
97
|
+
expect(state.intervals).toHaveLength(1)
|
|
98
|
+
expect(state.intervals[0]).toEqual({ start: '2026-08-06T02:00:00.000Z', end: null })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('opens a fresh interval after a prior one was closed', () => {
|
|
102
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'), dir)
|
|
103
|
+
closePrivateInterval(new Date('2026-08-06T02:05:00.000Z'), dir)
|
|
104
|
+
openPrivateInterval(new Date('2026-08-06T02:10:00.000Z'), dir)
|
|
105
|
+
const state = readPrivacyState(dir)
|
|
106
|
+
expect(state.intervals).toHaveLength(2)
|
|
107
|
+
expect(state.intervals[0].end).toBe('2026-08-06T02:05:00.000Z')
|
|
108
|
+
expect(state.intervals[1]).toEqual({ start: '2026-08-06T02:10:00.000Z', end: null })
|
|
109
|
+
expect(isPrivate(state)).toBe(true)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('closePrivateInterval', () => {
|
|
114
|
+
it('sets end on the open interval', () => {
|
|
115
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'), dir)
|
|
116
|
+
closePrivateInterval(new Date('2026-08-06T02:05:10.100Z'), dir)
|
|
117
|
+
expect(onDisk().intervals[0]).toEqual({
|
|
118
|
+
start: '2026-08-06T02:00:00.000Z',
|
|
119
|
+
end: '2026-08-06T02:05:10.100Z',
|
|
120
|
+
})
|
|
121
|
+
expect(isPrivate(readPrivacyState(dir))).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('is idempotent — /public while already public is a no-op (no throw, no file)', () => {
|
|
125
|
+
expect(() => closePrivateInterval(new Date(), dir)).not.toThrow()
|
|
126
|
+
// No open interval existed → nothing was written.
|
|
127
|
+
expect(existsSync(path())).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('does not reopen or touch an already-closed interval', () => {
|
|
131
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'), dir)
|
|
132
|
+
closePrivateInterval(new Date('2026-08-06T02:05:00.000Z'), dir)
|
|
133
|
+
closePrivateInterval(new Date('2026-08-06T02:09:00.000Z'), dir)
|
|
134
|
+
expect(onDisk().intervals[0].end).toBe('2026-08-06T02:05:00.000Z')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('resetToPublic', () => {
|
|
139
|
+
it('truncates to the empty public default', () => {
|
|
140
|
+
openPrivateInterval(new Date(), dir)
|
|
141
|
+
resetToPublic(dir)
|
|
142
|
+
expect(onDisk()).toEqual({ version: 1, intervals: [] })
|
|
143
|
+
expect(isPrivate(readPrivacyState(dir))).toBe(false)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('atomic writes', () => {
|
|
148
|
+
it('leaves no temp file behind after a write', () => {
|
|
149
|
+
openPrivateInterval(new Date(), dir)
|
|
150
|
+
closePrivateInterval(new Date(), dir)
|
|
151
|
+
// Exactly one file — the state file — no lingering `.tmp`/`.new` sibling.
|
|
152
|
+
const entries = readdirSync(dir)
|
|
153
|
+
expect(entries).toEqual(['privacy-state.json'])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('a concurrent reader only ever sees a complete document', () => {
|
|
157
|
+
// Because the write is rename-based, the file at the destination path is
|
|
158
|
+
// always a fully-formed JSON doc — parsing it after any op never throws.
|
|
159
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'), dir)
|
|
160
|
+
expect(() => JSON.parse(readFileSync(path(), 'utf8'))).not.toThrow()
|
|
161
|
+
closePrivateInterval(new Date('2026-08-06T02:05:00.000Z'), dir)
|
|
162
|
+
expect(() => JSON.parse(readFileSync(path(), 'utf8'))).not.toThrow()
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
})
|