switchroom 0.17.6 → 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 (155) hide show
  1. package/dist/agent-scheduler/index.js +38 -4
  2. package/dist/auth-broker/index.js +302 -203
  3. package/dist/cli/notion-write-pretool.mjs +35 -2
  4. package/dist/cli/switchroom.js +1178 -576
  5. package/dist/host-control/main.js +148 -14
  6. package/dist/vault/approvals/kernel-server.js +140 -55
  7. package/dist/vault/broker/server.js +142 -57
  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/bridge/bridge.ts +80 -1
  13. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  14. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  15. package/telegram-plugin/consolidation-legibility.ts +279 -0
  16. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  17. package/telegram-plugin/dist/gateway/gateway.js +2565 -610
  18. package/telegram-plugin/dist/server.js +86 -2
  19. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  20. package/telegram-plugin/gateway/activity-card-store.ts +293 -0
  21. package/telegram-plugin/gateway/gateway.ts +1376 -82
  22. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  23. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  24. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  25. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  26. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  27. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  28. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  29. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  30. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  31. package/telegram-plugin/memory-legibility.ts +217 -0
  32. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  33. package/telegram-plugin/scoped-approval.ts +59 -0
  34. package/telegram-plugin/silent-end.ts +78 -0
  35. package/telegram-plugin/subagent-watcher.ts +60 -6
  36. package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
  37. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  38. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  39. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  40. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
  41. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  42. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  43. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  44. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  45. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  46. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  47. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  48. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  49. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  50. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  51. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  52. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  53. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  54. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  55. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  56. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  57. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  58. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  59. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  60. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
  61. package/telegram-plugin/uat/assertions.ts +88 -4
  62. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  63. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  64. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  65. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  66. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  67. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  68. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  69. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  70. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  71. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  72. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  73. package/telegram-plugin/worktree-watch-cwds.ts +60 -0
  74. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  75. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  76. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  77. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  78. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  79. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  80. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  81. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  82. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  84. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
  94. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  95. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  96. package/vendor/hindsight-memory/scripts/recall.py +153 -4
  97. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  98. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  99. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  126. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  127. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  128. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  129. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  130. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  131. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
