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.
- package/dist/agent-scheduler/index.js +38 -4
- package/dist/auth-broker/index.js +302 -203
- package/dist/cli/notion-write-pretool.mjs +35 -2
- package/dist/cli/switchroom.js +1178 -576
- package/dist/host-control/main.js +148 -14
- package/dist/vault/approvals/kernel-server.js +140 -55
- package/dist/vault/broker/server.js +142 -57
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +50 -6
- package/profiles/default/CLAUDE.md +116 -0
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +2565 -610
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +293 -0
- package/telegram-plugin/gateway/gateway.ts +1376 -82
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +60 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +153 -4
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- 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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|