switchroom 0.19.1 → 0.19.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/dist/agent-scheduler/index.js +31 -1
- package/dist/auth-broker/index.js +565 -48
- package/dist/cli/autoaccept-poll.js +31 -1
- package/dist/cli/drive-write-pretool.mjs +32 -2
- package/dist/cli/ms-365-write-pretool.mjs +32 -2
- package/dist/cli/switchroom.js +1148 -274
- package/dist/host-control/main.js +3 -3
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +1 -0
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/skills/switchroom-cli/SKILL.md +25 -0
- package/telegram-plugin/auth-snapshot-format.ts +143 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1427 -689
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/external-spend.ts +135 -0
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +141 -158
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +309 -1
- package/telegram-plugin/gateway/narrative-lane.ts +23 -9
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/status-pin-store.ts +64 -4
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/gateway/usage-mask.ts +29 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
- package/telegram-plugin/quota-bar-format.ts +78 -12
- package/telegram-plugin/quota-check.ts +17 -2
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/external-spend.test.ts +168 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +219 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
- package/telegram-plugin/tests/quota-check.test.ts +57 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
- package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
- package/telegram-plugin/tests/usage-mask.test.ts +35 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- package/vendor/hindsight-memory/settings.json +3 -1
|
@@ -188,6 +188,293 @@ export function classifyModelSwitchConfirmation(input: {
|
|
|
188
188
|
return { kind: 'default', launched: revertedTo }
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Diagnostic stderr lines for a /model apply-boot rehydration.
|
|
193
|
+
* Pure strings so gateway.ts stays thin (line-ratchet #2996).
|
|
194
|
+
*/
|
|
195
|
+
export function formatModelRelaunchDiagLog(input: {
|
|
196
|
+
agent: string
|
|
197
|
+
launched: string
|
|
198
|
+
configured: string
|
|
199
|
+
confirmation: ModelSwitchConfirmation | null
|
|
200
|
+
isApplyBoot: boolean
|
|
201
|
+
}): string {
|
|
202
|
+
const { agent, launched, configured, confirmation, isApplyBoot } = input
|
|
203
|
+
const L = launched || '(none)'
|
|
204
|
+
if (confirmation == null) {
|
|
205
|
+
return (
|
|
206
|
+
'telegram gateway: gw /model relaunch applied agent=' +
|
|
207
|
+
agent +
|
|
208
|
+
' launched=' +
|
|
209
|
+
L +
|
|
210
|
+
' configured=' +
|
|
211
|
+
configured +
|
|
212
|
+
' override=' +
|
|
213
|
+
(isApplyBoot ? 'set' : 'cleared') +
|
|
214
|
+
'\n'
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
if (confirmation.kind === 'not-applied') {
|
|
218
|
+
return (
|
|
219
|
+
'telegram gateway: gw /model relaunch NOT-APPLIED agent=' +
|
|
220
|
+
agent +
|
|
221
|
+
' target=' +
|
|
222
|
+
confirmation.target +
|
|
223
|
+
' launched=' +
|
|
224
|
+
L +
|
|
225
|
+
' configured=' +
|
|
226
|
+
configured +
|
|
227
|
+
' revertedTo=' +
|
|
228
|
+
confirmation.revertedTo +
|
|
229
|
+
'\n'
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
if (confirmation.kind === 'applied') {
|
|
233
|
+
return (
|
|
234
|
+
'telegram gateway: gw /model relaunch applied agent=' +
|
|
235
|
+
agent +
|
|
236
|
+
' launched=' +
|
|
237
|
+
L +
|
|
238
|
+
' configured=' +
|
|
239
|
+
configured +
|
|
240
|
+
' override=set outcome=applied\n'
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
return (
|
|
244
|
+
'telegram gateway: gw /model relaunch applied agent=' +
|
|
245
|
+
agent +
|
|
246
|
+
' launched=' +
|
|
247
|
+
L +
|
|
248
|
+
' configured=' +
|
|
249
|
+
configured +
|
|
250
|
+
' override=cleared outcome=default\n'
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Telegram body for the single switch-confirmation card (F1/N4). */
|
|
255
|
+
export function formatModelSwitchConfirmationBody(
|
|
256
|
+
confirmation: ModelSwitchConfirmation,
|
|
257
|
+
): string {
|
|
258
|
+
if (confirmation.kind === 'applied') {
|
|
259
|
+
return (
|
|
260
|
+
'✅ Now running `' +
|
|
261
|
+
confirmation.launched +
|
|
262
|
+
'` — session-only, reverts to the configured model on the next restart. Fresh session; memory and the handoff briefing carry the context.'
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
if (confirmation.kind === 'not-applied') {
|
|
266
|
+
return (
|
|
267
|
+
"⚠️ Your switch to `" +
|
|
268
|
+
confirmation.target +
|
|
269
|
+
"` didn't apply — the agent reverted to `" +
|
|
270
|
+
confirmation.revertedTo +
|
|
271
|
+
"` (the apply-boot didn't complete). Re-issue `/model " +
|
|
272
|
+
confirmation.target +
|
|
273
|
+
"` to try again."
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
return (
|
|
277
|
+
'✅ Now running `' +
|
|
278
|
+
confirmation.launched +
|
|
279
|
+
'` (the configured default) — fresh session; memory and the handoff briefing carry the context.'
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function formatModelRelaunchSuppressNotAppliedLog(input: {
|
|
284
|
+
agent: string
|
|
285
|
+
target: string
|
|
286
|
+
}): string {
|
|
287
|
+
return (
|
|
288
|
+
'telegram gateway: gw /model relaunch NOT-APPLIED — suppressing not-applied confirmation (a .session-model-alert is present and will be relayed) agent=' +
|
|
289
|
+
input.agent +
|
|
290
|
+
' target=' +
|
|
291
|
+
input.target +
|
|
292
|
+
'\n'
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The single boot-time notification decision for a classified /model switch
|
|
298
|
+
* (#3427 item 2): either the ONE confirmation card (green applied/default or
|
|
299
|
+
* the ⚠️ not-applied warn), or — LOW-2 dedup — a suppress log when start.sh
|
|
300
|
+
* wrote a TAILORED `.session-model-alert` for this boot that already explains
|
|
301
|
+
* why the switch didn't apply (the alert relay is the more specific message,
|
|
302
|
+
* so the generic not-applied card would double-warn). Pure so the decision is
|
|
303
|
+
* unit-testable end-to-end (classify → notice) without booting the gateway;
|
|
304
|
+
* gateway.ts only sends `card` bodies / writes `suppress` logs.
|
|
305
|
+
*/
|
|
306
|
+
export type ModelSwitchBootNotice =
|
|
307
|
+
| { kind: 'card'; body: string }
|
|
308
|
+
| { kind: 'suppress'; log: string }
|
|
309
|
+
|
|
310
|
+
export function resolveModelSwitchBootNotice(input: {
|
|
311
|
+
agent: string
|
|
312
|
+
confirmation: ModelSwitchConfirmation
|
|
313
|
+
hasSessionModelAlert: boolean
|
|
314
|
+
}): ModelSwitchBootNotice {
|
|
315
|
+
const { agent, confirmation, hasSessionModelAlert } = input
|
|
316
|
+
if (confirmation.kind === 'not-applied' && hasSessionModelAlert) {
|
|
317
|
+
return {
|
|
318
|
+
kind: 'suppress',
|
|
319
|
+
log: formatModelRelaunchSuppressNotAppliedLog({ agent, target: confirmation.target }),
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { kind: 'card', body: formatModelSwitchConfirmationBody(confirmation) }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Requested-vs-served divergence gate (#3427 item 4). `--fallback-model` masks
|
|
327
|
+
* a shape-valid but UNKNOWN requested Claude id: claude silently serves the
|
|
328
|
+
* fallback while `.active-session-model` (and the boot confirmation) carry the
|
|
329
|
+
* requested token, until the first assistant transcript line reclaims /status.
|
|
330
|
+
* That window used to self-heal SILENTLY — the operator was never told their
|
|
331
|
+
* requested id was bogus. This comparator lets the session-model source flag
|
|
332
|
+
* the divergence deterministically at the FIRST possible post-launch signal
|
|
333
|
+
* (the first assistant line's `message.model`).
|
|
334
|
+
*
|
|
335
|
+
* Deliberately conservative — return true ("matches") whenever the pair is not
|
|
336
|
+
* DETERMINISTICALLY comparable, so a false accusation is impossible:
|
|
337
|
+
* - served non-`claude-*` ids (sr-* / LiteLLM-mapped names) are skipped: the
|
|
338
|
+
* proxy may echo an alias, a mapped id, or the raw route name;
|
|
339
|
+
* - a requested alias (opus/sonnet/haiku/fable) family-matches its resolved
|
|
340
|
+
* full id (`sonnet` ≡ `claude-sonnet-5-…`) via modelFamilyToken;
|
|
341
|
+
* - a requested full `claude-*` id must be served exactly, or as a
|
|
342
|
+
* date-stamped descendant (`claude-sonnet-5` ≡ `claude-sonnet-5-20260203`);
|
|
343
|
+
* - anything else (sr-* requests, legacy friendly labels like "Opus 4.8")
|
|
344
|
+
* is not comparable → true.
|
|
345
|
+
*/
|
|
346
|
+
export function servedModelMatchesRequested(requested: string, served: string): boolean {
|
|
347
|
+
const req = requested.trim().toLowerCase()
|
|
348
|
+
const srv = served.trim().toLowerCase()
|
|
349
|
+
if (!srv.startsWith('claude-')) return true
|
|
350
|
+
if ((MODEL_ALIASES as readonly string[]).includes(req)) {
|
|
351
|
+
if (req === 'default') return true
|
|
352
|
+
if (modelFamilyToken(srv) === req) return true
|
|
353
|
+
// L3 (#3437 review): legacy id shapes put the family AFTER the version
|
|
354
|
+
// (`claude-3-opus-20240229` → first segment "3", not "opus"). Accept any
|
|
355
|
+
// dash-segment equal to the alias — widens toward "match" only, so it can
|
|
356
|
+
// suppress a real accusation in weird shapes but never create a false one.
|
|
357
|
+
return srv.slice('claude-'.length).split('-').includes(req)
|
|
358
|
+
}
|
|
359
|
+
if (req.startsWith('claude-')) {
|
|
360
|
+
return srv === req || srv.startsWith(req + '-')
|
|
361
|
+
}
|
|
362
|
+
return true
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Greppable stderr line for a requested-vs-served divergence (#3427 item 4).
|
|
367
|
+
* Names BOTH candidate causes (M2): `--fallback-model` substitutes for an
|
|
368
|
+
* invalid/unknown id AND for a transiently-unavailable one — the signal alone
|
|
369
|
+
* cannot distinguish them, so the log must not assert "invalid".
|
|
370
|
+
*/
|
|
371
|
+
export function formatServedModelDivergenceLog(input: {
|
|
372
|
+
agent: string
|
|
373
|
+
requested: string
|
|
374
|
+
served: string
|
|
375
|
+
}): string {
|
|
376
|
+
return (
|
|
377
|
+
'telegram gateway: gw /model served-model DIVERGENCE agent=' +
|
|
378
|
+
input.agent +
|
|
379
|
+
' requested=' +
|
|
380
|
+
input.requested +
|
|
381
|
+
' served=' +
|
|
382
|
+
input.served +
|
|
383
|
+
' (--fallback-model substituted: requested id invalid/unknown OR model transiently unavailable)\n'
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Operator card for a requested-vs-served divergence (#3427 item 4, M2-softened). */
|
|
388
|
+
export function formatServedModelDivergenceCard(input: {
|
|
389
|
+
requested: string
|
|
390
|
+
served: string
|
|
391
|
+
}): string {
|
|
392
|
+
return (
|
|
393
|
+
'⚠️ The first reply was served by `' +
|
|
394
|
+
input.served +
|
|
395
|
+
'`, not the requested `' +
|
|
396
|
+
input.requested +
|
|
397
|
+
'` — claude substituted the fallback model. Either the requested id is invalid/unknown, or the model was temporarily unavailable for that call (a transient substitution self-corrects on later replies). If it persists, re-issue `/model <valid id>` or `/model default`. `/status` always shows the model actually serving calls.'
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The boot marker chat that initiated a /model switch (thread-aware). */
|
|
402
|
+
export interface ModelBootCardTarget {
|
|
403
|
+
chatId: string
|
|
404
|
+
threadId: number | null
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Injected side-effect surface for the boot-time /model cards (#3427): the
|
|
409
|
+
* served-model divergence warn and the switch confirmation share ONE raw
|
|
410
|
+
* Markdown send closure plus a stderr log sink. Lives here (not inline in
|
|
411
|
+
* gateway.ts's boot IIFE) so gateway.ts does not inflate (#2996 ratchet).
|
|
412
|
+
*/
|
|
413
|
+
export interface ModelBootCardDeps {
|
|
414
|
+
agent: string
|
|
415
|
+
/** null → no initiating chat known; cards are skipped, logs still write. */
|
|
416
|
+
chat: ModelBootCardTarget | null
|
|
417
|
+
log: (line: string) => void
|
|
418
|
+
sendCard: (
|
|
419
|
+
chatId: string,
|
|
420
|
+
body: string,
|
|
421
|
+
opts: { parse_mode: 'Markdown'; message_thread_id?: number },
|
|
422
|
+
) => Promise<unknown>
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** One-shot fire-and-forget card to the marker chat; send failures log, never throw. */
|
|
426
|
+
function sendModelBootCard(deps: ModelBootCardDeps, chat: ModelBootCardTarget, body: string, failLabel: string): void {
|
|
427
|
+
void deps
|
|
428
|
+
.sendCard(chat.chatId, body, {
|
|
429
|
+
parse_mode: 'Markdown',
|
|
430
|
+
...(chat.threadId != null ? { message_thread_id: chat.threadId } : {}),
|
|
431
|
+
})
|
|
432
|
+
.catch((err: unknown) =>
|
|
433
|
+
deps.log(`telegram gateway: ${failLabel} send failed: ${(err as Error)?.message ?? String(err)}\n`),
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Build the divergence-tripwire handler gateway.ts registers at the boot
|
|
439
|
+
* rehydration site (#3427 item 4): the first LIVE assistant line serving a
|
|
440
|
+
* different model (invalid id OR transient unavailability — --fallback-model
|
|
441
|
+
* substituted) logs + warns the operator. The override is KEPT (M2): freshness
|
|
442
|
+
* rules already make /status show the served model, and a transient
|
|
443
|
+
* substitution self-corrects without destroying the switch record.
|
|
444
|
+
*/
|
|
445
|
+
export function buildServedModelDivergenceHandler(
|
|
446
|
+
deps: ModelBootCardDeps,
|
|
447
|
+
): (d: { requested: string; served: string }) => void {
|
|
448
|
+
return (d) => {
|
|
449
|
+
deps.log(formatServedModelDivergenceLog({ agent: deps.agent, requested: d.requested, served: d.served }))
|
|
450
|
+
if (deps.chat == null) return
|
|
451
|
+
sendModelBootCard(deps, deps.chat, formatServedModelDivergenceCard(d), 'served-model divergence')
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Deliver the boot-time model-switch confirmation (#3427 item 2): resolve the
|
|
457
|
+
* card-vs-suppress decision (pure — resolveModelSwitchBootNotice) and either
|
|
458
|
+
* log the suppress line or send the card. No-op when no initiating chat is
|
|
459
|
+
* known — matching the pre-extraction inline gateway.ts behavior (the
|
|
460
|
+
* suppress log only ever wrote when a marker chat existed).
|
|
461
|
+
*/
|
|
462
|
+
export function deliverModelSwitchBootNotice(
|
|
463
|
+
deps: ModelBootCardDeps & { confirmation: ModelSwitchConfirmation; hasSessionModelAlert: boolean },
|
|
464
|
+
): void {
|
|
465
|
+
if (deps.chat == null) return
|
|
466
|
+
const notice = resolveModelSwitchBootNotice({
|
|
467
|
+
agent: deps.agent,
|
|
468
|
+
confirmation: deps.confirmation,
|
|
469
|
+
hasSessionModelAlert: deps.hasSessionModelAlert,
|
|
470
|
+
})
|
|
471
|
+
if (notice.kind === 'suppress') {
|
|
472
|
+
deps.log(notice.log)
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
sendModelBootCard(deps, deps.chat, notice.body, 'model-switch confirmation')
|
|
476
|
+
}
|
|
477
|
+
|
|
191
478
|
export type ParsedModelCommand =
|
|
192
479
|
| { kind: 'show' }
|
|
193
480
|
| { kind: 'set'; model: string }
|
|
@@ -528,6 +815,23 @@ function relaunchErrorReply(
|
|
|
528
815
|
return { text: `❌ Could not schedule model switch: ${deps.escapeHtml(msg)}`, html: true }
|
|
529
816
|
}
|
|
530
817
|
|
|
818
|
+
/**
|
|
819
|
+
* Fail-fast caveat (#3427 item 4) for a free-text full `claude-*` id: the
|
|
820
|
+
* gateway cannot pre-validate an arbitrary id against the API (Claude-native
|
|
821
|
+
* constraint — no raw API probes), so if the id is bogus `--fallback-model`
|
|
822
|
+
* silently serves the fallback. Say so IMMEDIATELY in the switch ack, and
|
|
823
|
+
* point at the first-reply tripwire that will catch it. Aliases and sr-* ids
|
|
824
|
+
* are vouched by their own gates (MODEL_ALIASES / the LiteLLM route probe),
|
|
825
|
+
* so only typed `claude-*` ids carry the caveat. Exported for tests.
|
|
826
|
+
*/
|
|
827
|
+
export function unvalidatedIdCaveat(
|
|
828
|
+
deps: Pick<ModelCommandDeps, 'escapeHtml'>,
|
|
829
|
+
model: string,
|
|
830
|
+
): string | null {
|
|
831
|
+
if (!model.trim().toLowerCase().startsWith('claude-')) return null
|
|
832
|
+
return `_\`${deps.escapeHtml(model)}\` can't be validated before launch — if it isn't a real Claude model id, claude will silently serve the configured fallback model instead. I check the first reply and will warn if that happens._`
|
|
833
|
+
}
|
|
834
|
+
|
|
531
835
|
/** Schedule a carrier relaunch onto `model`, returning the deterministic ack. */
|
|
532
836
|
async function scheduleRelaunchReply(
|
|
533
837
|
deps: ModelCommandDeps,
|
|
@@ -539,7 +843,11 @@ async function scheduleRelaunchReply(
|
|
|
539
843
|
} catch (err) {
|
|
540
844
|
return relaunchErrorReply(deps, model, err)
|
|
541
845
|
}
|
|
542
|
-
|
|
846
|
+
const caveat = unvalidatedIdCaveat(deps, model)
|
|
847
|
+
return {
|
|
848
|
+
text: [switchingLine(deps, model), ...(caveat ? [caveat] : []), PERSIST_NOTE].join('\n'),
|
|
849
|
+
html: true,
|
|
850
|
+
}
|
|
543
851
|
}
|
|
544
852
|
|
|
545
853
|
/** Schedule the `/model default` clear + revert relaunch, returning its ack. */
|
|
@@ -389,7 +389,18 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
|
|
|
389
389
|
// mid-turn has something to finalize on next boot instead of
|
|
390
390
|
// leaving this card frozen forever. Fire-and-forget/best-effort:
|
|
391
391
|
// a failed persist degrades to the pre-fix (in-memory-only)
|
|
392
|
-
// behaviour, never blocks the card opening
|
|
392
|
+
// behaviour, never blocks the card opening (writeActivityCardRecord
|
|
393
|
+
// → persistActivityCards swallows write errors — it never throws).
|
|
394
|
+
//
|
|
395
|
+
// F6 (persist-intent-first ordering): this write is the FIRST action
|
|
396
|
+
// taken after the send resolves and BEFORE the status-pin reconcile
|
|
397
|
+
// below — no `await` sits between the send and this persist, so the
|
|
398
|
+
// crash window in which a sent card has no durable record (and is
|
|
399
|
+
// thus unreapable by the boot reaper) is the minimum achievable. A
|
|
400
|
+
// true pre-send provisional record is impossible: the reaper keys
|
|
401
|
+
// its finalizing edit on `activityMessageId`, which only exists once
|
|
402
|
+
// sendRichMessage returns. Keep this persist synchronous and ahead
|
|
403
|
+
// of the pin; do not move it after an await.
|
|
393
404
|
if (activityCardPersistEnabled) {
|
|
394
405
|
writeActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, {
|
|
395
406
|
turnKey: statusKey(chat, thread),
|
|
@@ -397,15 +408,18 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
|
|
|
397
408
|
threadId: thread ?? null,
|
|
398
409
|
activityMessageId: sent.message_id,
|
|
399
410
|
startedAt: turn.startedAt,
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
// `PIN_STATUS_WHILE_WORKING` is on
|
|
403
|
-
// when it's off, and can also fail
|
|
404
|
-
// rights). Persisting `pinned: true`
|
|
405
|
-
// boot reaper attempt an unpin on a
|
|
406
|
-
// The
|
|
411
|
+
// F5 (persist-intent honesty): mirror the ACTUAL pin decision,
|
|
412
|
+
// not an unconditional `true`. The OPEN below silently-pins the
|
|
413
|
+
// fresh card only when `PIN_STATUS_WHILE_WORKING` is on
|
|
414
|
+
// (`reconcileStatusPin` no-ops when it's off, and can also fail
|
|
415
|
+
// on missing supergroup rights). Persisting `pinned: true`
|
|
416
|
+
// regardless would make the boot reaper attempt an unpin on a
|
|
417
|
+
// card that was never pinned. The persist is intentionally
|
|
418
|
+
// written BEFORE the fire-and-forget pin resolves (intent, not
|
|
419
|
+
// outcome); the reaper's unpin is idempotent defense-in-depth
|
|
407
420
|
// (`statusPinBootCleanup` owns the primary unpin), so tracking
|
|
408
|
-
// the
|
|
421
|
+
// the DECISION honestly — pinned iff we will actually attempt a
|
|
422
|
+
// pin — is what matters here, not the async pin's result.
|
|
409
423
|
pinned: PIN_STATUS_WHILE_WORKING,
|
|
410
424
|
})
|
|
411
425
|
}
|
|
@@ -53,7 +53,11 @@ import { resolveChatIdFallback } from './chat-id-fallback.js'
|
|
|
53
53
|
import { isFinalAnswerReply, isSubstantiveFinalReply } from '../final-answer-detect.js'
|
|
54
54
|
import { decideOverPing, type OverPingDecision } from '../over-ping-safety-net.js'
|
|
55
55
|
import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
|
|
56
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
decideSupersedeCorrection,
|
|
58
|
+
flushedAnswerMatchesReply,
|
|
59
|
+
type FlushedTurnSupersedeRegistry,
|
|
60
|
+
} from '../flushed-turn-supersede.js'
|
|
57
61
|
import { decideAnswerLatchSuppression } from '../reply-owner-resolve.js'
|
|
58
62
|
import { deriveTelegraphTitle } from '../telegraph.js'
|
|
59
63
|
import {
|
|
@@ -872,10 +876,16 @@ export async function sendReply(
|
|
|
872
876
|
// resolvers agree and the late-reply supersede fires by identity.
|
|
873
877
|
const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args)
|
|
874
878
|
const resolvedTurnId = ownerTurn?.turnId ?? null
|
|
879
|
+
// #3429 — pass the (normalized) reply text so the registry can apply the
|
|
880
|
+
// new-content gate: identity match + TTL alone also fits an async handback
|
|
881
|
+
// that merely resolved this flush-delivered ENDED turn as its owner via
|
|
882
|
+
// the latest-ended tier. Editing the flushed message in place with that
|
|
883
|
+
// handback's text does not re-notify client-side (Telegram edits never
|
|
884
|
+
// push) — the observed silent non-surfacing of msgs 10482/10486.
|
|
875
885
|
const decision = flushedTurnSupersede.take(
|
|
876
886
|
chat_id,
|
|
877
887
|
replyThreadId,
|
|
878
|
-
{ liveTurnId: resolvedTurnId, now: Date.now() },
|
|
888
|
+
{ liveTurnId: resolvedTurnId, replyText: text, now: Date.now() },
|
|
879
889
|
)
|
|
880
890
|
if (decision.supersede) {
|
|
881
891
|
process.stderr.write(
|
|
@@ -899,20 +909,31 @@ export async function sendReply(
|
|
|
899
909
|
// resolves the same ended owner turn, sees the latch, and is suppressed —
|
|
900
910
|
// exactly one message ever ships. The latch is idempotent and the normal
|
|
901
911
|
// (no-throw) path is unaffected: the correction below still ships B once.
|
|
902
|
-
|
|
912
|
+
// Tagged 'flush' (#3426): a flush record existed for this turn (take()
|
|
913
|
+
// just consumed it), so the flushed message A is what the suppression
|
|
914
|
+
// protects against duplicating. #3429: stash the record's flushed text
|
|
915
|
+
// alongside, so the retry's latch check can discriminate by content —
|
|
916
|
+
// the retry of THIS superseding reply matches and stays suppressed, while
|
|
917
|
+
// a later genuinely-new handback does not and delivers.
|
|
918
|
+
if (ownerTurn != null) {
|
|
919
|
+
ownerTurn.answerDelivered = 'flush'
|
|
920
|
+
if (decision.recordText != null) ownerTurn.flushedAnswerText = decision.recordText
|
|
921
|
+
}
|
|
903
922
|
} else {
|
|
904
923
|
// 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race latch.
|
|
905
924
|
// Supersede found no record. Either there was no flush (normal reply), or
|
|
906
925
|
// the flush FIRED but has not yet recorded its message ids (the residual
|
|
907
926
|
// pre-record race Part 1's supersede cannot reach). The flush sets
|
|
908
|
-
// `answerDelivered =
|
|
909
|
-
// send AND before `record`), and it persists on the ended turn — so
|
|
910
|
-
// this LATE, substantive reply resolves its owner turn and sees the
|
|
911
|
-
//
|
|
912
|
-
// reply would ship a duplicate. Suppress it. Scoped to the
|
|
913
|
-
// ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor
|
|
914
|
-
// interim sub-floor ack, a
|
|
915
|
-
//
|
|
927
|
+
// `answerDelivered = 'flush'` synchronously at fire time (before its
|
|
928
|
+
// async send AND before `record`), and it persists on the ended turn — so
|
|
929
|
+
// when this LATE, substantive reply resolves its owner turn and sees the
|
|
930
|
+
// FLUSH-armed latch, the flush's message A is already on its way out and
|
|
931
|
+
// this reply would ship a duplicate. Suppress it. Scoped to the
|
|
932
|
+
// substantive ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor, the late-reply case,
|
|
933
|
+
// AND the 'flush' latch source (#3426) so an interim sub-floor ack, a
|
|
934
|
+
// chunked multi-part answer, a legitimate second in-turn substantive
|
|
935
|
+
// reply (live `currentTurn`), or an async sub-agent handback landing
|
|
936
|
+
// after a reply-delivered turn ended (latch = 'reply') is never
|
|
916
937
|
// suppressed. `isSubstantiveFinalReply` reduces to the ≥200-char test on
|
|
917
938
|
// the `reply` path (no `done`); pass the model's original notification
|
|
918
939
|
// intent to mirror the #2533 decoupling call shape.
|
|
@@ -920,12 +941,33 @@ export async function sendReply(
|
|
|
920
941
|
text: rawText,
|
|
921
942
|
disableNotification: args.disable_notification === true,
|
|
922
943
|
})
|
|
944
|
+
// #3429 — content evidence for the latch. `'new-content'` is the
|
|
945
|
+
// registry's POSITIVE determination that this reply differs from the
|
|
946
|
+
// flushed answer (record present, identity matched, text did not);
|
|
947
|
+
// otherwise compare against the owner turn's stashed `flushedAnswerText`
|
|
948
|
+
// (covers the post-fire pre-record race window, where no record exists
|
|
949
|
+
// yet but the fire site already stamped what it is delivering). Null —
|
|
950
|
+
// no flushed text to compare — keeps the conservative pre-#3429
|
|
951
|
+
// flush-armed suppression.
|
|
952
|
+
const replyMatchesFlushedAnswer: boolean | null =
|
|
953
|
+
decision.reason === 'new-content'
|
|
954
|
+
? false
|
|
955
|
+
: ownerTurn?.flushedAnswerText != null
|
|
956
|
+
? flushedAnswerMatchesReply(ownerTurn.flushedAnswerText, text)
|
|
957
|
+
: null
|
|
923
958
|
const suppressByLatch = decideAnswerLatchSuppression({
|
|
924
959
|
superseded: false,
|
|
925
960
|
replySubstantive,
|
|
926
961
|
isLateReply: turn == null,
|
|
927
962
|
ownerAnswerDelivered: ownerTurn?.answerDelivered ?? false,
|
|
963
|
+
replyMatchesFlushedAnswer,
|
|
928
964
|
})
|
|
965
|
+
if (decision.reason === 'new-content') {
|
|
966
|
+
process.stderr.write(
|
|
967
|
+
`telegram gateway: reply: flush supersede declined — new content (#3429) ` +
|
|
968
|
+
`chatId=${chat_id} ownerTurnId=${JSON.stringify(resolvedTurnId)}; sending fresh\n`,
|
|
969
|
+
)
|
|
970
|
+
}
|
|
929
971
|
if (suppressByLatch) {
|
|
930
972
|
process.stderr.write(
|
|
931
973
|
`telegram gateway: reply: suppressed by answer-delivered latch ` +
|
|
@@ -934,11 +976,22 @@ export async function sendReply(
|
|
|
934
976
|
)
|
|
935
977
|
return { content: [{ type: 'text', text: 'sent (deduped — answer already delivered via turn-flush)' }] }
|
|
936
978
|
}
|
|
937
|
-
// A substantive answer is going out via this reply —
|
|
938
|
-
// owner turn
|
|
939
|
-
//
|
|
979
|
+
// A substantive answer is going out via this reply — record it on the
|
|
980
|
+
// owner turn, tagged 'reply' (#3426). The 'reply' tag does NOT trip the
|
|
981
|
+
// late-reply suppression above: a later reply attributed to this turn
|
|
982
|
+
// after it ends (the async sub-agent handback pattern — dispatch, interim
|
|
983
|
+
// ack, turn_end, handback with no live gateway turn) is genuinely new
|
|
984
|
+
// content and must deliver. Byte-identical replays of THIS answer are
|
|
985
|
+
// deduped by the content-keyed #546 cache at the top of this function.
|
|
986
|
+
// Honest bound: the dedup TTL (60 s) is anchored at reply RECORD time,
|
|
987
|
+
// while the latest-ended owner tier's 60 s is anchored at `endedAt` —
|
|
988
|
+
// later by the reply→turn_end gap. A byte-identical replay landing >60 s
|
|
989
|
+
// after record but ≤60 s after endedAt is evicted from dedup yet still
|
|
990
|
+
// resolves this ended turn, so it now DELIVERS as a duplicate message.
|
|
991
|
+
// Conscious trade: a rare duplicate beats the silent handback drop the
|
|
992
|
+
// boolean latch caused (#3426).
|
|
940
993
|
if (replySubstantive && ownerTurn != null) {
|
|
941
|
-
ownerTurn.answerDelivered =
|
|
994
|
+
ownerTurn.answerDelivered = 'reply'
|
|
942
995
|
}
|
|
943
996
|
}
|
|
944
997
|
}
|
|
@@ -20,6 +20,28 @@
|
|
|
20
20
|
* whichever was observed last. A fresh assistant line always reclaims the
|
|
21
21
|
* transcript as the source; a confirmed switch always beats an older
|
|
22
22
|
* transcript line. Pinned by tests/session-model-source.test.ts.
|
|
23
|
+
*
|
|
24
|
+
* Divergence tripwire (#3427 item 4): `--fallback-model` masks an invalid
|
|
25
|
+
* requested model id — claude silently serves the fallback while the override
|
|
26
|
+
* carries the requested token. The FIRST live transcript observation of the
|
|
27
|
+
* post-relaunch session is therefore the earliest deterministic verification
|
|
28
|
+
* point: when the injected comparator says the served id does NOT satisfy the
|
|
29
|
+
* requested token, the registered divergence handler fires (once per armed
|
|
30
|
+
* override) so the gateway can log + warn instead of self-healing silently.
|
|
31
|
+
*
|
|
32
|
+
* Two false-positive guards (#3437 review H1/H2 — "a false accusation must be
|
|
33
|
+
* impossible" is the contract, enforced HERE, not by caller discipline):
|
|
34
|
+
* - H1: verification arms ONLY on an explicit `setOverride(model,
|
|
35
|
+
* { verify: true })` — the boot-rehydration site, where the override IS
|
|
36
|
+
* the launched token of the session now serving. A command-time
|
|
37
|
+
* `setOverride(model)` (the pre-restart status-honesty record in
|
|
38
|
+
* scheduleModelRelaunch) must NOT arm: an assistant line landing in the
|
|
39
|
+
* pre-restart window is served by the OLD model and would false-mismatch
|
|
40
|
+
* the NEW requested token.
|
|
41
|
+
* - H2: observations flagged `replayed: true` (the session-tail's
|
|
42
|
+
* first-attach replay of a prior session's in-flight turn — OLD-model
|
|
43
|
+
* lines delivered AFTER boot) neither consume nor fire verification;
|
|
44
|
+
* the tripwire waits for the first LIVE observation.
|
|
23
45
|
*/
|
|
24
46
|
|
|
25
47
|
export interface SessionModelResolution {
|
|
@@ -30,14 +52,45 @@ export interface SessionModelResolution {
|
|
|
30
52
|
source: 'transcript' | 'override'
|
|
31
53
|
}
|
|
32
54
|
|
|
55
|
+
/** The first live post-override assistant line served a different model (#3427). */
|
|
56
|
+
export interface SessionModelDivergence {
|
|
57
|
+
/** The override token the operator requested (`/model <token>`). */
|
|
58
|
+
requested: string
|
|
59
|
+
/** The transcript's `message.model` — the model actually serving calls. */
|
|
60
|
+
served: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SessionModelSourceOptions {
|
|
64
|
+
/**
|
|
65
|
+
* Comparator for the divergence tripwire: does `served` (a resolved
|
|
66
|
+
* transcript id) satisfy `requested` (the override token)? Must be
|
|
67
|
+
* CONSERVATIVE — return true when the pair is not deterministically
|
|
68
|
+
* comparable (see servedModelMatchesRequested in model-command.ts).
|
|
69
|
+
* Absent → the tripwire never fires (verification is skipped).
|
|
70
|
+
*/
|
|
71
|
+
servedMatchesRequested?: (requested: string, served: string) => boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
33
74
|
export interface SessionModelSource {
|
|
34
|
-
/**
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Record a transcript observation (an assistant line's `message.model`,
|
|
77
|
+
* already sentinel-filtered by the session-tail projection). Pass
|
|
78
|
+
* `replayed: true` for lines delivered by the session-tail's first-attach
|
|
79
|
+
* replay (a PRIOR session's in-flight turn): they still update /status
|
|
80
|
+
* freshness exactly as before, but are excluded from divergence
|
|
81
|
+
* verification (H2 — they carry the pre-relaunch model).
|
|
82
|
+
*/
|
|
83
|
+
noteTranscriptModel(model: string, opts?: { replayed?: boolean }): void
|
|
84
|
+
/**
|
|
85
|
+
* Record an override set (a positively-confirmed /model switch), or clear
|
|
86
|
+
* it with null. Setting stamps a fresh sequence, so the override wins over
|
|
87
|
+
* every EARLIER transcript observation until a new assistant line lands.
|
|
88
|
+
* `verify: true` additionally ARMS divergence verification for this
|
|
89
|
+
* override — pass it ONLY when the override is the launched token of the
|
|
90
|
+
* session currently serving (the boot-rehydration site). Default: not
|
|
91
|
+
* armed (H1 — command-time/rollback sets must never arm).
|
|
92
|
+
*/
|
|
93
|
+
setOverride(model: string | null, opts?: { verify?: boolean }): void
|
|
41
94
|
/** Current override value (the #2982 in-memory record), independent of
|
|
42
95
|
* freshness — for callers that need the override itself (e.g. the model
|
|
43
96
|
* menu's "session" marker), not the /status resolution. */
|
|
@@ -45,18 +98,42 @@ export interface SessionModelSource {
|
|
|
45
98
|
/** The freshest observation across both sources, or null when neither has
|
|
46
99
|
* reported yet. */
|
|
47
100
|
resolve(): SessionModelResolution | null
|
|
101
|
+
/** Register the handler fired when the first LIVE transcript observation
|
|
102
|
+
* after an ARMED override fails the comparator (#3427 item 4). At most
|
|
103
|
+
* once per armed override; null unregisters. Replaces any prior handler. */
|
|
104
|
+
setDivergenceHandler(handler: ((d: SessionModelDivergence) => void) | null): void
|
|
48
105
|
}
|
|
49
106
|
|
|
50
|
-
export function createSessionModelSource(
|
|
107
|
+
export function createSessionModelSource(
|
|
108
|
+
options: SessionModelSourceOptions = {},
|
|
109
|
+
): SessionModelSource {
|
|
51
110
|
let seq = 0
|
|
52
111
|
let transcript: { model: string; seq: number } | null = null
|
|
53
112
|
let override: { model: string; seq: number } | null = null
|
|
113
|
+
// True while an ARMED ({ verify: true }) non-null override awaits its first
|
|
114
|
+
// LIVE transcript observation. Consumed (set false) on that observation
|
|
115
|
+
// whether or not it diverges, so the handler fires at most once per armed
|
|
116
|
+
// override. Replayed observations neither consume nor fire (H2).
|
|
117
|
+
let overrideUnverified = false
|
|
118
|
+
let onDivergence: ((d: SessionModelDivergence) => void) | null = null
|
|
54
119
|
return {
|
|
55
|
-
noteTranscriptModel(model: string): void {
|
|
120
|
+
noteTranscriptModel(model: string, opts?: { replayed?: boolean }): void {
|
|
56
121
|
transcript = { model, seq: ++seq }
|
|
122
|
+
if (opts?.replayed === true) return // H2: pre-relaunch line — no verification
|
|
123
|
+
if (override != null && overrideUnverified) {
|
|
124
|
+
overrideUnverified = false
|
|
125
|
+
const matches = options.servedMatchesRequested
|
|
126
|
+
if (matches != null && !matches(override.model, model)) {
|
|
127
|
+
onDivergence?.({ requested: override.model, served: model })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
57
130
|
},
|
|
58
|
-
setOverride(model: string | null): void {
|
|
131
|
+
setOverride(model: string | null, opts?: { verify?: boolean }): void {
|
|
59
132
|
override = model == null ? null : { model, seq: ++seq }
|
|
133
|
+
// H1: only an explicit verify-arm (the boot-rehydration site) starts
|
|
134
|
+
// verification; a plain set (command-time record, rollback restore)
|
|
135
|
+
// clears any pending arm — its token is NOT what is serving right now.
|
|
136
|
+
overrideUnverified = model != null && opts?.verify === true
|
|
60
137
|
},
|
|
61
138
|
getOverride(): string | null {
|
|
62
139
|
return override?.model ?? null
|
|
@@ -69,5 +146,8 @@ export function createSessionModelSource(): SessionModelSource {
|
|
|
69
146
|
}
|
|
70
147
|
return { model: transcript.model, source: 'transcript' }
|
|
71
148
|
},
|
|
149
|
+
setDivergenceHandler(handler: ((d: SessionModelDivergence) => void) | null): void {
|
|
150
|
+
onDivergence = handler
|
|
151
|
+
},
|
|
72
152
|
}
|
|
73
153
|
}
|