switchroom 0.17.6 → 0.18.3

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 (197) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +65 -5
  3. package/dist/auth-broker/index.js +6623 -514
  4. package/dist/cli/notion-write-pretool.mjs +64 -4
  5. package/dist/cli/switchroom.js +1888 -1162
  6. package/dist/host-control/main.js +6306 -162
  7. package/dist/vault/approvals/kernel-server.js +6014 -202
  8. package/dist/vault/broker/server.js +6741 -940
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +218 -25
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +116 -0
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/skills/mental-model-curator/SKILL.md +162 -0
  18. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  19. package/telegram-plugin/bridge/bridge.ts +80 -1
  20. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  21. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  22. package/telegram-plugin/consolidation-legibility.ts +279 -0
  23. package/telegram-plugin/context-exhaustion.ts +124 -0
  24. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  25. package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
  26. package/telegram-plugin/dist/server.js +86 -2
  27. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  28. package/telegram-plugin/gateway/activity-card-store.ts +369 -0
  29. package/telegram-plugin/gateway/gateway.ts +1861 -172
  30. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  31. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  32. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  33. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  34. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  35. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  36. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  37. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  38. package/telegram-plugin/gateway/model-command.ts +70 -10
  39. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  40. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  41. package/telegram-plugin/memory-legibility.ts +217 -0
  42. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/telegram-plugin/package.json +6 -0
  44. package/telegram-plugin/quota-watch.ts +4 -6
  45. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  46. package/telegram-plugin/registry/turns-schema.ts +78 -0
  47. package/telegram-plugin/render/ir.ts +209 -0
  48. package/telegram-plugin/render/parse.ts +363 -0
  49. package/telegram-plugin/render/render.ts +440 -0
  50. package/telegram-plugin/render/rich-render.ts +72 -0
  51. package/telegram-plugin/scoped-approval.ts +59 -0
  52. package/telegram-plugin/silent-end.ts +78 -0
  53. package/telegram-plugin/stream-controller.ts +14 -3
  54. package/telegram-plugin/subagent-watcher.ts +60 -6
  55. package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
  56. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  57. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  58. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  59. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  60. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  61. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  62. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
  63. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  64. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  65. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  66. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  67. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  68. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  69. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  70. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  71. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  72. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  73. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  74. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  75. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  76. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  77. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  78. package/telegram-plugin/tests/model-command.test.ts +193 -16
  79. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  80. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  81. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  82. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  83. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  84. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  85. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  86. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  87. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  88. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  89. package/telegram-plugin/tests/render/render.test.ts +436 -0
  90. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  91. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  92. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  93. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  94. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  95. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  96. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
  97. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  98. package/telegram-plugin/uat/assertions.ts +88 -4
  99. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  100. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  101. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  102. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  103. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  104. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  105. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  106. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  107. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  109. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  110. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  111. package/telegram-plugin/worktree-watch-cwds.ts +135 -0
  112. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  113. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  119. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  132. package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
  133. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  134. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  135. package/vendor/hindsight-memory/scripts/recall.py +217 -10
  136. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  137. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  138. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  157. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  158. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  159. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  160. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  161. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  162. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  163. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  164. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  165. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  166. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  167. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  168. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  169. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  170. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  172. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  173. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  174. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  175. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  176. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  177. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  178. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  179. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  180. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  181. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  182. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  183. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  184. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  185. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  186. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  187. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  188. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  189. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  190. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  191. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  192. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  193. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  194. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  195. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  196. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
  197. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -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
+ }
@@ -48,3 +48,127 @@ export function shouldArmOrphanedReplyTimeout(params: {
48
48
  !params.progressCardActive
49
49
  )
50
50
  }
