switchroom 0.17.6 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +65 -5
- package/dist/auth-broker/index.js +6623 -514
- package/dist/cli/notion-write-pretool.mjs +64 -4
- package/dist/cli/switchroom.js +1888 -1162
- package/dist/host-control/main.js +6306 -162
- package/dist/vault/approvals/kernel-server.js +6014 -202
- package/dist/vault/broker/server.js +6741 -940
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +218 -25
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +116 -0
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- 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/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
- 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 +369 -0
- package/telegram-plugin/gateway/gateway.ts +1861 -172
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- 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/model-command.ts +70 -10
- 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/package.json +6 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -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 +44 -9
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -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/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- 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/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -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/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- 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/jtbd-rich-formatting-render-dm.test.ts +99 -7
- 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 +135 -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/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
- 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 +217 -10
- 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_recall_integration.py +1 -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_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
@@ -116,3 +116,29 @@ export function decideInboundDelivery(
|
|
|
116
116
|
if (input.turnInFlight) return 'buffer-until-idle'
|
|
117
117
|
return 'deliver'
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* #2917 — atomic check-and-reserve for per-chat outbound FIFO.
|
|
122
|
+
*
|
|
123
|
+
* `decideInboundDelivery` decides deliver-vs-buffer, but on the concurrent
|
|
124
|
+
* `handleInbound` path that decision and the busy-mark that records "a turn is
|
|
125
|
+
* now in flight for this chat" are separated by an `await` (attachment
|
|
126
|
+
* download, composer-clear). Two same-chat inbounds can therefore each read
|
|
127
|
+
* "idle" during the other's async lead-in and both deliver — the replies then
|
|
128
|
+
* come back reordered. This helper couples the decision with a `reserve` flag:
|
|
129
|
+
* a FRESH-TURN deliver must reserve the chat's busy key SYNCHRONOUSLY (before
|
|
130
|
+
* any await) so the next same-chat inbound sees it and buffers behind it.
|
|
131
|
+
*
|
|
132
|
+
* `reserve` is true ONLY for a fresh-turn deliver. Steering / interrupt
|
|
133
|
+
* inbounds deliver mid-turn WITHOUT starting a turn, so they must not reserve
|
|
134
|
+
* (reserving would wedge the running turn's key). A buffered decision never
|
|
135
|
+
* reserves.
|
|
136
|
+
*/
|
|
137
|
+
export function reserveInboundDelivery(
|
|
138
|
+
input: InboundDeliveryGateInput,
|
|
139
|
+
): { decision: InboundDeliveryDecision; reserve: boolean } {
|
|
140
|
+
const decision = decideInboundDelivery(input)
|
|
141
|
+
const reserve =
|
|
142
|
+
decision === 'deliver' && !input.isSteering && input.isInterrupt !== true
|
|
143
|
+
return { decision, reserve }
|
|
144
|
+
}
|
|
@@ -94,6 +94,28 @@ export function spoolId(msg: InboundMessage): string {
|
|
|
94
94
|
) {
|
|
95
95
|
return `s:resume:${msg.meta.resume_turn_key}`
|
|
96
96
|
}
|
|
97
|
+
// Cron BOOT-REPLAY (#2793 part B): a scheduled fire that the boot
|
|
98
|
+
// replay re-injects because it was missed across a restart. Keyed on
|
|
99
|
+
// the minute-aligned fire it is replaying (`replay_fire_ms`) plus the
|
|
100
|
+
// schedule index, NOT the fresh synthetic `messageId` (=ts, minted per
|
|
101
|
+
// replay attempt) — so a re-replay of the SAME missed fire across a
|
|
102
|
+
// subsequent gateway restart collapses to ONE live spool entry instead
|
|
103
|
+
// of stacking a fresh one each boot. This is what makes routing cron
|
|
104
|
+
// replays through the durable spool at-least-once WITH dedup, mirroring
|
|
105
|
+
// the resume/handback stable-id idiom above. Live (non-replay) cron
|
|
106
|
+
// ticks never set `replay_fire_ms`, so they are unaffected and keep
|
|
107
|
+
// their per-fire identity.
|
|
108
|
+
if (
|
|
109
|
+
msg.meta?.source === 'cron' &&
|
|
110
|
+
typeof msg.meta?.replay_fire_ms === 'string' &&
|
|
111
|
+
msg.meta.replay_fire_ms.length > 0
|
|
112
|
+
) {
|
|
113
|
+
const idx =
|
|
114
|
+
typeof msg.meta?.schedule_index === 'string' && msg.meta.schedule_index.length > 0
|
|
115
|
+
? msg.meta.schedule_index
|
|
116
|
+
: '-'
|
|
117
|
+
return `s:cron-replay:${msg.chatId}:${idx}:${msg.meta.replay_fire_ms}`
|
|
118
|
+
}
|
|
97
119
|
if (typeof msg.messageId === 'number' && msg.messageId > 0) {
|
|
98
120
|
return `m:${msg.chatId}:${msg.messageId}`
|
|
99
121
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure renderer for the agent-initiated mental-model PROPOSAL approval card
|
|
3
|
+
* (hindsight Phase 5, stacked on #2874).
|
|
4
|
+
*
|
|
5
|
+
* Mirrors `vault-request-access-card.ts` exactly — same rich-message (GFM
|
|
6
|
+
* markdown) escaping discipline:
|
|
7
|
+
* - Non-code-span interpolations (`**agent**`, `_reason_`, `_source_query_`)
|
|
8
|
+
* are markdown-escaped via `escapeHtmlForTg` so a stray `*`/`_`/`[` can't
|
|
9
|
+
* break the surrounding emphasis.
|
|
10
|
+
* - Code-span interpolations (`name`) are LITERAL inside the span, so they
|
|
11
|
+
* must NOT be markdown-escaped; only the backtick is defused via
|
|
12
|
+
* `codeSpanSafe`.
|
|
13
|
+
*
|
|
14
|
+
* The card asks the OPERATOR to approve turning a proposed model into a
|
|
15
|
+
* first-class DECLARED model (`memory.mental_models[]`). The agent can only
|
|
16
|
+
* PROPOSE — the [Approve]/[Deny] tap is operator-gated in the gateway, exactly
|
|
17
|
+
* like the vault flow, so an agent can never self-approve.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { escapeHtmlForTg } from "../shared/bot-runtime.js";
|
|
21
|
+
import { codeSpanSafe } from "./approval-card.js";
|
|
22
|
+
import { hardenCardBreaks } from "../format.js";
|
|
23
|
+
|
|
24
|
+
/** Minimal shape the card needs — a subset of the pending proposal. */
|
|
25
|
+
export interface MentalModelProposeCardInput {
|
|
26
|
+
agent: string;
|
|
27
|
+
name: string;
|
|
28
|
+
source_query: string;
|
|
29
|
+
reason?: string;
|
|
30
|
+
refresh_after_consolidation?: boolean;
|
|
31
|
+
max_tokens?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderMentalModelProposeCard(
|
|
35
|
+
req: MentalModelProposeCardInput,
|
|
36
|
+
): string {
|
|
37
|
+
const lines: string[] = [];
|
|
38
|
+
lines.push(`🧠 **${escapeHtmlForTg(req.agent)}** proposes a mental model`);
|
|
39
|
+
// Code-span content is literal — defuse only the backtick, never
|
|
40
|
+
// markdown-escape (see file header).
|
|
41
|
+
lines.push(`name: \`${codeSpanSafe(req.name)}\``);
|
|
42
|
+
lines.push(`source query: _${escapeHtmlForTg(req.source_query)}_`);
|
|
43
|
+
const refreshLabel = req.refresh_after_consolidation ? "on" : "off";
|
|
44
|
+
lines.push(`refresh after consolidation: \`${codeSpanSafe(refreshLabel)}\``);
|
|
45
|
+
// Always show max_tokens so the operator approves EXACTLY what's applied.
|
|
46
|
+
// When the proposal omits it, the declared model inherits the bank default —
|
|
47
|
+
// render that explicitly rather than hiding the field.
|
|
48
|
+
const maxTokensLabel =
|
|
49
|
+
typeof req.max_tokens === "number" ? String(req.max_tokens) : "default";
|
|
50
|
+
lines.push(`max tokens: \`${codeSpanSafe(maxTokensLabel)}\``);
|
|
51
|
+
// Always render the why-line, even when omitted — a missing rationale is
|
|
52
|
+
// then visibly an agent-side failure, not a card-template choice (mirrors
|
|
53
|
+
// the vault card's #1790 behaviour).
|
|
54
|
+
if (req.reason && req.reason.length > 0) {
|
|
55
|
+
lines.push(`why: _${escapeHtmlForTg(req.reason)}_`);
|
|
56
|
+
} else {
|
|
57
|
+
lines.push(`why: _not provided_`);
|
|
58
|
+
}
|
|
59
|
+
lines.push("");
|
|
60
|
+
lines.push(
|
|
61
|
+
`_Tap Approve to DECLARE this model — it is appended to ${escapeHtmlForTg(req.agent)}'s ` +
|
|
62
|
+
`\`memory.mental_models[]\` (operator-approved config edit) and ensured in the bank. ` +
|
|
63
|
+
`Tap Deny to drop it — nothing is written._`,
|
|
64
|
+
);
|
|
65
|
+
// hardenCardBreaks: the labelled field lines would soft-collapse into one
|
|
66
|
+
// blob under the GFM rich renderer; this card is sent direct via
|
|
67
|
+
// richMessage, bypassing the switchroomReply chokepoint.
|
|
68
|
+
return hardenCardBreaks(lines.join("\n"));
|
|
69
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure (no-network) helpers for the agent-proposes → human-approves
|
|
3
|
+
* mental-model curation flow (hindsight Phase 5, stacked on #2874).
|
|
4
|
+
*
|
|
5
|
+
* #2874 shipped the operator-DECLARED half: a per-agent
|
|
6
|
+
* `memory.mental_models[]` config that `ensureDeclaredMentalModels`
|
|
7
|
+
* materialises at scaffold/reconcile. This module is the persistence
|
|
8
|
+
* primitive for the SECOND half: when an agent PROPOSES a model and the
|
|
9
|
+
* operator taps Approve, the proposed model must become a first-class
|
|
10
|
+
* DECLARED model — i.e. it must land in `agents.<agent>.memory.mental_models[]`
|
|
11
|
+
* in switchroom.yaml so the exact same #2874 ensure/reconcile path
|
|
12
|
+
* consumes it. Nothing bespoke: the approved proposal is just a new
|
|
13
|
+
* declared model.
|
|
14
|
+
*
|
|
15
|
+
* The persist reuses the proven `config_propose_edit` apply+reconcile
|
|
16
|
+
* machinery (hostd is the sole config writer — the leash). So this module
|
|
17
|
+
* only has to produce a git-apply-compatible unified diff that appends the
|
|
18
|
+
* declared model to the agent's block. We build `after` with the
|
|
19
|
+
* structure-preserving `yaml` document API and diff it against the live
|
|
20
|
+
* `before` bytes with the same `git diff --no-index` family hostd applies
|
|
21
|
+
* with (see src/web/config-diff.ts) — so the patch round-trips.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately no I/O here beyond the git-diff subprocess in
|
|
24
|
+
* `generateUnifiedDiff`; callers read the raw config bytes and feed them in.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { parseDocument } from "yaml";
|
|
28
|
+
import { generateUnifiedDiff } from "../../src/web/config-diff.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A proposed mental model, in the same snake_case shape the
|
|
32
|
+
* `memory.mental_models[]` schema (#2874) accepts. `name` + `source_query`
|
|
33
|
+
* are required; the knobs are opt-in and only serialised when set (a minimal
|
|
34
|
+
* declaration is the schema-clean default).
|
|
35
|
+
*/
|
|
36
|
+
export interface MentalModelProposeSpec {
|
|
37
|
+
name: string;
|
|
38
|
+
source_query: string;
|
|
39
|
+
refresh_after_consolidation?: boolean;
|
|
40
|
+
max_tokens?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type BuildDiffResult =
|
|
44
|
+
| { ok: true; diff: string; after: string }
|
|
45
|
+
| { ok: false; error: "agent-not-found" | "duplicate" | "no-change" | "parse-error"; detail: string };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read the NAMES of the models already DECLARED in
|
|
49
|
+
* `agents.<agentName>.memory.mental_models[]` of raw config text. Used for the
|
|
50
|
+
* duplicate-name guard so a proposal for an already-declared model is rejected
|
|
51
|
+
* BEFORE a card is ever posted. Returns [] when the agent has no declared
|
|
52
|
+
* models (or the block/keys are absent). Never throws — a parse failure yields
|
|
53
|
+
* [] so the caller degrades to the (also-guarded) build step.
|
|
54
|
+
*/
|
|
55
|
+
export function readDeclaredMentalModelNames(
|
|
56
|
+
configText: string,
|
|
57
|
+
agentName: string,
|
|
58
|
+
): string[] {
|
|
59
|
+
if (!configText || !agentName) return [];
|
|
60
|
+
let js: unknown;
|
|
61
|
+
try {
|
|
62
|
+
js = parseDocument(configText, { merge: false, strict: false }).toJS();
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const models = navigateMentalModels(js, agentName);
|
|
67
|
+
if (!Array.isArray(models)) return [];
|
|
68
|
+
return models
|
|
69
|
+
.map((m) => (m && typeof m === "object" ? (m as { name?: unknown }).name : undefined))
|
|
70
|
+
.filter((n): n is string => typeof n === "string");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function navigateMentalModels(js: unknown, agentName: string): unknown {
|
|
74
|
+
if (!js || typeof js !== "object") return undefined;
|
|
75
|
+
const agents = (js as Record<string, unknown>).agents;
|
|
76
|
+
if (!agents || typeof agents !== "object") return undefined;
|
|
77
|
+
const agent = (agents as Record<string, unknown>)[agentName];
|
|
78
|
+
if (!agent || typeof agent !== "object") return undefined;
|
|
79
|
+
const memory = (agent as Record<string, unknown>).memory;
|
|
80
|
+
if (!memory || typeof memory !== "object") return undefined;
|
|
81
|
+
return (memory as Record<string, unknown>).mental_models;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build a git-apply-compatible unified diff that APPENDS `spec` to the target
|
|
86
|
+
* agent's `memory.mental_models[]` in raw `configText`. Creates the
|
|
87
|
+
* `memory:` / `mental_models:` keys if absent. Returns a structured error
|
|
88
|
+
* (never throws) when:
|
|
89
|
+
* - the agent block is absent (`agent-not-found`) — we never invent one;
|
|
90
|
+
* - a model with the same name is already declared (`duplicate`) — the
|
|
91
|
+
* schema's idempotent-ensure key must stay unique;
|
|
92
|
+
* - the edit is a no-op (`no-change`);
|
|
93
|
+
* - the config doesn't parse (`parse-error`).
|
|
94
|
+
*
|
|
95
|
+
* Only fields that are set on `spec` are serialised, matching the minimal,
|
|
96
|
+
* schema-clean create payload #2874 emits.
|
|
97
|
+
*/
|
|
98
|
+
export function buildMentalModelAppendDiff(args: {
|
|
99
|
+
configText: string;
|
|
100
|
+
agentName: string;
|
|
101
|
+
spec: MentalModelProposeSpec;
|
|
102
|
+
/** Injectable git binary for tests (defaults to "git"). */
|
|
103
|
+
gitBin?: string;
|
|
104
|
+
}): BuildDiffResult {
|
|
105
|
+
const { configText, agentName, spec } = args;
|
|
106
|
+
|
|
107
|
+
let doc;
|
|
108
|
+
try {
|
|
109
|
+
doc = parseDocument(configText, { merge: false, strict: false });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return { ok: false, error: "parse-error", detail: (e as Error).message };
|
|
112
|
+
}
|
|
113
|
+
if (doc.errors && doc.errors.length > 0) {
|
|
114
|
+
return { ok: false, error: "parse-error", detail: doc.errors[0]!.message };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The agent block must already exist — a proposal never scaffolds a new
|
|
118
|
+
// agent, only augments the caller's own declared models.
|
|
119
|
+
if (doc.getIn(["agents", agentName]) === undefined) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
error: "agent-not-found",
|
|
123
|
+
detail: `agents.${agentName} not present in config`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Duplicate-name guard (defense-in-depth; the executor also checks up front
|
|
128
|
+
// so it never even posts a card for a dupe).
|
|
129
|
+
const existing = readDeclaredMentalModelNames(configText, agentName);
|
|
130
|
+
if (existing.includes(spec.name)) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
error: "duplicate",
|
|
134
|
+
detail: `mental model "${spec.name}" is already declared for ${agentName}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Assemble the minimal, schema-clean declaration node.
|
|
139
|
+
const item: Record<string, unknown> = {
|
|
140
|
+
name: spec.name,
|
|
141
|
+
source_query: spec.source_query,
|
|
142
|
+
};
|
|
143
|
+
if (spec.refresh_after_consolidation !== undefined) {
|
|
144
|
+
item.refresh_after_consolidation = spec.refresh_after_consolidation;
|
|
145
|
+
}
|
|
146
|
+
if (spec.max_tokens !== undefined) {
|
|
147
|
+
item.max_tokens = spec.max_tokens;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Append into agents.<agent>.memory.mental_models[], creating the
|
|
151
|
+
// intermediate mapping/sequence if they don't exist yet. `addIn` on an
|
|
152
|
+
// absent path creates the sequence; when it exists we push onto it.
|
|
153
|
+
const path = ["agents", agentName, "memory", "mental_models"];
|
|
154
|
+
const seq = doc.getIn(path);
|
|
155
|
+
if (seq === undefined) {
|
|
156
|
+
doc.setIn(path, [item]);
|
|
157
|
+
} else {
|
|
158
|
+
doc.addIn(path, item);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const after = String(doc);
|
|
162
|
+
if (after === configText) {
|
|
163
|
+
return { ok: false, error: "no-change", detail: "append produced no change" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const diff = generateUnifiedDiff(configText, after, "switchroom.yaml", args.gitBin ?? "git");
|
|
167
|
+
if (diff === "") {
|
|
168
|
+
return { ok: false, error: "no-change", detail: "diff was empty" };
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, diff, after };
|
|
171
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure builders for the synthetic inbounds the gateway injects after the
|
|
3
|
+
* operator taps Approve / Deny on a mental-model PROPOSAL card (hindsight
|
|
4
|
+
* Phase 5, stacked on #2874). Mirrors `vault-grant-inbound-builders.ts`.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from `gateway.ts` so the InboundMessage shape is pinned by tests
|
|
7
|
+
* separately from the config-apply/IPC plumbing. The shape is load-bearing —
|
|
8
|
+
* `meta.source` is what the bridge keys on to render
|
|
9
|
+
* `<channel source="mental_model_proposal_applied">` /
|
|
10
|
+
* `mental_model_proposal_denied` / `mental_model_proposal_failed` blocks, and
|
|
11
|
+
* the `meta.{agent,name,stage_id,operator_id}` fields are the forensic anchor.
|
|
12
|
+
*
|
|
13
|
+
* A regression that drops a meta field or changes the source string would
|
|
14
|
+
* silently break the agent's wake-up: the bridge wouldn't recognise the source
|
|
15
|
+
* and would route as a generic channel event, the model wouldn't know its
|
|
16
|
+
* proposal resolved, and the conversation would drift. Pinning these against
|
|
17
|
+
* fixture tests is cheaper than catching that downstream.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { InboundMessage } from "./ipc-protocol.js";
|
|
21
|
+
|
|
22
|
+
/** Subset of the pending proposal the builders need. Kept narrow so callers
|
|
23
|
+
* don't have to pass the full pending record. */
|
|
24
|
+
export interface MentalModelProposeInboundContext {
|
|
25
|
+
agent: string;
|
|
26
|
+
/** The proposed model's stable name (identity key). */
|
|
27
|
+
name: string;
|
|
28
|
+
/** Telegram chat id where the approval card lived. Used as the inbound's
|
|
29
|
+
* chatId so the synthesized turn stays associated with the originating
|
|
30
|
+
* conversation. */
|
|
31
|
+
chat_id: string;
|
|
32
|
+
/** Supergroup forum topic (message_thread_id) the agent was working in when
|
|
33
|
+
* it proposed — so the resumed turn's reply lands back in that topic, not
|
|
34
|
+
* General. Undefined for DM / non-topic proposals. */
|
|
35
|
+
threadId?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build the synthetic InboundMessage for a successful operator approval whose
|
|
40
|
+
* config edit APPLIED — the proposed model is now a declared model and has
|
|
41
|
+
* been (or will be, on reconcile) ensured in the bank.
|
|
42
|
+
*/
|
|
43
|
+
export function buildMentalModelProposeAppliedInbound(opts: {
|
|
44
|
+
ctx: MentalModelProposeInboundContext;
|
|
45
|
+
stageId: string;
|
|
46
|
+
operatorId: string;
|
|
47
|
+
nowMs?: number;
|
|
48
|
+
}): InboundMessage {
|
|
49
|
+
const ts = opts.nowMs ?? Date.now();
|
|
50
|
+
return {
|
|
51
|
+
type: "inbound",
|
|
52
|
+
chatId: opts.ctx.chat_id,
|
|
53
|
+
...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
|
|
54
|
+
messageId: ts, // synthetic — no Telegram message id exists
|
|
55
|
+
user: "hindsight-curator",
|
|
56
|
+
userId: 0,
|
|
57
|
+
ts,
|
|
58
|
+
text:
|
|
59
|
+
`✅ Operator approved your mental-model proposal \`${opts.ctx.name}\`. ` +
|
|
60
|
+
`It is now a DECLARED model in your \`memory.mental_models[]\` and is ` +
|
|
61
|
+
`being ensured in your bank (idempotent). Resume whatever you were doing ` +
|
|
62
|
+
`— the model will refresh from your bank content; no further action is ` +
|
|
63
|
+
`needed to create it.`,
|
|
64
|
+
meta: {
|
|
65
|
+
source: "mental_model_proposal_applied",
|
|
66
|
+
agent: opts.ctx.agent,
|
|
67
|
+
...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
|
|
68
|
+
name: opts.ctx.name,
|
|
69
|
+
stage_id: opts.stageId,
|
|
70
|
+
operator_id: opts.operatorId,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the synthetic InboundMessage for an operator denial. The model was
|
|
77
|
+
* NOT declared and NOTHING was written. Steers the agent to a fallback rather
|
|
78
|
+
* than re-proposing.
|
|
79
|
+
*/
|
|
80
|
+
export function buildMentalModelProposeDeniedInbound(opts: {
|
|
81
|
+
ctx: MentalModelProposeInboundContext;
|
|
82
|
+
stageId: string;
|
|
83
|
+
operatorId: string;
|
|
84
|
+
nowMs?: number;
|
|
85
|
+
}): InboundMessage {
|
|
86
|
+
const ts = opts.nowMs ?? Date.now();
|
|
87
|
+
return {
|
|
88
|
+
type: "inbound",
|
|
89
|
+
chatId: opts.ctx.chat_id,
|
|
90
|
+
...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
|
|
91
|
+
messageId: ts,
|
|
92
|
+
user: "hindsight-curator",
|
|
93
|
+
userId: 0,
|
|
94
|
+
ts,
|
|
95
|
+
text:
|
|
96
|
+
`🚫 Operator denied your mental-model proposal \`${opts.ctx.name}\`. ` +
|
|
97
|
+
`NOTHING was written — the model was not declared or created. Carry on ` +
|
|
98
|
+
`with the original task without it. Do NOT re-propose the same model ` +
|
|
99
|
+
`without first asking the user.`,
|
|
100
|
+
meta: {
|
|
101
|
+
source: "mental_model_proposal_denied",
|
|
102
|
+
agent: opts.ctx.agent,
|
|
103
|
+
...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
|
|
104
|
+
name: opts.ctx.name,
|
|
105
|
+
stage_id: opts.stageId,
|
|
106
|
+
operator_id: opts.operatorId,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build the synthetic InboundMessage for an approval whose config edit FAILED
|
|
113
|
+
* (hostd apply/reconcile error, config-edit disabled, a dupe that raced in,
|
|
114
|
+
* etc.). The operator tapped Approve but the model did NOT land — be honest so
|
|
115
|
+
* the agent doesn't assume it exists.
|
|
116
|
+
*/
|
|
117
|
+
export function buildMentalModelProposeFailedInbound(opts: {
|
|
118
|
+
ctx: MentalModelProposeInboundContext;
|
|
119
|
+
stageId: string;
|
|
120
|
+
operatorId: string;
|
|
121
|
+
reason: string;
|
|
122
|
+
nowMs?: number;
|
|
123
|
+
}): InboundMessage {
|
|
124
|
+
const ts = opts.nowMs ?? Date.now();
|
|
125
|
+
return {
|
|
126
|
+
type: "inbound",
|
|
127
|
+
chatId: opts.ctx.chat_id,
|
|
128
|
+
...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
|
|
129
|
+
messageId: ts,
|
|
130
|
+
user: "hindsight-curator",
|
|
131
|
+
userId: 0,
|
|
132
|
+
ts,
|
|
133
|
+
text:
|
|
134
|
+
`⚠️ The operator approved your mental-model proposal \`${opts.ctx.name}\` but ` +
|
|
135
|
+
`persisting it FAILED (${opts.reason}). The model was NOT declared or ` +
|
|
136
|
+
`created — do NOT assume it exists. Carry on with the original task; the ` +
|
|
137
|
+
`operator can retry the declaration by hand if it still matters.`,
|
|
138
|
+
meta: {
|
|
139
|
+
source: "mental_model_proposal_failed",
|
|
140
|
+
agent: opts.ctx.agent,
|
|
141
|
+
...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
|
|
142
|
+
name: opts.ctx.name,
|
|
143
|
+
stage_id: opts.stageId,
|
|
144
|
+
operator_id: opts.operatorId,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approve/deny ORCHESTRATION for the mental-model proposal flow (hindsight
|
|
3
|
+
* Phase 5, stacked on #2874), factored out of gateway.ts so the invariants are
|
|
4
|
+
* unit-testable with injected deps (no gateway, no Telegram, no hostd, no
|
|
5
|
+
* network):
|
|
6
|
+
*
|
|
7
|
+
* - APPROVE appends the model to config (via the injected config-edit
|
|
8
|
+
* dispatch) AND ensures it, then wakes the agent with an "applied" inbound.
|
|
9
|
+
* - DENY writes NOTHING (never reads config, never builds a diff, never
|
|
10
|
+
* dispatches an edit, never ensures) and wakes the agent with a "denied"
|
|
11
|
+
* inbound.
|
|
12
|
+
* - A duplicate / agent-not-found / no-change proposal is REJECTED before any
|
|
13
|
+
* edit is dispatched, and the agent gets a "failed" inbound.
|
|
14
|
+
*
|
|
15
|
+
* The gateway callback is a thin adapter that wires the real deps (read live
|
|
16
|
+
* config bytes, register the single-tap correlation, `config_propose_edit` via
|
|
17
|
+
* hostd, `ensureMentalModel`, inject-inbound) into `resolveMentalModelProposal`.
|
|
18
|
+
* Self-approval is impossible by construction: this function is only ever
|
|
19
|
+
* reached AFTER the gateway callback has verified the tapper is an allow-listed
|
|
20
|
+
* operator — the agent has no path to invoke it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
buildMentalModelAppendDiff,
|
|
25
|
+
type MentalModelProposeSpec,
|
|
26
|
+
} from "./mental-model-propose-diff.js";
|
|
27
|
+
import {
|
|
28
|
+
buildMentalModelProposeAppliedInbound,
|
|
29
|
+
buildMentalModelProposeDeniedInbound,
|
|
30
|
+
buildMentalModelProposeFailedInbound,
|
|
31
|
+
type MentalModelProposeInboundContext,
|
|
32
|
+
} from "./mental-model-propose-inbound-builders.js";
|
|
33
|
+
import type { InboundMessage } from "./ipc-protocol.js";
|
|
34
|
+
|
|
35
|
+
/** The pending proposal state the resolver needs. */
|
|
36
|
+
export interface MentalModelPendingProposal {
|
|
37
|
+
agent: string;
|
|
38
|
+
chat_id: string;
|
|
39
|
+
threadId?: number;
|
|
40
|
+
spec: MentalModelProposeSpec;
|
|
41
|
+
/** Human-readable rationale, echoed into the config-edit reason. */
|
|
42
|
+
reason?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Outcome of dispatching the config edit through hostd's apply+reconcile. */
|
|
46
|
+
export type ConfigEditDispatchResult =
|
|
47
|
+
| { state: "applied" }
|
|
48
|
+
| { state: "denied"; reason: string }
|
|
49
|
+
| { state: "error"; reason: string };
|
|
50
|
+
|
|
51
|
+
export interface ResolveDeps {
|
|
52
|
+
/** Read the live switchroom.yaml bytes (before-image for the diff). */
|
|
53
|
+
readConfigText: () => string;
|
|
54
|
+
/**
|
|
55
|
+
* Register a single-tap correlation so hostd's config-approval callback
|
|
56
|
+
* auto-resolves the edit WITHOUT posting a SECOND operator card — the
|
|
57
|
+
* operator already approved on THIS proposal card. Forge-resistance is the
|
|
58
|
+
* caller's job (exact diff byte-match). Called only on the approve path,
|
|
59
|
+
* only after a diff was successfully built.
|
|
60
|
+
*/
|
|
61
|
+
registerPreApproval: (agent: string, diff: string) => void;
|
|
62
|
+
/** Drop a previously-registered correlation (cleanup, single-shot). */
|
|
63
|
+
clearPreApproval: (agent: string, diff: string) => void;
|
|
64
|
+
/**
|
|
65
|
+
* Persist the append diff via hostd `config_propose_edit`
|
|
66
|
+
* (validate → approve → apply → reconcile). Reconcile runs
|
|
67
|
+
* `ensureDeclaredMentalModels` (#2874), materialising the model.
|
|
68
|
+
*/
|
|
69
|
+
dispatchConfigEdit: (args: {
|
|
70
|
+
agent: string;
|
|
71
|
+
diff: string;
|
|
72
|
+
reason: string;
|
|
73
|
+
}) => Promise<ConfigEditDispatchResult>;
|
|
74
|
+
/**
|
|
75
|
+
* Best-effort belt-and-suspenders ensure of the model in the bank
|
|
76
|
+
* immediately after apply (reconcile is the authoritative suspenders). Must
|
|
77
|
+
* not throw. Optional — when absent, ensure is left entirely to reconcile.
|
|
78
|
+
*/
|
|
79
|
+
ensureModel?: (spec: MentalModelProposeSpec) => Promise<void>;
|
|
80
|
+
/** Deliver a synthetic inbound to wake the agent with the outcome. */
|
|
81
|
+
injectInbound: (inbound: InboundMessage) => void;
|
|
82
|
+
/** Injectable clock for deterministic tests. */
|
|
83
|
+
now?: () => number;
|
|
84
|
+
/** Injectable git binary for the diff subprocess (tests). */
|
|
85
|
+
gitBin?: string;
|
|
86
|
+
log?: (msg: string) => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type ResolveOutcome =
|
|
90
|
+
| { outcome: "applied" }
|
|
91
|
+
| { outcome: "denied" }
|
|
92
|
+
| { outcome: "rejected"; reason: string }
|
|
93
|
+
| { outcome: "failed"; reason: string };
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve an operator's Approve / Deny decision on a mental-model proposal.
|
|
97
|
+
* Pure orchestration — all side effects go through `deps`.
|
|
98
|
+
*/
|
|
99
|
+
export async function resolveMentalModelProposal(
|
|
100
|
+
action: "approve" | "deny",
|
|
101
|
+
pending: MentalModelPendingProposal,
|
|
102
|
+
stageId: string,
|
|
103
|
+
operatorId: string,
|
|
104
|
+
deps: ResolveDeps,
|
|
105
|
+
): Promise<ResolveOutcome> {
|
|
106
|
+
const nowMs = (deps.now ?? Date.now)();
|
|
107
|
+
const ctx: MentalModelProposeInboundContext = {
|
|
108
|
+
agent: pending.agent,
|
|
109
|
+
name: pending.spec.name,
|
|
110
|
+
chat_id: pending.chat_id,
|
|
111
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// ── DENY: write NOTHING. No config read, no diff, no dispatch, no ensure. ──
|
|
115
|
+
if (action === "deny") {
|
|
116
|
+
deps.injectInbound(
|
|
117
|
+
buildMentalModelProposeDeniedInbound({ ctx, stageId, operatorId, nowMs }),
|
|
118
|
+
);
|
|
119
|
+
return { outcome: "denied" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── APPROVE: append to config (reused apply+reconcile) + ensure. ──
|
|
123
|
+
const configText = deps.readConfigText();
|
|
124
|
+
const built = buildMentalModelAppendDiff({
|
|
125
|
+
configText,
|
|
126
|
+
agentName: pending.agent,
|
|
127
|
+
spec: pending.spec,
|
|
128
|
+
...(deps.gitBin ? { gitBin: deps.gitBin } : {}),
|
|
129
|
+
});
|
|
130
|
+
if (!built.ok) {
|
|
131
|
+
// Duplicate / agent-not-found / no-change / parse-error — reject BEFORE
|
|
132
|
+
// any edit is dispatched. Nothing is written.
|
|
133
|
+
deps.log?.(
|
|
134
|
+
`mental_model_propose: approve rejected (${built.error}) for ${pending.agent} "${pending.spec.name}": ${built.detail}`,
|
|
135
|
+
);
|
|
136
|
+
deps.injectInbound(
|
|
137
|
+
buildMentalModelProposeFailedInbound({
|
|
138
|
+
ctx,
|
|
139
|
+
stageId,
|
|
140
|
+
operatorId,
|
|
141
|
+
reason: built.detail,
|
|
142
|
+
nowMs,
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
return { outcome: "rejected", reason: built.detail };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Pre-register the single-tap correlation so hostd auto-approves the edit
|
|
149
|
+
// (operator already approved on this proposal card — no second card).
|
|
150
|
+
deps.registerPreApproval(pending.agent, built.diff);
|
|
151
|
+
let dispatch: ConfigEditDispatchResult;
|
|
152
|
+
try {
|
|
153
|
+
dispatch = await deps.dispatchConfigEdit({
|
|
154
|
+
agent: pending.agent,
|
|
155
|
+
diff: built.diff,
|
|
156
|
+
reason:
|
|
157
|
+
`Declare agent-proposed mental model "${pending.spec.name}"` +
|
|
158
|
+
(pending.reason ? ` — ${pending.reason}` : ""),
|
|
159
|
+
});
|
|
160
|
+
} catch (err) {
|
|
161
|
+
dispatch = { state: "error", reason: (err as Error).message };
|
|
162
|
+
} finally {
|
|
163
|
+
// Single-shot: drop the correlation whether or not hostd consumed it.
|
|
164
|
+
deps.clearPreApproval(pending.agent, built.diff);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (dispatch.state !== "applied") {
|
|
168
|
+
deps.log?.(
|
|
169
|
+
`mental_model_propose: config edit ${dispatch.state} for ${pending.agent} "${pending.spec.name}": ${dispatch.reason}`,
|
|
170
|
+
);
|
|
171
|
+
deps.injectInbound(
|
|
172
|
+
buildMentalModelProposeFailedInbound({
|
|
173
|
+
ctx,
|
|
174
|
+
stageId,
|
|
175
|
+
operatorId,
|
|
176
|
+
reason: dispatch.reason,
|
|
177
|
+
nowMs,
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
return { outcome: "failed", reason: dispatch.reason };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Applied. Belt-and-suspenders ensure (reconcile already ensures via #2874;
|
|
184
|
+
// this makes the model available without waiting for the restart). Never let
|
|
185
|
+
// an ensure failure flip a successful declaration into a "failed" inbound —
|
|
186
|
+
// the model IS declared and will be ensured at reconcile regardless.
|
|
187
|
+
if (deps.ensureModel) {
|
|
188
|
+
try {
|
|
189
|
+
await deps.ensureModel(pending.spec);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
deps.log?.(
|
|
192
|
+
`mental_model_propose: best-effort ensure threw (declaration still applied) for ${pending.agent} "${pending.spec.name}": ${(err as Error).message}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
deps.injectInbound(
|
|
198
|
+
buildMentalModelProposeAppliedInbound({ ctx, stageId, operatorId, nowMs }),
|
|
199
|
+
);
|
|
200
|
+
return { outcome: "applied" };
|
|
201
|
+
}
|