switchroom 0.18.6 → 0.18.8
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 +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +1172 -812
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +146 -50
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2965 -862
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1618 -198
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +227 -54
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +203 -43
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/operator-events.test.ts +1 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
- package/telegram-plugin/worker-activity-feed.ts +9 -0
|
@@ -88,7 +88,8 @@ export function spoolId(msg: InboundMessage): string {
|
|
|
88
88
|
// given turn can only be one or the other.
|
|
89
89
|
if (
|
|
90
90
|
(msg.meta?.source === 'resume_interrupted' ||
|
|
91
|
-
msg.meta?.source === 'resume_watchdog_timeout'
|
|
91
|
+
msg.meta?.source === 'resume_watchdog_timeout' ||
|
|
92
|
+
msg.meta?.source === 'resume_deferred') &&
|
|
92
93
|
typeof msg.meta?.resume_turn_key === 'string' &&
|
|
93
94
|
msg.meta.resume_turn_key.length > 0
|
|
94
95
|
) {
|
|
@@ -104,6 +104,25 @@ describe('handleInjectCommand — guards', () => {
|
|
|
104
104
|
expect(replies[0].text).toContain('Usage')
|
|
105
105
|
expect(replies[0].text).toContain('/cost')
|
|
106
106
|
})
|
|
107
|
+
|
|
108
|
+
it('refuses `/inject /model <name>` and points at the real /model driver', async () => {
|
|
109
|
+
// Bug 7c: /inject /model <arg> bypasses the whole /model driver (no busy
|
|
110
|
+
// gate, no sr-* carrier/base-URL repoint, no session-override recording).
|
|
111
|
+
const inject = vi.fn()
|
|
112
|
+
const { deps, replies } = makeDeps({ getArgs: () => '/model sonnet', inject })
|
|
113
|
+
await handleInjectCommand(fakeCtx(), deps)
|
|
114
|
+
expect(inject).not.toHaveBeenCalled()
|
|
115
|
+
expect(replies).toHaveLength(1)
|
|
116
|
+
expect(replies[0].text).toContain('/model sonnet')
|
|
117
|
+
expect(replies[0].text).toContain('own driver')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('still allows bare `/inject /model` (read-only picker) through', async () => {
|
|
121
|
+
const inject = vi.fn().mockResolvedValue(okResult('/model', 'picker output'))
|
|
122
|
+
const { deps } = makeDeps({ getArgs: () => '/model', inject })
|
|
123
|
+
await handleInjectCommand(fakeCtx(), deps)
|
|
124
|
+
expect(inject).toHaveBeenCalledWith('gymbro', '/model')
|
|
125
|
+
})
|
|
107
126
|
})
|
|
108
127
|
|
|
109
128
|
describe('handleInjectCommand — outcome=ok', () => {
|
|
@@ -146,6 +146,23 @@ export async function handleInjectCommand(ctx: Context, deps: InjectDeps): Promi
|
|
|
146
146
|
return
|
|
147
147
|
}
|
|
148
148
|
const slashCommand = arg.startsWith('/') ? arg : `/${arg}`
|
|
149
|
+
|
|
150
|
+
// `/inject /model <name>` would type `/model <name>` straight into the pane,
|
|
151
|
+
// bypassing the entire /model driver — no busy gate, no sr-* carrier/base-URL
|
|
152
|
+
// repoint, no session-override recording. That path silently fails for sr-*
|
|
153
|
+
// ids and leaves /status stale for Claude ones. Route the operator to the real
|
|
154
|
+
// command. Bare `/inject /model` (no arg) still opens claude's read-only picker.
|
|
155
|
+
const injectTokens = slashCommand.split(/\s+/)
|
|
156
|
+
if (injectTokens[0]?.toLowerCase() === '/model' && injectTokens.length > 1) {
|
|
157
|
+
const modelArg = injectTokens.slice(1).join(' ')
|
|
158
|
+
await deps.reply(
|
|
159
|
+
ctx,
|
|
160
|
+
`\`/model\` has its own driver — use \`/model ${deps.escapeHtml(modelArg)}\` directly (not via \`/inject\`) so the switch is gated, recorded, and \`/status\` stays honest.`,
|
|
161
|
+
{ html: true },
|
|
162
|
+
)
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
149
166
|
const agentName = deps.getAgentName()
|
|
150
167
|
|
|
151
168
|
let result: InjectResult
|
|
@@ -134,6 +134,25 @@ export interface ConfigApprovalResolvedEvent {
|
|
|
134
134
|
denySource?: "operator" | "dispatch_failure";
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Issue #2971 — the wedge-watchdog's permission branch answer to
|
|
139
|
+
* `query_pending_permission`. Read-only reply sourced from the gateway's
|
|
140
|
+
* `pendingPermissions` map: `pending: true` means a live Telegram approval
|
|
141
|
+
* card (and the #2724 TTL reaper) is already racing the TUI prompt for this
|
|
142
|
+
* agent, so the watchdog must NOT Esc — the reaper's channel-delivered deny
|
|
143
|
+
* dismisses the TUI cleanly at TTL. `pending: false` means no card exists
|
|
144
|
+
* (bridge down, a `requiresUserInteraction` tool, or genuinely no live
|
|
145
|
+
* request) — the watchdog's Esc fallback is safe and expected.
|
|
146
|
+
*/
|
|
147
|
+
export interface PendingPermissionStatusEvent {
|
|
148
|
+
type: "pending_permission_status";
|
|
149
|
+
/** Echoes the correlationId from the originating query. */
|
|
150
|
+
correlationId: string;
|
|
151
|
+
pending: boolean;
|
|
152
|
+
/** The live `pendingPermissions` key, when `pending: true`. */
|
|
153
|
+
requestId?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
137
156
|
export type GatewayToClient =
|
|
138
157
|
| InboundMessage
|
|
139
158
|
| PermissionEvent
|
|
@@ -143,7 +162,8 @@ export type GatewayToClient =
|
|
|
143
162
|
| DriveApprovalPostedEvent
|
|
144
163
|
| Ms365ApprovalPostedEvent
|
|
145
164
|
| ConfigApprovalResolvedEvent
|
|
146
|
-
| RolloutStatusPostedEvent
|
|
165
|
+
| RolloutStatusPostedEvent
|
|
166
|
+
| PendingPermissionStatusEvent;
|
|
147
167
|
|
|
148
168
|
// === Bridge (Client) -> Gateway messages ===
|
|
149
169
|
|
|
@@ -555,6 +575,27 @@ export interface RolloutStatusPostedEvent {
|
|
|
555
575
|
reason?: string;
|
|
556
576
|
}
|
|
557
577
|
|
|
578
|
+
/**
|
|
579
|
+
* Issue #2971 — sent by the wedge-watchdog (autoaccept-poll sidecar) over
|
|
580
|
+
* the gateway UDS, BEFORE it would otherwise Esc a shape-persistent
|
|
581
|
+
* per-tool permission prompt. Read-only: asks whether a live Telegram
|
|
582
|
+
* approval card (`pendingPermissions`) already exists for this agent's
|
|
583
|
+
* in-flight permission request, so the watchdog can defer to the card /
|
|
584
|
+
* the #2724 TTL reaper instead of racing it with a keystroke.
|
|
585
|
+
*
|
|
586
|
+
* Trust model: same as quota_wall_detected — the socket is per-agent
|
|
587
|
+
* inside the container; `agentName` is validated server-side (and, in the
|
|
588
|
+
* gateway handler, checked against the gateway's own SWITCHROOM_AGENT_NAME)
|
|
589
|
+
* but this message can never mutate state — it only reads
|
|
590
|
+
* `pendingPermissions.size`/entries.
|
|
591
|
+
*/
|
|
592
|
+
export interface QueryPendingPermissionMessage {
|
|
593
|
+
type: "query_pending_permission";
|
|
594
|
+
agentName: string;
|
|
595
|
+
/** Caller-generated correlation id, echoed in the reply. */
|
|
596
|
+
correlationId: string;
|
|
597
|
+
}
|
|
598
|
+
|
|
558
599
|
export type ClientToGateway =
|
|
559
600
|
| RegisterMessage
|
|
560
601
|
| ToolCallMessage
|
|
@@ -574,4 +615,5 @@ export type ClientToGateway =
|
|
|
574
615
|
| SendOutboundMessage
|
|
575
616
|
| PostSkillProposalMessage
|
|
576
617
|
| RolloutStatusPostMessage
|
|
577
|
-
| RolloutStatusEditMessage
|
|
618
|
+
| RolloutStatusEditMessage
|
|
619
|
+
| QueryPendingPermissionMessage;
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
InjectInboundMessage,
|
|
7
7
|
SendOutboundMessage,
|
|
8
8
|
QuotaWallDetectedMessage,
|
|
9
|
+
QueryPendingPermissionMessage,
|
|
9
10
|
PostSkillProposalMessage,
|
|
10
11
|
OperatorEventForward,
|
|
11
12
|
PermissionRequestForward,
|
|
@@ -67,6 +68,18 @@ export interface IpcServerOptions {
|
|
|
67
68
|
* don't run failover simply ignore it.
|
|
68
69
|
*/
|
|
69
70
|
onQuotaWallDetected?: (client: IpcClient, msg: QuotaWallDetectedMessage) => void;
|
|
71
|
+
/**
|
|
72
|
+
* Issue #2971 — read-only wedge-watchdog query: is there a live pending
|
|
73
|
+
* permission request (Telegram approval card) for this agent right now?
|
|
74
|
+
* The handler MUST reply on the same connection with a
|
|
75
|
+
* `pending_permission_status` event (matching `correlationId`) sourced
|
|
76
|
+
* from the gateway's `pendingPermissions` map. Optional: when no handler
|
|
77
|
+
* is wired (older gateway build, or a test fixture that doesn't need it),
|
|
78
|
+
* the server replies `pending: false` immediately — the watchdog's
|
|
79
|
+
* existing Esc fallback then behaves exactly as it did before this
|
|
80
|
+
* feature (mixed-version safety).
|
|
81
|
+
*/
|
|
82
|
+
onQueryPendingPermission?: (client: IpcClient, msg: QueryPendingPermissionMessage) => void;
|
|
70
83
|
/**
|
|
71
84
|
* #2670 one-tap self-improvement — persist a skill-improvement proposal
|
|
72
85
|
* and post its Approve/Dismiss card. Handler persists the draft bundle to
|
|
@@ -330,6 +343,15 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
|
|
|
330
343
|
&& (typeof m.resetAt !== "number" || !Number.isFinite(m.resetAt as number))) return false;
|
|
331
344
|
return true;
|
|
332
345
|
}
|
|
346
|
+
case "query_pending_permission": {
|
|
347
|
+
// Issue #2971 — read-only wedge-watchdog probe. Wire shape only.
|
|
348
|
+
if (typeof m.agentName !== "string"
|
|
349
|
+
|| !AGENT_NAME_RE.test(m.agentName as string)) return false;
|
|
350
|
+
if (typeof m.correlationId !== "string"
|
|
351
|
+
|| (m.correlationId as string).length === 0
|
|
352
|
+
|| (m.correlationId as string).length > 64) return false;
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
333
355
|
case "request_config_approval": {
|
|
334
356
|
// #1623 — hostd-initiated config-edit approval card. Wire shape
|
|
335
357
|
// only; the handler module validates the diff content.
|
|
@@ -449,6 +471,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
449
471
|
onInjectInbound,
|
|
450
472
|
onSendOutbound,
|
|
451
473
|
onQuotaWallDetected,
|
|
474
|
+
onQueryPendingPermission,
|
|
452
475
|
onPostSkillProposal,
|
|
453
476
|
onRequestDriveApproval,
|
|
454
477
|
onRequestMs365Approval,
|
|
@@ -569,6 +592,23 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
569
592
|
case "quota_wall_detected":
|
|
570
593
|
if (onQuotaWallDetected) onQuotaWallDetected(client, msg as QuotaWallDetectedMessage);
|
|
571
594
|
break;
|
|
595
|
+
case "query_pending_permission":
|
|
596
|
+
if (onQueryPendingPermission) {
|
|
597
|
+
onQueryPendingPermission(client, msg as QueryPendingPermissionMessage);
|
|
598
|
+
} else {
|
|
599
|
+
// No handler wired — fail closed to "not pending" so the caller's
|
|
600
|
+
// Esc fallback fires (byte-identical to pre-#2971 behaviour).
|
|
601
|
+
try {
|
|
602
|
+
client.send({
|
|
603
|
+
type: "pending_permission_status",
|
|
604
|
+
correlationId: (msg as QueryPendingPermissionMessage).correlationId,
|
|
605
|
+
pending: false,
|
|
606
|
+
});
|
|
607
|
+
} catch {
|
|
608
|
+
/* best effort */
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
break;
|
|
572
612
|
case "request_drive_approval":
|
|
573
613
|
if (onRequestDriveApproval) {
|
|
574
614
|
// Handler is async — fire-and-forget here; the handler
|
|
@@ -27,6 +27,49 @@
|
|
|
27
27
|
import { parseDocument } from "yaml";
|
|
28
28
|
import { generateUnifiedDiff } from "../../src/web/config-diff.js";
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Decode the canonical six HTML/XML entities to their literal characters — the
|
|
32
|
+
* config-write-boundary layer of the #2976 defense-in-depth.
|
|
33
|
+
*
|
|
34
|
+
* SCOPE / reachability (be honest about which half is load-bearing):
|
|
35
|
+
* - `source_query` decode is the REACHABLE half. A model can copy escaped
|
|
36
|
+
* entities out of its Telegram-HTML-rendered context into a proposal's
|
|
37
|
+
* free-form `source_query`, and undecoded it would steer recall on `R&D`
|
|
38
|
+
* instead of `R&D` — a durable, silent corruption of a memory-integrity
|
|
39
|
+
* field. Normalizing here means an escaped query can never LAND in
|
|
40
|
+
* `memory.mental_models[]`.
|
|
41
|
+
* - `name` decode is REDUNDANT belt-and-suspenders. The `mental_model_propose`
|
|
42
|
+
* gateway tool already slug-validates `name` against
|
|
43
|
+
* /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/ BEFORE this runs, so an entity-bearing
|
|
44
|
+
* name is rejected upstream and can't reach here. It's kept only so this
|
|
45
|
+
* write boundary is self-contained if that gate ever moves.
|
|
46
|
+
* - The observed in-NAME corruption (`Nutrition Protocol & Deficit Status`,
|
|
47
|
+
* klanker 2026-07-06) actually arrived via the DIRECT `create_mental_model`
|
|
48
|
+
* Hindsight tool (`ensureMentalModel`, undecoded), NOT this propose path.
|
|
49
|
+
* That vector is OUT OF SCOPE here — covered by the steering.ts context-
|
|
50
|
+
* hygiene + in-service `Dockerfile.hindsight` normalization follow-ups.
|
|
51
|
+
*
|
|
52
|
+
* SINGLE PASS by design (matches issue #2976's success criteria): a
|
|
53
|
+
* double-escaped `R&amp;D` decodes ONE layer to `R&D`, not all the way
|
|
54
|
+
* to `R&D` — we only undo the escaping switchroom itself applied when it
|
|
55
|
+
* rendered the model's prior output, never the user's literal intent. `&`
|
|
56
|
+
* is decoded LAST so `&lt;` collapses to `<` (one layer), not `<`.
|
|
57
|
+
*
|
|
58
|
+
* Accepted tradeoff: a `source_query` a human genuinely intended to contain a
|
|
59
|
+
* literal `&` / `<` / etc. (rare — a reflection question, not markup)
|
|
60
|
+
* will have that one layer decoded. Steering queries reading as escaped HTML is
|
|
61
|
+
* the far more common and more harmful case, so we optimize for it.
|
|
62
|
+
*/
|
|
63
|
+
export function decodeCanonicalEntities(s: string): string {
|
|
64
|
+
return s
|
|
65
|
+
.replace(/</g, "<")
|
|
66
|
+
.replace(/>/g, ">")
|
|
67
|
+
.replace(/"/g, '"')
|
|
68
|
+
.replace(/'/g, "'")
|
|
69
|
+
.replace(/'/g, "'")
|
|
70
|
+
.replace(/&/g, "&");
|
|
71
|
+
}
|
|
72
|
+
|
|
30
73
|
/**
|
|
31
74
|
* A proposed mental model, in the same snake_case shape the
|
|
32
75
|
* `memory.mental_models[]` schema (#2874) accepts. `name` + `source_query`
|
|
@@ -125,20 +168,33 @@ export function buildMentalModelAppendDiff(args: {
|
|
|
125
168
|
}
|
|
126
169
|
|
|
127
170
|
// Duplicate-name guard (defense-in-depth; the executor also checks up front
|
|
128
|
-
// so it never even posts a card for a dupe).
|
|
171
|
+
// so it never even posts a card for a dupe). Compare on the DECODED name so a
|
|
172
|
+
// re-propose of an entity-escaped variant collides with the already-stored
|
|
173
|
+
// literal (#2976) rather than sneaking in a corrupted twin.
|
|
174
|
+
const decodedName = decodeCanonicalEntities(spec.name);
|
|
129
175
|
const existing = readDeclaredMentalModelNames(configText, agentName);
|
|
130
|
-
if (existing.includes(
|
|
176
|
+
if (existing.includes(decodedName)) {
|
|
131
177
|
return {
|
|
132
178
|
ok: false,
|
|
133
179
|
error: "duplicate",
|
|
134
|
-
detail: `mental model "${
|
|
180
|
+
detail: `mental model "${decodedName}" is already declared for ${agentName}`,
|
|
135
181
|
};
|
|
136
182
|
}
|
|
137
183
|
|
|
184
|
+
// #2976 write-boundary normalization: decode any HTML/XML entities the model
|
|
185
|
+
// may have copied out of its own escaped context so the LITERAL characters
|
|
186
|
+
// land in config — never `&` / `<` etc. The load-bearing target is the
|
|
187
|
+
// free-form `source_query`; the `name` decode is redundant with the gateway
|
|
188
|
+
// slug gate (an entity-bearing name is rejected upstream) and kept only so
|
|
189
|
+
// this boundary is self-contained. Duplicate-name guard runs on the decoded
|
|
190
|
+
// name above.
|
|
191
|
+
const name = decodedName;
|
|
192
|
+
const source_query = decodeCanonicalEntities(spec.source_query);
|
|
193
|
+
|
|
138
194
|
// Assemble the minimal, schema-clean declaration node.
|
|
139
195
|
const item: Record<string, unknown> = {
|
|
140
|
-
name
|
|
141
|
-
source_query
|
|
196
|
+
name,
|
|
197
|
+
source_query,
|
|
142
198
|
};
|
|
143
199
|
if (spec.refresh_after_consolidation !== undefined) {
|
|
144
200
|
item.refresh_after_consolidation = spec.refresh_after_consolidation;
|