51
+
52
+ /**
53
+ * Default "recently streaming" window (ms) for the orphaned-reply liveness
54
+ * stamp. The gateway overrides this from
55
+ * SWITCHROOM_ORPHANED_REPLY_STREAM_WINDOW_MS; this constant is the fallback and
56
+ * the value the LivenessTracker tests pin against.
57
+ */
58
+ export const ORPHANED_REPLY_STREAM_WINDOW_MS = 120_000
59
+
60
+ /**
61
+ * Result of a fuse-expiry decision.
62
+ *
63
+ * - `rearm` — re-arm the fuse (keep the turn alive) instead of
64
+ * firing the synthetic turn_end backstop.
65
+ * - `countsAgainstCap`— this rearm (or the fire it turned into at the cap) was
66
+ * a working/recently-streaming rearm, i.e. subject to the
67
+ * ORPHANED_REPLY_MAX_REARMS cap. Human-wait rearms are
68
+ * uncapped and report `false` here.
69
+ */
70
+ export interface OrphanedReplyExpiryDecision {
71
+ rearm: boolean
72
+ countsAgainstCap: boolean
73
+ }
74
+
75
+ /**
76
+ * Per-turn liveness tracker for the orphaned-reply backstop.
77
+ *
78
+ * WHY THIS EXISTS (the thinking-pause fix):
79
+ * The orphaned-reply fuse (ORPHANED_REPLY_TIMEOUT_MS = 30 s) used to be reset
80
+ * ONLY by `tool_label` and `text` stream events. During a long model
81
+ * reasoning pause the gateway sees NO such events (thinking events carry no
82
+ * text), so the fuse ran down and force-ended a genuinely-live turn mid-work
83
+ * ("orphaned-reply timeout (30000ms) — forcing backstop" ~1 ms before
84
+ * turn_end, with minutes of real work still to come).
85
+ *
86
+ * THE FIX:
87
+ * Stamp `lastStreamEventAt` on ANY genuine stream event (via onStreamEvent,
88
+ * called from the gateway dispatcher entry). If a genuine event arrived
89
+ * within `windowMs` (default 120 s) the turn is "recently streaming" and the
90
+ * fuse re-arms instead of firing — a reasoning pause is survivable while a
91
+ * genuine multi-minute hang (no events at all) still fires.
92
+ *
93
+ * This is a small, pure, side-effect-free seam so the decision logic is unit
94
+ * testable without the full gateway. The gateway holds one instance per
95
+ * CurrentTurn (so per-turn identity / supersession semantics are unchanged) and
96
+ * delegates: the dispatcher calls onStreamEvent; the fire callback calls
97
+ * decideOnExpiry; the defensive turn_end guard reads recentlyStreaming.
98
+ */
99
+ export class LivenessTracker {
100
+ /** Wall-clock (ms) of the last genuine stream event for this turn. */
101
+ lastStreamEventAt: number
102
+ /**
103
+ * How many times the fuse re-armed on a working / recently-streaming
104
+ * expiry. Bounded by ORPHANED_REPLY_MAX_REARMS. Zeroed by onStreamEvent
105
+ * whenever a genuine stream event lands, so the cap only bites CONSECUTIVE
106
+ * silent expiries (a genuinely wedged single tool with no stream at all).
107
+ */
108
+ orphanedReplyRearmCount = 0
109
+
110
+ constructor(startedAt: number) {
111
+ this.lastStreamEventAt = startedAt
112
+ }
113
+
114
+ /**
115
+ * Stamp liveness for a genuine stream event and reset the rearm counter.
116
+ *
117
+ * F4 predicate: everything EXCEPT the synthetic `turn_end` re-entry
118
+ * (durationMs === -1, the fire callback's own re-dispatch) counts as a
119
+ * genuine stream event. Stamping a REAL turn_end (durationMs >= 0) is
120
+ * harmless — the turn is ending anyway.
121
+ *
122
+ * The counter reset lives HERE (driven by the dispatcher), NOT in the fuse
123
+ * re-arm path — otherwise every rearm would zero its own counter and nullify
124
+ * the 20-cap.
125
+ */
126
+ onStreamEvent(kind: string, durationMs: number | undefined, now: number): void {
127
+ if (kind === 'turn_end' && durationMs === -1) return
128
+ this.lastStreamEventAt = now
129
+ this.orphanedReplyRearmCount = 0
130
+ }
131
+
132
+ /** True iff a genuine stream event landed within `windowMs` of `now`. */
133
+ recentlyStreaming(now: number, windowMs: number): boolean {
134
+ return now - this.lastStreamEventAt < windowMs
135
+ }
136
+
137
+ /**
138
+ * Decide what to do when the orphaned-reply fuse expires.
139
+ *
140
+ * Rearm when the turn is working OR recently streaming OR a human is being
141
+ * waited on. Working / recently-streaming rearms count against `maxRearms`
142
+ * (so a genuinely wedged single tool that never streams still surfaces after
143
+ * the cap). Human-wait rearms are uncapped (the human simply hasn't tapped
144
+ * yet). Once the cap is hit with nothing else keeping the turn alive, fire
145
+ * (rearm:false) — matching the existing fail-safe.
146
+ *
147
+ * Mutates `orphanedReplyRearmCount` on each rearm.
148
+ */
149
+ decideOnExpiry(opts: {
150
+ working: boolean
151
+ humanWaiting: boolean
152
+ now: number
153
+ windowMs: number
154
+ maxRearms: number
155
+ }): OrphanedReplyExpiryDecision {
156
+ // Human-wait rearms are uncapped (unchanged from prior behaviour).
157
+ if (opts.humanWaiting) {
158
+ this.orphanedReplyRearmCount++
159
+ return { rearm: true, countsAgainstCap: false }
160
+ }
161
+ const recently = this.recentlyStreaming(opts.now, opts.windowMs)
162
+ if (opts.working || recently) {
163
+ if (this.orphanedReplyRearmCount < opts.maxRearms) {
164
+ this.orphanedReplyRearmCount++
165
+ return { rearm: true, countsAgainstCap: true }
166
+ }
167
+ // Cap reached with only working/recently-streaming keeping it alive:
168
+ // fire to surface the wedge.
169
+ return { rearm: false, countsAgainstCap: true }
170
+ }
171
+ // Nothing keeping the turn alive → fire (fail-safe).
172
+ return { rearm: false, countsAgainstCap: false }
173
+ }
174
+ }
@@ -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")