switchroom 0.17.5 → 0.17.10

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 (158) hide show
  1. package/dist/agent-scheduler/index.js +39 -5
  2. package/dist/auth-broker/index.js +386 -208
  3. package/dist/cli/notion-write-pretool.mjs +36 -3
  4. package/dist/cli/switchroom.js +1185 -585
  5. package/dist/host-control/main.js +149 -15
  6. package/dist/vault/approvals/kernel-server.js +141 -56
  7. package/dist/vault/broker/server.js +143 -58
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +50 -6
  10. package/profiles/default/CLAUDE.md +116 -0
  11. package/skills/mental-model-curator/SKILL.md +162 -0
  12. package/telegram-plugin/auth-snapshot-format.ts +50 -2
  13. package/telegram-plugin/bridge/bridge.ts +80 -1
  14. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  15. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  16. package/telegram-plugin/consolidation-legibility.ts +279 -0
  17. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  18. package/telegram-plugin/dist/gateway/gateway.js +2597 -645
  19. package/telegram-plugin/dist/server.js +86 -2
  20. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  21. package/telegram-plugin/gateway/activity-card-store.ts +293 -0
  22. package/telegram-plugin/gateway/auth-command.ts +1 -1
  23. package/telegram-plugin/gateway/gateway.ts +1414 -119
  24. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  26. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  27. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  28. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  29. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  30. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  31. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  32. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  33. package/telegram-plugin/memory-legibility.ts +217 -0
  34. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  35. package/telegram-plugin/scoped-approval.ts +59 -0
  36. package/telegram-plugin/silent-end.ts +78 -0
  37. package/telegram-plugin/subagent-watcher.ts +60 -6
  38. package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  40. package/telegram-plugin/tests/auth-snapshot-format.test.ts +75 -0
  41. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  42. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  43. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
  44. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  45. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  46. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  47. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  48. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  49. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  50. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  51. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  52. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  53. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  54. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  55. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  56. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  57. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  58. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  59. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  60. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  61. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  62. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  63. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
  64. package/telegram-plugin/uat/assertions.ts +88 -4
  65. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  66. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  67. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  68. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  69. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  70. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  71. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  72. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  73. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  74. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  75. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  76. package/telegram-plugin/worktree-watch-cwds.ts +60 -0
  77. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  78. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  79. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  80. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  81. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  82. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  84. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
  97. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  98. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  99. package/vendor/hindsight-memory/scripts/recall.py +153 -4
  100. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  101. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  129. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  130. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  131. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  132. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  156. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  157. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  158. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Consolidation-driven chat-legible memory surface — hindsight Phase 4,