@@ -0,0 +1,162 @@
1
+ ---
2
+ name: mental-model-curator
3
+ description: >
4
+ Review the agent's OWN Hindsight memory bank with the most capable model and
5
+ PROPOSE well-formed mental models ("knowledge models") to the operator via the
6
+ approve/deny proposal card, instead of blindly creating them. Use to curate,
7
+ seed, or audit the standing models the agent maintains over its specialty.
8
+ Triggers on phrasings like: "curate mental models", "propose knowledge models",
9
+ "review my memory bank", "what standing models should I have", "bootstrap
10
+ mental models", "suggest mental models from my bank", "audit my mental models",
11
+ "what recurring questions do I keep re-deriving", and typos like "propose
12
+ knowlege models". Do NOT use for storing a single fact/preference/decision —
13
+ that is retain, not a mental model. Do NOT use for identity or "who is the
14
+ user" — that lives in profile banks, so never propose an identity model here
15
+ (the operator's review of the card, not any code guard, is the only gate — so
16
+ it's on you to honor).
17
+ Even when the operator directly asks you to CREATE one specific named model
18
+ whose shape you know, PROPOSE it through the approve/deny card — never call
19
+ create_mental_model directly (it is not pre-approved; the propose card is the
20
+ only sanctioned write path, Fix 1.2 / #2903).
21
+ allowed-tools: mcp__hindsight__list_banks mcp__hindsight__get_bank_stats mcp__hindsight__list_mental_models mcp__hindsight__get_mental_model mcp__hindsight__reflect mcp__hindsight__recall mcp__switchroom-telegram__mental_model_propose
22
+ ---
23
+
24
+ # mental-model-curator — Propose standing knowledge models from your own bank
25
+
26
+ A **mental model** in Hindsight is a pre-computed semantic summary backed by
27
+ reflection over the memory bank — a standing answer to a recurring DOMAIN
28
+ question the agent keeps re-deriving (e.g. a coach's "current training plan,
29
+ recent sessions, and open adjustments"). This skill's job is to use your most
30
+ capable model to survey your OWN bank and PROPOSE a few well-earned models to
31
+ the operator through the approve/deny card — never to bulk-create noise.
32
+
33
+ You cannot self-approve. Every proposal renders a card the operator taps. Your
34
+ job is to make each proposal so obviously right that a glance is enough.
35
+
36
+ ## Why propose instead of create
37
+
38
+ You do NOT have direct `create_mental_model` access — it is deliberately not
39
+ pre-approved (Fix 1.2 / #2903). Every mental-model write routes through
40
+ `mcp__switchroom-telegram__mental_model_propose`, because a
41
+ standing model is a persistent, always-in-context artifact with real cost
42
+ (recall/reflect spend, and — if `refresh_after_consolidation` is on — invisible
43
+ post-consolidation spend). A human should decide which of those the agent runs.
44
+ Bulk self-creation is how banks fill with overlapping, stale, or wrong-fact
45
+ models. Propose the few that are earned; let the operator ratify.
46
+
47
+ ## Modes
48
+
49
+ - **Propose mode (default):** run the full workflow and fire proposal cards, one
50
+ at a time, up to the rate limit.
51
+ - **Dry-run / preview mode:** when the user says "preview", "dry run", "just
52
+ show me the candidates", "what would you propose", or you're testing against an
53
+ unfamiliar bank — run steps 1–4, then OUTPUT the ranked candidate list as text
54
+ (name + source_query + one-line reason) and STOP. Do NOT call
55
+ `mental_model_propose` in this mode. This is the safe way to run against any
56
+ bank.
57
+
58
+ ## Workflow
59
+
60
+ ### 1. Identify the bank and check it's worth analyzing
61
+
62
+ Resolve the session's bank (`mcp__hindsight__list_banks`, or the bank bound to
63
+ this agent's session). Then pull `mcp__hindsight__get_bank_stats`.
64
+
65
+ If the bank is **empty or thin**, STOP and report "not enough content to
66
+ synthesize models yet — the bank needs more accumulated memories before standing
67
+ models are meaningful." Synthesizing over an empty or thin bank produces
68
+ confident noise: models with no grounding that read as authoritative. A handful
69
+ of memories is not a domain. Do not propose against a thin bank, even in dry-run.
70
+
71
+ ### 2. Read what already exists (dedupe)
72
+
73
+ Call `mcp__hindsight__list_mental_models`, and `get_mental_model` on each to read
74
+ its `source_query`. Build a dedupe set of the questions already covered.
75
+
76
+ The propose flow hard-rejects an EXACT-name duplicate before a card posts, but
77
+ that is not enough — a near-duplicate under a fresh name is still waste. You must
78
+ **semantically** dedupe: if a candidate's source_query asks substantially the
79
+ same thing as an existing model (even reworded), drop it. Only propose genuinely
80
+ new coverage.
81
+
82
+ ### 3. Survey recurring themes
83
+
84
+ Use `mcp__hindsight__reflect` and `mcp__hindsight__recall` to find the DOMAIN
85
+ questions this agent keeps re-deriving — the standing state of its specialty.
86
+ Good prompts: "What questions do I repeatedly answer from scratch?", "What are
87
+ the recurring themes in this bank?", "What standing state would I want summarized
88
+ before every relevant turn?" Cluster the answers into candidate standing
89
+ questions. Each cluster must be backed by memories the bank ACTUALLY holds — if
90
+ you can't point to the underlying content, it's not a candidate.
91
+
92
+ ### 4. Frame each candidate correctly
93
+
94
+ - **`source_query` must be a DOMAIN question**, never an identity / "who is the
95
+ user" question. Identity lives in dedicated profile banks; never propose an
96
+ identity-based model — the operator, not the platform, is the only gate, so
97
+ this is on you to honor (one previously caused a wrong-fact contradiction bug).
98
+ If a candidate is really "facts about the user", discard it.
99
+ - **`refresh_after_consolidation` defaults OFF.** Only set it true when the model
100
+ genuinely tracks fast-moving state that must be current the moment memory
101
+ consolidates. It adds invisible post-consolidation spend and timeout risk, so
102
+ the default answer is off.
103
+ - **Keep models tight.** recall/reflect tiers cap around ~1024 tokens; a model
104
+ that tries to summarize everything summarizes nothing. Set a modest `max_tokens`
105
+ if the default would overrun. Narrow, answerable source_queries beat broad ones.
106
+ - **Few models per bank.** Each must be earned by content the bank holds. Quality
107
+ over quantity — a bank with three sharp models beats one with ten vague ones.
108
+
109
+ ### 5. Propose within limits
110
+
111
+ Rank the surviving candidates by how much re-derivation they save and how well
112
+ the bank supports them. Propose only the **top few**.
113
+
114
+ **HARD LIMIT: 5 proposal cards per hour per agent (sliding window).** Batch and
115
+ prioritize — never fire one card per cluster. If you have more than 5 worthwhile
116
+ candidates, propose the best 5 and mention the rest in text for a later pass.
117
+
118
+ Propose **one at a time**:
119
+
120
+ ```
121
+ mcp__switchroom-telegram__mental_model_propose(
122
+ chat_id=<the current chat_id>,
123
+ name=<slug>,
124
+ source_query=<the domain question>,
125
+ reason=<CRISP one line — the operator sees this on the card and denies vague ones>,
126
+ ... # refresh_after_consolidation / max_tokens only when justified above
127
+ )
128
+ ```
129
+
130
+ The `reason` is load-bearing: it's the one line the operator reads before
131
+ tapping. "Tracks the athlete's current plan + open adjustments so I stop
132
+ rebuilding it each session" earns a tap; "useful model" gets denied.
133
+
134
+ After firing a card, **END THE TURN CLEANLY.** The flow resumes on its own via a
135
+ synthetic inbound (`mental_model_proposal_applied` / `mental_model_proposal_denied`)
136
+ when the operator taps. Do not loop, do not poll, do not fire the next card in the
137
+ same turn. You cannot self-approve — that's by design.
138
+
139
+ ### 6. Scheduled-sweep note (future extension)
140
+
141
+ Unattended cron proposing is **NOT supported today.** `mental_model_propose`
142
+ needs a live turn with an operator present to tap the card — firing cards into an
143
+ empty topic at 3am is useless and noisy. If invoked from a scheduled sweep,
144
+ **STAGE** the ranked candidates (retain them, or hold them) and surface them on
145
+ the next interactive turn instead of firing cards unattended. Treat live-turn,
146
+ operator-present proposing as the only supported path for now.
147
+
148
+ ## Anti-patterns
149
+
150
+ - ❌ Proposing against an empty or thin bank — synthesizes to noise.
151
+ - ❌ An identity / "who is the user" source_query — forbidden; profile banks own that.
152
+ - ❌ Ten near-duplicate models under different names — semantically dedupe first.
153
+ - ❌ Firing a card per cluster — respect the 5/hour limit; propose the top few.
154
+ - ❌ `refresh_after_consolidation: true` by default — it's an invisible cost; leave it off unless the model tracks fast-moving state.
155
+ - ❌ A vague `reason` — the operator denies it and you've burned a slot.
156
+ - ❌ Looping/polling after a proposal — end the turn; the resume inbound wakes you.
157
+
158
+ ## Output
159
+
160
+ In propose mode: a short line naming what you proposed and that the operator's
161
+ tap will resume the flow. In dry-run mode: the ranked candidate list (name +
162
+ source_query + reason) and nothing fired.
@@ -30,6 +30,7 @@ import { createIpcClient, type IpcClientHandle } from './ipc-client.js'
30
30
  import { buildEffectiveToolSchemas, LINEAR_ENV } from './tool-filter.js'
31
31
  import type { InboundMessage, PermissionEvent, StatusEvent } from '../gateway/ipc-protocol.js'
32
32
  import { matchesAllowRule } from '../permission-rule.js'
33
+ import { createOutstandingPermissionLedger } from './permission-ledger.js'
33
34
 
34
35
  installPluginLogger()
35
36
 
@@ -417,6 +418,24 @@ const TOOL_SCHEMAS = [
417
418
  required: ['chat_id', 'key'],
418
419
  },
419
420
  },
421
+ {
422
+ name: 'mental_model_propose',
423
+ description:
424
+ "Propose a Hindsight MENTAL MODEL for the operator to approve (agent-proposes → human-approves, hindsight Phase 5). Use this when — over real work — 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 — appended to your `memory.mental_models[]` in switchroom.yaml via the operator-approved config-edit path — 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 — it is the leashed, human-gated way to add a DURABLE declared model to your config. After firing this tool, END YOUR TURN cleanly — 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).",
425
+ inputSchema: {
426
+ type: 'object',
427
+ properties: {
428
+ chat_id: { type: 'string', description: 'Chat to render the approval card in (use the chat_id of the user message that triggered the workflow).' },
429
+ name: { type: 'string', description: 'Stable model name — 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.' },
430
+ 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.' },
431
+ reason: { type: 'string', description: 'REQUIRED in practice — 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.' },
432
+ refresh_after_consolidation: { type: 'boolean', description: 'Refresh this model after each consolidation. Defaults OFF — refresh adds bounded background model-spend + timeout risk (RFC Phase 5). Only set true when the model genuinely needs to track fast-moving state.' },
433
+ max_tokens: { type: 'number', description: 'Optional cap on the synthesized model\'s token size.' },
434
+ message_thread_id: { type: 'string', description: 'Forum topic thread ID. Auto-applied from the last inbound message if not specified.' },
435
+ },
436
+ required: ['chat_id', 'name', 'source_query'],
437
+ },
438
+ },
420
439
  {
421
440
  name: 'linear_agent_activity',
422
441
  description:
@@ -582,6 +601,40 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
582
601
  // added by either is honoured by all.
583
602
  const sessionAllowRules = new Set<string>()
584
603
 
604
+ // #2861: outstanding permission-request ledger. Every permission_request from
605
+ // claude is recorded here and re-sent on each IPC (re)connect, so a request
606
+ // that arrives while the gateway is down (or that the gateway lost across a
607
+ // restart) is redelivered instead of dropped. Deleted in onPermission when a
608
+ // verdict is delivered. Kill switch: SWITCHROOM_PERMISSION_REARM=0 reverts to
609
+ // the legacy drop-when-disconnected behavior.
610
+ const outstandingPermissions = createOutstandingPermissionLedger()
611
+ const permissionRearmEnabled = process.env.SWITCHROOM_PERMISSION_REARM !== '0'
612
+
613
+ /**
614
+ * Re-send every outstanding permission_request to the gateway. Called on each
615
+ * IPC (re)connect via the ipc-client onConnect hook. The gateway dedupes /
616
+ * re-arms them idempotently (gateway/permission-rearm.ts), so re-sending on
617
+ * every reconnect is safe. Never answers a card — re-transmits the question.
618
+ */
619
+ function flushOutstandingPermissionRequests(): void {
620
+ if (!permissionRearmEnabled) return
621
+ if (!ipc || !ipc.isConnected()) return
622
+ const pending = outstandingPermissions.all()
623
+ if (pending.length === 0) return
624
+ process.stderr.write(
625
+ `telegram bridge: re-sending ${pending.length} outstanding permission request(s) on gateway (re)connect\n`,
626
+ )
627
+ for (const p of pending) {
628
+ ipc.sendPermissionRequest({
629
+ type: 'permission_request',
630
+ requestId: p.request_id,
631
+ toolName: p.tool_name,
632
+ description: p.description,
633
+ inputPreview: p.input_preview,
634
+ })
635
+ }
636
+ }
637
+
585
638
  mcp.setNotificationHandler(
586
639
  z.object({
587
640
  method: z.literal('notifications/claude/channel/permission_request'),
@@ -615,8 +668,29 @@ mcp.setNotificationHandler(
615
668
  return
616
669
  }
617
670
  }
671
+ // #2861: record the request in the outstanding ledger BEFORE the connect
672
+ // check so it survives a disconnected gateway and is re-sent on reconnect.
673
+ // It's deleted in onPermission when the verdict is delivered.
674
+ if (permissionRearmEnabled) {
675
+ outstandingPermissions.add({
676
+ request_id: params.request_id,
677
+ tool_name: params.tool_name,
678
+ description: params.description,
679
+ input_preview: params.input_preview,
680
+ })
681
+ }
618
682
  if (!ipc || !ipc.isConnected()) {
619
- process.stderr.write('telegram bridge: permission_request received but not connected to gateway\n')
683
+ // BUFFER, don't drop (#2861 R2). The request is in the ledger; the
684
+ // onConnect flush re-sends it once the gateway is reachable. Falls back
685
+ // to the legacy drop only when re-arm is killed.
686
+ if (permissionRearmEnabled) {
687
+ process.stderr.write(
688
+ `telegram bridge: permission_request buffered (gateway offline), will re-send on reconnect ` +
689
+ `request_id=${params.request_id}\n`,
690
+ )
691
+ } else {
692
+ process.stderr.write('telegram bridge: permission_request received but not connected to gateway\n')
693
+ }
620
694
  return
621
695
  }
622
696
  ipc.sendPermissionRequest({
@@ -658,6 +732,9 @@ function onPermission(msg: PermissionEvent): void {
658
732
  if (msg.rule) {
659
733
  sessionAllowRules.add(msg.rule)
660
734
  }
735
+ // #2861: verdict delivered → drop the request from the outstanding ledger so
736
+ // a later reconnect doesn't re-send an already-resolved approval.
737
+ outstandingPermissions.delete(msg.requestId)
661
738
  mcp.notification({
662
739
  method: 'notifications/claude/channel/permission',
663
740
  params: {
@@ -836,6 +913,8 @@ async function main(): Promise<void> {
836
913
  onInbound,
837
914
  onPermission,
838
915
  onStatus,
916
+ // #2861: re-send outstanding permission requests on every (re)connect.
917
+ onConnect: flushOutstandingPermissionRequests,
839
918
  log: (msg) => process.stderr.write(`telegram bridge: ipc: ${msg}\n`),
840
919
  // #2307 Tier-1: the cron-session bridge shares the agent's STATE_DIR
841
920
  // (access.json / history / gateway.sock) but writes its liveness file to a
@@ -48,6 +48,14 @@ export interface IpcClientOptions {
48
48
  onInbound: (msg: InboundMessage) => void;
49
49
  onPermission: (msg: PermissionEvent) => void;
50
50
  onStatus: (msg: StatusEvent) => void;
51
+ /**
52
+ * Called every time the socket (re)connects and has registered — i.e. on
53
+ * the initial connect AND on each background reconnect. The bridge uses
54
+ * this to flush its outstanding permission-request ledger so approvals
55
+ * that arrived while the gateway was down are re-sent (#2861). Best-effort:
56
+ * a throw here is logged, never propagated into the socket open path.
57
+ */
58
+ onConnect?: () => void;
51
59
  log?: (msg: string) => void;
52
60
  reconnectDelayMs?: number;
53
61
  maxReconnectDelayMs?: number;
@@ -93,6 +101,7 @@ export function createIpcClient(options: IpcClientOptions): Promise<IpcClientHan
93
101
  onInbound,
94
102
  onPermission,
95
103
  onStatus,
104
+ onConnect,
96
105
  log = () => {},
97
106
  reconnectDelayMs = 2000,
98
107
  maxReconnectDelayMs = 30000,
@@ -237,6 +246,16 @@ export function createIpcClient(options: IpcClientOptions): Promise<IpcClientHan
237
246
  sendRegister();
238
247
  startHeartbeat();
239
248
  log(`connected to ${socketPath}`);
249
+ // Fire the (re)connect hook AFTER register + heartbeat so the
250
+ // bridge's ledger flush writes onto a live, registered socket.
251
+ // Best-effort: never let a callback throw abort the open path.
252
+ if (onConnect) {
253
+ try {
254
+ onConnect();
255
+ } catch (err) {
256
+ log(`onConnect hook threw: ${err}`);
257
+ }
258
+ }
240
259
  resolve();
241
260
  },
242
261
  data(sock, data) {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Bridge-side outstanding permission-request ledger (#2861 / umbrella #2859).
3
+ *
4
+ * Problem it closes (R2): the bridge's `permission_request` handler used to
5
+ * DROP the request when the gateway IPC was down
6
+ * (`if (!ipc || !ipc.isConnected()) { log; return }`). claude stays suspended
7
+ * inside the MCP permission call, no card is ever posted, the turn wedges
8
+ * forever. There was no record of the ask to re-send once the gateway came
9
+ * back.
10
+ *
11
+ * Fix: every `permission_request` notification from claude is recorded here,
12
+ * keyed by request_id. When the IPC (re)connects, the bridge re-sends every
13
+ * outstanding request (the gateway dedupes / re-arms them idempotently — see
14
+ * `gateway/permission-rearm.ts`). An entry is deleted the moment a verdict is
15
+ * delivered back to claude (`onPermission`), so a resolved request is never
16
+ * re-sent.
17
+ *
18
+ * This never answers a card — it only re-transmits the QUESTION
19
+ * (no-self-escalation invariant). Pure in-memory bookkeeping: no model
20
+ * callsite, no I/O.
21
+ */
22
+
23
+ /** The claude-side `permission_request` params the bridge forwards. */
24
+ export interface OutstandingPermissionParams {
25
+ request_id: string
26
+ tool_name: string
27
+ description: string
28
+ input_preview: string
29
+ }
30
+
31
+ export interface OutstandingPermissionLedger {
32
+ /** Record a request. Idempotent on request_id (latest params win). */
33
+ add(params: OutstandingPermissionParams): void
34
+ /** Remove a request once its verdict has been delivered. */
35
+ delete(requestId: string): void
36
+ has(requestId: string): boolean
37
+ /** Snapshot of all outstanding requests (insertion order). */
38
+ all(): OutstandingPermissionParams[]
39
+ readonly size: number
40
+ }
41
+
42
+ export function createOutstandingPermissionLedger(): OutstandingPermissionLedger {
43
+ const map = new Map<string, OutstandingPermissionParams>()
44
+ return {
45
+ add(params) {
46
+ map.set(params.request_id, params)
47
+ },
48
+ delete(requestId) {
49
+ map.delete(requestId)
50
+ },
51
+ has(requestId) {
52
+ return map.has(requestId)
53
+ },
54
+ all() {
55
+ return [...map.values()]
56
+ },
57
+ get size() {
58
+ return map.size
59
+ },
60
+ }
61
+ }
@@ -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
+ }