switchroom 0.20.10 → 0.20.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/agent-scheduler/index.js +68 -3
  2. package/dist/auth-broker/index.js +211 -46
  3. package/dist/cli/notion-write-pretool.mjs +68 -3
  4. package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
  5. package/dist/cli/self-improve-stop.mjs +889 -7
  6. package/dist/cli/skill-validate-pretool.mjs +82 -3
  7. package/dist/cli/switchroom.js +5063 -2961
  8. package/dist/host-control/main.js +93 -27
  9. package/dist/vault/approvals/kernel-server.js +92 -26
  10. package/dist/vault/broker/server.js +92 -26
  11. package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
  12. package/package.json +1 -1
  13. package/profiles/_shared/agent-self-service.md.hbs +15 -22
  14. package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
  15. package/profiles/_shared/dev-protocol.md.hbs +1 -1
  16. package/profiles/_shared/execution-discipline.md.hbs +4 -4
  17. package/profiles/_shared/vault-protocol.md.hbs +2 -18
  18. package/profiles/default/CLAUDE.md.hbs +3 -5
  19. package/skills/switchroom-architecture/telegram.md +0 -1
  20. package/skills/switchroom-cli/SKILL.md +0 -1
  21. package/telegram-plugin/README.md +2 -11
  22. package/telegram-plugin/auto-fallback-fleet.ts +37 -2
  23. package/telegram-plugin/bridge/bridge.ts +0 -12
  24. package/telegram-plugin/chat-lock.ts +1 -1
  25. package/telegram-plugin/dist/bridge/bridge.js +0 -12
  26. package/telegram-plugin/dist/gateway/gateway.js +1454 -976
  27. package/telegram-plugin/dist/server.js +0 -12
  28. package/telegram-plugin/fallback-card-collapse.ts +1 -0
  29. package/telegram-plugin/gateway/auth-command.ts +11 -1
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
  31. package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
  32. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
  33. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
  34. package/telegram-plugin/gateway/gateway.ts +109 -149
  35. package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
  36. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  37. package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
  38. package/telegram-plugin/gateway/narrative-lane.ts +33 -2
  39. package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
  40. package/telegram-plugin/gateway/privacy-reset.ts +87 -0
  41. package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
  42. package/telegram-plugin/gateway/privacy-state.ts +206 -0
  43. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
  44. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
  45. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
  46. package/telegram-plugin/gateway/stale-pin-sweep.ts +52 -35
  47. package/telegram-plugin/gateway/status-pin-store.ts +10 -9
  48. package/telegram-plugin/gateway/stream-render.ts +4 -4
  49. package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
  50. package/telegram-plugin/gateway/turn-record-status.ts +32 -1
  51. package/telegram-plugin/hooks/hooks.json +13 -12
  52. package/telegram-plugin/hooks/narration-classify.mjs +1 -2
  53. package/telegram-plugin/hooks/silent-end-scan.mjs +1 -1
  54. package/telegram-plugin/slot-banner-driver.ts +42 -5
  55. package/telegram-plugin/status-pin.ts +2 -5
  56. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
  57. package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
  58. package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
  59. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
  60. package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
  61. package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
  62. package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
  63. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
  64. package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
  65. package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
  66. package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
  67. package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
  68. package/telegram-plugin/throttle-tier.ts +12 -3
  69. package/telegram-plugin/turn-flush-safety.ts +97 -0
  70. package/telegram-plugin/worker-activity-feed.ts +1 -1
  71. package/vendor/hindsight-memory/scripts/recall.py +140 -0
  72. package/vendor/hindsight-memory/scripts/retain.py +306 -0
  73. package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
  74. package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
  75. package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