3
+ * the "updated what I know about Y" side (RFC
4
+ * `reference/rfcs/hindsight-synthesis-layers.md`, Phase 4).
5
+ *
6
+ * #2858 shipped the STORE/CORRECT side driven by deterministic tool-call
7
+ * observation of the interactive session (`create_directive` →
8
+ * "📌 remembered", `invalidate_memory` / demote → "✂️ forgot"). That path
9
+ * sees only what the *foreground* agent explicitly does with memory tools.
10
+ *
11
+ * The RFC also names the poll-free UPDATE side: when the *background*
12
+ * consolidation engine actually distils new durable observations (or
13
+ * supersedes stale ones) it emits a `consolidation.completed` webhook, and
14
+ * that is the honest signal for a terse "🧠 updated what I know about Y"
15
+ * line. This module is the consumer for that webhook.
16
+ *
17
+ * Two hard constraints from the RFC and the `remember-across-sessions` job
18
+ * shape every choice here:
19
+ *
20
+ * 1. **Sparse + material-only.** Consolidation fires on *every* retain
21
+ * (`retainEveryNTurns=1`), so the raw webhook stream is per-turn. A
22
+ * line per fire would BE the "regurgitating old facts unprompted just
23
+ * to prove it remembered" anti-pattern the job forbids. So we surface
24
+ * a line ONLY when a consolidation genuinely *stored* or *corrected* a
25
+ * durable memory (`detectConsolidationEvent` returns null otherwise),
26
+ * AND we rate-limit hard (`ConsolidationRateLimiter`) so bursts of
27
+ * material consolidations collapse to at most one line per interval.
28
+ *
29
+ * 2. **OFF by default.** Unlike the #2858 tool-observation path (default
30
+ * ON — it fires on rare, unambiguous, user-initiated tool calls), this
31
+ * path is driven by an unbounded background engine and by a webhook the
32
+ * pinned hindsight image does not yet emit. It stays OFF until an
33
+ * operator opts in (`SWITCHROOM_CONSOLIDATION_LEGIBILITY=1`), matching
34
+ * the RFC's "sparse, not per-turn … clearly gated" language.
35
+ *
36
+ * No model call, no polling, no `claude -p` — this is a pure consumer of a
37
+ * webhook the receiver already verified + forwarded (claude-native clean).
38
+ */
39
+
40
+ import { stripMarkdown, truncate } from './card-format.js'
41
+
42
+ /** The hindsight webhook source + event this module consumes. */
43
+ export const HINDSIGHT_WEBHOOK_SOURCE = 'hindsight'
44
+ export const CONSOLIDATION_COMPLETED_EVENT = 'consolidation.completed'
45
+
46
+ /**
47
+ * OPT-IN — the operator must set SWITCHROOM_CONSOLIDATION_LEGIBILITY to a
48
+ * truthy value ('1' / 'true' / 'on' / 'yes') to enable. Default OFF: the
49
+ * literal opposite of the #2858 tool-observation path's default-ON switch,
50
+ * deliberately, because this side is driven by an unbounded background
51
+ * engine (RFC: "sparse, not per-turn"; "clearly gated").
52
+ */
53
+ export function isConsolidationLegibilityEnabled(envVal: string | undefined): boolean {
54
+ if (envVal == null) return false
55
+ const v = envVal.trim().toLowerCase()
56
+ return v === '1' || v === 'true' || v === 'on' || v === 'yes'
57
+ }
58
+
59
+ export type ConsolidationLegibilityKind = 'updated' | 'revised'
60
+
61
+ export interface ConsolidationLegibilityEvent {
62
+ /** `updated` — new durable observation(s) distilled; `revised` — an
63
+ * existing memory was superseded/invalidated by consolidation. `revised`
64
+ * is the higher-signal framing and wins when both happened. */
65
+ kind: ConsolidationLegibilityKind
66
+ /** Best-effort human subject ("your deploy preferences"). May be empty —
67
+ * the render then falls back to a bare "updated what I know about you".
68
+ * Raw (unescaped); `renderConsolidationLine` cleans it. */
69
+ topic: string
70
+ }
71
+
72
+ function asString(v: unknown): string {
73
+ return typeof v === 'string' ? v : ''
74
+ }
75
+
76
+ /** Coerce a numeric-ish field (number, or a numeric string) to a count ≥ 0. */
77
+ function asCount(v: unknown): number {
78
+ if (typeof v === 'number' && Number.isFinite(v)) return v > 0 ? Math.floor(v) : 0
79
+ if (typeof v === 'string') {
80
+ const n = Number(v)
81
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
82
+ }
83
+ // An array field (e.g. `observations: [...]`) counts by length.
84
+ if (Array.isArray(v)) return v.length
85
+ return 0
86
+ }
87
+
88
+ /** First non-empty count across a set of tolerant field aliases. */
89
+ function firstCount(payload: Record<string, unknown>, keys: string[]): number {
90
+ for (const k of keys) {
91
+ const c = asCount(payload[k])
92
+ if (c > 0) return c
93
+ }
94
+ return 0
95
+ }
96
+
97
+ /**
98
+ * Best-effort human subject of the consolidation. The webhook is RFC-only
99
+ * (the pinned image does not emit it yet), so tolerate a range of shapes:
100
+ * an explicit `subject` / `topic`, the first entry of `subjects`/`entities`,
101
+ * or the `subject`/`statement` of the first observation. Anything opaque
102
+ * (a bank_id, a uuid) is left to the empty fallback.
103
+ */
104
+ function extractTopic(payload: Record<string, unknown>): string {
105
+ const direct = asString(payload.subject).trim() || asString(payload.topic).trim()
106
+ if (direct) return direct
107
+
108
+ for (const key of ['subjects', 'entities', 'topics']) {
109
+ const arr = payload[key]
110
+ if (Array.isArray(arr)) {
111
+ for (const el of arr) {
112
+ if (typeof el === 'string' && el.trim()) return el.trim()
113
+ if (el && typeof el === 'object') {
114
+ const name = asString((el as Record<string, unknown>).name).trim()
115
+ if (name) return name
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ const obs = payload.observations
122
+ if (Array.isArray(obs) && obs.length > 0 && obs[0] && typeof obs[0] === 'object') {
123
+ const o = obs[0] as Record<string, unknown>
124
+ const sub = asString(o.subject).trim()
125
+ if (sub) return sub
126
+ const stmt = asString(o.statement).trim()
127
+ if (stmt) return stmt
128
+ }
129
+ return ''
130
+ }
131
+
132
+ /**
133
+ * Decide whether a `consolidation.completed` payload represents a MATERIAL
134
+ * durable change — a genuine store or correct — or null if it is a routine
135
+ * no-op consolidation (the common case, which must surface NO line).
136
+ *
137
+ * Pure, no I/O. Tolerant of the RFC-only payload's exact field names.
138
+ */
139
+ export function detectConsolidationEvent(
140
+ payload: Record<string, unknown> | undefined,
141
+ ): ConsolidationLegibilityEvent | null {
142
+ if (payload == null || typeof payload !== 'object') return null
143
+
144
+ const stored = firstCount(payload, [
145
+ 'stored',
146
+ 'created',
147
+ 'added',
148
+ 'new_observations',
149
+ 'observations_created',
150
+ 'observations',
151
+ ])
152
+ const corrected = firstCount(payload, [
153
+ 'corrected',
154
+ 'invalidated',
155
+ 'superseded',
156
+ 'removed',
157
+ 'demoted',
158
+ ])
159
+
160
+ // Immaterial: nothing durable stored, nothing corrected. This is the
161
+ // overwhelming majority of consolidation fires — surface nothing.
162
+ if (stored === 0 && corrected === 0) return null
163
+
164
+ // A correction ("what I believed changed") is higher-signal than another
165
+ // stored fact, so it wins the framing when both happened.
166
+ const kind: ConsolidationLegibilityKind = corrected > 0 ? 'revised' : 'updated'
167
+ return { kind, topic: extractTopic(payload) }
168
+ }
169
+
170
+ /** HTML-escape for parse_mode:'HTML' (escape the 3 entity-significant chars). */
171
+ function escapeHtml(s: string): string {
172
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
173
+ }
174
+
175
+ /** Max chars of subject shown on the one-liner. */
176
+ const TOPIC_MAX = 120
177
+
178
+ /**
179
+ * Render the terse one-line surface (Telegram HTML). Sent as a real
180
+ * `sendMessage` with notifications suppressed — a status surface, never a
181
+ * device ping.
182
+ *
183
+ * 🧠 <i>updated what I know</i> about "your deploy preferences"
184
+ * 🧠 <i>revised what I know</i> about "the old runbook"
185
+ * 🧠 <i>updated what I know about you.</i> (no legible subject)
186
+ */
187
+ export function renderConsolidationLine(ev: ConsolidationLegibilityEvent): string {
188
+ const verb = ev.kind === 'revised' ? 'revised' : 'updated'
189
+ const clean = truncate(stripMarkdown(ev.topic).replace(/\s+/g, ' ').trim(), TOPIC_MAX)
190
+ if (clean.length === 0) return `🧠 <i>${verb} what I know about you.</i>`
191
+ return `🧠 <i>${verb} what I know</i> about "${escapeHtml(clean)}"`
192
+ }
193
+
194
+ export interface ConsolidationRateLimiterOptions {
195
+ /** Minimum gap between ANY two surfaced lines for one agent. Collapses a
196
+ * burst of material consolidations to at most one line per window.
197
+ * Default 10 min. */
198
+ minIntervalMs?: number
199
+ /** Suppress an IDENTICAL line (same kind+topic) for this long even if the
200
+ * min-interval has passed — stops "updated about X" repeating as the
201
+ * engine re-derives the same observation. Default 1 hour. */
202
+ dedupWindowMs?: number
203
+ /** Clock override (tests). */
204
+ now?: () => number
205
+ }
206
+
207
+ const DEFAULT_MIN_INTERVAL_MS = 10 * 60 * 1000
208
+ const DEFAULT_DEDUP_WINDOW_MS = 60 * 60 * 1000
209
+ /** Cap on retained per-signature timestamps so the map can't grow unbounded
210
+ * under a long-lived gateway. */
211
+ const MAX_TRACKED_SIGNATURES = 512
212
+
213
+ /**
214
+ * Per-agent rate limiter for the consolidation surface. Two independent
215
+ * gates, both must pass:
216
+ *
217
+ * - **min-interval** — at most one line per agent per `minIntervalMs`,
218
+ * regardless of subject. This is the anti-spam floor.
219
+ * - **dedup** — the same (kind+topic) signature is suppressed for
220
+ * `dedupWindowMs` even across the interval boundary.
221
+ *
222
+ * Stateful + long-lived: the gateway constructs ONE instance and reuses it
223
+ * across events. Deterministic under an injected clock (tests).
224
+ */
225
+ export class ConsolidationRateLimiter {
226
+ private readonly minIntervalMs: number
227
+ private readonly dedupWindowMs: number
228
+ private readonly clock: () => number
229
+ private readonly lastEmit = new Map<string, number>()
230
+ private readonly recentSig = new Map<string, number>()
231
+
232
+ constructor(opts: ConsolidationRateLimiterOptions = {}) {
233
+ this.minIntervalMs = opts.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
234
+ this.dedupWindowMs = opts.dedupWindowMs ?? DEFAULT_DEDUP_WINDOW_MS
235
+ this.clock = opts.now ?? Date.now
236
+ }
237
+
238
+ /**
239
+ * True when a line for (`agent`, `signature`) may be surfaced now, and
240
+ * records the emit. False (and records nothing) when either gate blocks.
241
+ * `signature` should be stable for identical lines (e.g. `kind:topic`).
242
+ */
243
+ allow(agent: string, signature: string, now?: number): boolean {
244
+ const t = now ?? this.clock()
245
+
246
+ const last = this.lastEmit.get(agent)
247
+ if (last !== undefined && t - last < this.minIntervalMs) return false
248
+
249
+ const sigKey = `${agent}\0${signature}`
250
+ const seen = this.recentSig.get(sigKey)
251
+ if (seen !== undefined && t - seen < this.dedupWindowMs) return false
252
+
253
+ this.lastEmit.set(agent, t)
254
+ this.recentSig.set(sigKey, t)
255
+ this.pruneSignatures(t)
256
+ return true
257
+ }
258
+
259
+ /** Drop signature entries older than the dedup window; hard-cap the map. */
260
+ private pruneSignatures(now: number): void {
261
+ for (const [k, ts] of this.recentSig) {
262
+ if (now - ts >= this.dedupWindowMs) this.recentSig.delete(k)
263
+ }
264
+ if (this.recentSig.size > MAX_TRACKED_SIGNATURES) {
265
+ // Evict oldest until back under the cap (insertion order ≈ age).
266
+ const excess = this.recentSig.size - MAX_TRACKED_SIGNATURES
267
+ let i = 0
268
+ for (const k of this.recentSig.keys()) {
269
+ if (i++ >= excess) break
270
+ this.recentSig.delete(k)
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ /** Stable dedup signature for an event (kind + normalised topic). */
277
+ export function consolidationSignature(ev: ConsolidationLegibilityEvent): string {
278
+ return `${ev.kind}:${ev.topic.replace(/\s+/g, ' ').trim().toLowerCase()}`
279
+ }
@@ -24279,6 +24279,7 @@ function createIpcClient(options) {
24279
24279
  onInbound,
24280
24280
  onPermission,
24281
24281
  onStatus,
24282
+ onConnect,
24282
24283
  log = () => {},
24283
24284
  reconnectDelayMs = 2000,
24284
24285
  maxReconnectDelayMs = 30000,
@@ -24414,6 +24415,13 @@ function createIpcClient(options) {
24414
24415
  sendRegister();
24415
24416
  startHeartbeat();
24416
24417
  log(`connected to ${socketPath}`);
24418
+ if (onConnect) {
24419
+ try {
24420
+ onConnect();
24421
+ } catch (err) {
24422
+ log(`onConnect hook threw: ${err}`);
24423
+ }
24424
+ }
24417
24425
  resolve();
24418
24426
  },
24419
24427
  data(sock, data) {
@@ -24629,6 +24637,28 @@ function matchesAllowRule(rule, toolName, inputPreview) {
24629
24637
  return rule === toolName;
24630
24638
  }
24631
24639
 
24640
+ // bridge/permission-ledger.ts
24641
+ function createOutstandingPermissionLedger() {
24642
+ const map2 = new Map;
24643
+ return {
24644
+ add(params) {
24645
+ map2.set(params.request_id, params);
24646
+ },
24647
+ delete(requestId) {
24648
+ map2.delete(requestId);
24649
+ },
24650
+ has(requestId) {
24651
+ return map2.has(requestId);
24652
+ },
24653
+ all() {
24654
+ return [...map2.values()];
24655
+ },
24656
+ get size() {
24657
+ return map2.size;
24658
+ }
24659
+ };
24660
+ }
24661
+
24632
24662
  // bridge/bridge.ts
24633
24663
  installPluginLogger();
24634
24664
  var STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join4(homedir3(), ".claude", "channels", "telegram");
@@ -24981,6 +25011,23 @@ var TOOL_SCHEMAS = [
24981
25011
  required: ["chat_id", "key"]
24982
25012
  }
24983
25013
  },
25014
+ {
25015
+ name: "mental_model_propose",
25016
+ description: "Propose a Hindsight MENTAL MODEL for the operator to approve (agent-proposes \u2192 human-approves, hindsight Phase 5). Use this when \u2014 over real work \u2014 you notice a recurring, domain-specific question worth maintaining a standing, semantically-refreshed answer to from YOUR bank (e.g. a coach's `training-plan-state`, a lawyer's `open-matters`). You may PROPOSE but can NEVER self-approve: this renders a Telegram [Approve]/[Deny] card to the operator. On Approve the model is DECLARED \u2014 appended to your `memory.mental_models[]` in switchroom.yaml via the operator-approved config-edit path \u2014 and ensured in your bank (it then refreshes from your bank content). On Deny nothing is written. This is NOT for identity/'who is the user' (dedicated profile banks own that) and NOT a substitute for `retain` (store a fact) or `create_mental_model` where you already have direct Hindsight tools \u2014 it is the leashed, human-gated way to add a DURABLE declared model to your config. After firing this tool, END YOUR TURN cleanly \u2014 a fresh inbound arrives (`<channel source=\"mental_model_proposal_applied\">` / `mental_model_proposal_denied`) once the operator decides. Do NOT propose a model whose name is already declared (it is rejected), and do NOT spam (one card per proposal; the operator sees every one).",
25017
+ inputSchema: {
25018
+ type: "object",
25019
+ properties: {
25020
+ chat_id: { type: "string", description: "Chat to render the approval card in (use the chat_id of the user message that triggered the workflow)." },
25021
+ name: { type: "string", description: "Stable model name \u2014 the idempotent-ensure identity key (lowercase kebab/snake, e.g. `training-plan-state`). Must be UNIQUE among your already-declared models or the proposal is rejected." },
25022
+ source_query: { type: "string", description: `The reflection query the model answers, semantically refreshed from your bank content (e.g. "What is the athlete's current training plan, recent sessions, and open adjustments?"). Frame it as a DOMAIN question, never an identity question.` },
25023
+ reason: { type: "string", description: 'REQUIRED in practice \u2014 one-line rationale rendered on the card (e.g. "I keep re-deriving the plan state every session; a standing model would save the lookup"). Omitting it renders "why: not provided" and the operator will usually Deny.' },
25024
+ refresh_after_consolidation: { type: "boolean", description: "Refresh this model after each consolidation. Defaults OFF \u2014 refresh adds bounded background model-spend + timeout risk (RFC Phase 5). Only set true when the model genuinely needs to track fast-moving state." },
25025
+ max_tokens: { type: "number", description: "Optional cap on the synthesized model's token size." },
25026
+ message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." }
25027
+ },
25028
+ required: ["chat_id", "name", "source_query"]
25029
+ }
25030
+ },
24984
25031
  {
24985
25032
  name: "linear_agent_activity",
24986
25033
  description: 'Emit a structured Linear AgentActivity against an agent session (#2298). Use this ONLY inside a turn that was woken by a Linear agent session (the inbound carries meta.source="linear" and meta.agent_session_id) \u2014 pass that agent_session_id back here. Linear renders activities as status chips + a timeline on the issue, so the human sees acknowledge \u2192 work \u2192 result. Emit a `thought` within ~10s of being woken so the session does not look dead, then `message`(s) as you make progress, and finally exactly one terminal `complete` (work done) or `error` (you could not proceed). body is required for thought/message/error and optional for complete. Resolves the agent\'s Linear app token from the vault; on VAULT-BROKER-DENIED it returns an error instructing you to vault_request_access for `linear/<agent>/token`.',
@@ -25104,6 +25151,28 @@ mcp.setRequestHandler(CallToolRequestSchema2, async (req) => {
25104
25151
  }
25105
25152
  });
25106
25153
  var sessionAllowRules = new Set;
25154
+ var outstandingPermissions = createOutstandingPermissionLedger();
25155
+ var permissionRearmEnabled = process.env.SWITCHROOM_PERMISSION_REARM !== "0";
25156
+ function flushOutstandingPermissionRequests() {
25157
+ if (!permissionRearmEnabled)
25158
+ return;
25159
+ if (!ipc || !ipc.isConnected())
25160
+ return;
25161
+ const pending = outstandingPermissions.all();
25162
+ if (pending.length === 0)
25163
+ return;
25164
+ process.stderr.write(`telegram bridge: re-sending ${pending.length} outstanding permission request(s) on gateway (re)connect
25165
+ `);
25166
+ for (const p of pending) {
25167
+ ipc.sendPermissionRequest({
25168
+ type: "permission_request",
25169
+ requestId: p.request_id,
25170
+ toolName: p.tool_name,
25171
+ description: p.description,
25172
+ inputPreview: p.input_preview
25173
+ });
25174
+ }
25175
+ }
25107
25176
  mcp.setNotificationHandler(exports_external.object({
25108
25177
  method: exports_external.literal("notifications/claude/channel/permission_request"),
25109
25178
  params: exports_external.object({
@@ -25125,9 +25194,22 @@ mcp.setNotificationHandler(exports_external.object({
25125
25194
  return;
25126
25195
  }
25127
25196
  }
25197
+ if (permissionRearmEnabled) {
25198
+ outstandingPermissions.add({
25199
+ request_id: params.request_id,
25200
+ tool_name: params.tool_name,
25201
+ description: params.description,
25202
+ input_preview: params.input_preview
25203
+ });
25204
+ }
25128
25205
  if (!ipc || !ipc.isConnected()) {
25129
- process.stderr.write(`telegram bridge: permission_request received but not connected to gateway
25206
+ if (permissionRearmEnabled) {
25207
+ process.stderr.write(`telegram bridge: permission_request buffered (gateway offline), will re-send on reconnect ` + `request_id=${params.request_id}
25208
+ `);
25209
+ } else {
25210
+ process.stderr.write(`telegram bridge: permission_request received but not connected to gateway
25130
25211
  `);
25212
+ }
25131
25213
  return;
25132
25214
  }
25133
25215
  ipc.sendPermissionRequest({
@@ -25155,6 +25237,7 @@ function onPermission(msg) {
25155
25237
  if (msg.rule) {
25156
25238
  sessionAllowRules.add(msg.rule);
25157
25239
  }
25240
+ outstandingPermissions.delete(msg.requestId);
25158
25241
  mcp.notification({
25159
25242
  method: "notifications/claude/channel/permission",
25160
25243
  params: {
@@ -25287,6 +25370,7 @@ async function main() {
25287
25370
  onInbound,
25288
25371
  onPermission,
25289
25372
  onStatus,
25373
+ onConnect: flushOutstandingPermissionRequests,
25290
25374
  log: (msg) => process.stderr.write(`telegram bridge: ipc: ${msg}
25291
25375
  `),
25292
25376
  livenessFilePath: process.env.SWITCHROOM_BRIDGE_ALIVE_PATH ?? join4(STATE_DIR, ".bridge-alive")