@@ -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
+ })
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Per-session privacy state — the gateway side of the `/private` `/public`
3
+ * feature (switchroom private-mode).
4
+ *
5
+ * The operator can pause Hindsight auto-retain for a stretch of a session with
6
+ * `/private`, then resume it with `/public`. The pause is recorded here as a
7
+ * list of half-open time intervals in a small JSON state file that the Python
8
+ * retain side (`vendor/hindsight-memory`) reads to EXCLUDE any turn whose
9
+ * timestamp falls inside an interval from memory.
10
+ *
11
+ * ── The state-file contract (MUST match the Python reader exactly) ──────────
12
+ * Path: `${TELEGRAM_STATE_DIR}/privacy-state.json` (dir resolved the same way
13
+ * `src/cli/self-improve-stop.ts:resolveStateDir()` does). Schema:
14
+ *
15
+ * { "version": 1,
16
+ * "intervals": [
17
+ * { "start": "2026-08-06T02:00:25.558Z", "end": "2026-08-06T02:05:10.100Z" },
18
+ * { "start": "2026-08-06T02:10:00.000Z", "end": null }
19
+ * ] }
20
+ *
21
+ * `end: null` = an OPEN interval = "private right now". At most one is open at
22
+ * a time. A missing file (or `{"intervals":[]}`) means public — the default.
23
+ * Timestamps are ISO-8601 via `new Date().toISOString()`.
24
+ *
25
+ * ── Invariants ──────────────────────────────────────────────────────────────
26
+ * - All writes are ATOMIC (tmp + fsync + rename via `atomicWriteFileSync`),
27
+ * so a crash mid-write can never leave the Python reader a torn file.
28
+ * - All reads are BEST-EFFORT and never throw: a missing, unreadable, or
29
+ * corrupt file resolves to the public default. Losing this state is
30
+ * fail-safe (memory records rather than drops), so — unlike the security
31
+ * stores — a corrupt file here is tolerated silently rather than
32
+ * quarantined.
33
+ * - `openPrivateInterval` / `closePrivateInterval` are IDEMPOTENT: a second
34
+ * `/private` while already private is a no-op, and `/public` while already
35
+ * public is a no-op.
36
+ */
37
+
38
+ import { readFileSync, mkdirSync } from 'node:fs'
39
+ import { homedir } from 'node:os'
40
+ import { join } from 'node:path'
41
+
42
+ import { atomicWriteFileSync } from '../../src/util/atomic.js'
43
+
44
+ /** One half-open privacy interval. `end: null` = still open ("private now"). */
45
+ export interface PrivacyInterval {
46
+ start: string
47
+ end: string | null
48
+ }
49
+
50
+ /** The on-disk shape of `privacy-state.json`. */
51
+ export interface PrivacyState {
52
+ version: 1
53
+ intervals: PrivacyInterval[]
54
+ }
55
+
56
+ /** The public (default) state — no private intervals. */
57
+ export function emptyPrivacyState(): PrivacyState {
58
+ return { version: 1, intervals: [] }
59
+ }
60
+
61
+ // ── Loud, verbatim operator-facing strings ──────────────────────────────────
62
+ // Exported so the gateway command handlers and the boot alert use the exact
63
+ // wording the spec pins (and so tests assert against a single source).
64
+
65
+ /** Reply to `/private`. */
66
+ export const PRIVATE_ON_REPLY =
67
+ '🔒 Private mode ON — memory writing paused. Nothing said until /public is stored.'
68
+
69
+ /** Reply to `/public`. */
70
+ export const PUBLIC_REPLY =
71
+ '🔓 Public mode — memory writing resumed. The private stretch was excluded from memory.'
72
+
73
+ /** Loud alert posted when a genuine session start reset a leftover open interval. */
74
+ export const SESSION_RESET_ALERT =
75
+ '🔓 New session — memory writing is ON by default. Private mode from the previous session was reset.'
76
+
77
+ /**
78
+ * Agent state dir — set by start.sh; resolved identically to
79
+ * `self-improve-stop.ts:resolveStateDir()` so the gateway writer and the
80
+ * Python reader agree on where `privacy-state.json` lives.
81
+ */
82
+ export function resolvePrivacyStateDir(): string {
83
+ return (
84
+ process.env.TELEGRAM_STATE_DIR ??
85
+ join(homedir(), '.claude', 'channels', 'telegram')
86
+ )
87
+ }
88
+
89
+ /** Absolute path to the shared state file. */
90
+ export function privacyStatePath(stateDir: string = resolvePrivacyStateDir()): string {
91
+ return join(stateDir, 'privacy-state.json')
92
+ }
93
+
94
+ /** True iff `v` is a well-formed interval object. */
95
+ function isInterval(v: unknown): v is PrivacyInterval {
96
+ if (v === null || typeof v !== 'object') return false
97
+ const o = v as Record<string, unknown>
98
+ if (typeof o.start !== 'string') return false
99
+ return o.end === null || typeof o.end === 'string'
100
+ }
101
+
102
+ /**
103
+ * Read the current privacy state. BEST-EFFORT: a missing / unreadable /
104
+ * corrupt file resolves to the public default and NEVER throws. Only
105
+ * well-formed intervals survive; a partially-corrupt array is filtered down to
106
+ * its valid members rather than discarded wholesale.
107
+ */
108
+ export function readPrivacyState(stateDir: string = resolvePrivacyStateDir()): PrivacyState {
109
+ try {
110
+ const raw = readFileSync(privacyStatePath(stateDir), 'utf8')
111
+ const parsed: unknown = JSON.parse(raw)
112
+ if (parsed === null || typeof parsed !== 'object') return emptyPrivacyState()
113
+ const rawIntervals = (parsed as Record<string, unknown>).intervals
114
+ if (!Array.isArray(rawIntervals)) return emptyPrivacyState()
115
+ return { version: 1, intervals: rawIntervals.filter(isInterval) }
116
+ } catch {
117
+ return emptyPrivacyState()
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Atomically persist `state`. BEST-EFFORT: a write failure is logged to stderr
123
+ * and swallowed so a transient fs error can never crash a command handler or
124
+ * the boot path. (Losing the write is fail-safe — memory records the turn.)
125
+ */
126
+ function writePrivacyState(state: PrivacyState, stateDir: string): void {
127
+ try {
128
+ mkdirSync(stateDir, { recursive: true })
129
+ } catch {
130
+ /* dir may already exist / be unwritable — the write below reports */
131
+ }
132
+ try {
133
+ atomicWriteFileSync(privacyStatePath(stateDir), JSON.stringify(state), 0o600)
134
+ } catch (err) {
135
+ process.stderr.write(
136
+ `telegram gateway: privacy-state write failed: ${err instanceof Error ? err.message : String(err)}\n`,
137
+ )
138
+ }
139
+ }
140
+
141
+ /** True iff an interval is currently open ("private right now"). */
142
+ export function isPrivate(state: PrivacyState): boolean {
143
+ return state.intervals.some(i => i.end === null)
144
+ }
145
+
146
+ /**
147
+ * Start a private stretch. IDEMPOTENT: if an interval is already open this is a
148
+ * no-op (a second `/private` doesn't stack).
149
+ */
150
+ export function openPrivateInterval(
151
+ now: Date = new Date(),
152
+ stateDir: string = resolvePrivacyStateDir(),
153
+ ): void {
154
+ const state = readPrivacyState(stateDir)
155
+ if (isPrivate(state)) return
156
+ state.intervals.push({ start: now.toISOString(), end: null })
157
+ writePrivacyState(state, stateDir)
158
+ }
159
+
160
+ /**
161
+ * End the current private stretch. IDEMPOTENT: if no interval is open (already
162
+ * public) this is a no-op.
163
+ */
164
+ export function closePrivateInterval(
165
+ now: Date = new Date(),
166
+ stateDir: string = resolvePrivacyStateDir(),
167
+ ): void {
168
+ const state = readPrivacyState(stateDir)
169
+ const open = state.intervals.find(i => i.end === null)
170
+ if (!open) return
171
+ open.end = now.toISOString()
172
+ writePrivacyState(state, stateDir)
173
+ }
174
+
175
+ /** Truncate the state file back to the public default. */
176
+ export function resetToPublic(stateDir: string = resolvePrivacyStateDir()): void {
177
+ writePrivacyState(emptyPrivacyState(), stateDir)
178
+ }
179
+
180
+ /** Outcome of a session-start reset. */
181
+ export interface SessionResetResult {
182
+ /** True iff an OPEN interval existed and was reset (a private→public transition). */
183
+ hadOpenInterval: boolean
184
+ }
185
+
186
+ /**
187
+ * Reset privacy to public at a GENUINE session start (cold boot / crash /
188
+ * planned restart / `/clear`). Always truncates the state file. If — and only
189
+ * if — an OPEN interval existed (the previous session ended still private),
190
+ * `onOpenIntervalReset` is invoked so the caller can post the loud alert. When
191
+ * the previous session was already public there is no transition, so no alert
192
+ * fires (silent reset).
193
+ *
194
+ * The alert is delegated to a callback rather than sent here so this module
195
+ * stays free of gateway/bot dependencies and unit-testable in isolation.
196
+ */
197
+ export function resetPrivacyOnGenuineSessionStart(opts: {
198
+ stateDir?: string
199
+ onOpenIntervalReset?: () => void
200
+ } = {}): SessionResetResult {
201
+ const stateDir = opts.stateDir ?? resolvePrivacyStateDir()
202
+ const hadOpenInterval = isPrivate(readPrivacyState(stateDir))
203
+ resetToPublic(stateDir)
204
+ if (hadOpenInterval) opts.onOpenIntervalReset?.()
205
+ return { hadOpenInterval }
206
+ }