switchroom 0.19.40 → 0.19.42
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 +22 -2
- package/dist/auth-broker/index.js +22 -2
- package/dist/cli/notion-write-pretool.mjs +22 -2
- package/dist/cli/switchroom.js +810 -241
- package/dist/host-control/main.js +94 -19
- package/dist/vault/approvals/kernel-server.js +22 -2
- package/dist/vault/broker/server.js +22 -2
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +2 -2
- package/telegram-plugin/dist/gateway/gateway.js +332 -124
- package/telegram-plugin/dist/server.js +2 -2
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
- package/telegram-plugin/gateway/gateway.ts +76 -76
- package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- package/telegram-plugin/gateway/stream-render.ts +57 -0
- package/telegram-plugin/gateway/turn-record-status.ts +80 -0
- package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/telegram-plugin/tests/turn-record-status.test.ts +62 -0
- package/vendor/hindsight-memory/CHANGELOG.md +28 -0
- package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
- package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +18 -3
- package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
- package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
- package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
type AskUserOutcome,
|
|
36
36
|
} from '../ask-user.js'
|
|
37
37
|
import { redactAskUserFields, redactChecklistFields } from '../outbound-field-redact.js'
|
|
38
|
+
import { createChecklistStore, checklistStoreKey, performSendChecklist, performUpdateChecklist, sendChecklistToolText, updateChecklistToolText } from './checklist-fallback.js'
|
|
38
39
|
import { parseInterruptMarker } from '../interrupt-marker.js'
|
|
39
40
|
import {
|
|
40
41
|
ToolFlightTracker,
|
|
@@ -456,7 +457,7 @@ import {
|
|
|
456
457
|
type SendReplyGatewayDeps,
|
|
457
458
|
type DeliverCapturedProseDeps,
|
|
458
459
|
} from './outbound-send-path.js'
|
|
459
|
-
import { handleSessionEvent as handleSessionEventCore } from './stream-render.js'
|
|
460
|
+
import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat } from './stream-render.js'
|
|
460
461
|
import { createNarrativeLane } from './narrative-lane.js'
|
|
461
462
|
import {
|
|
462
463
|
parseAgentCallback,
|
|
@@ -1383,65 +1384,29 @@ let _rawEditMessageChecklist: unknown
|
|
|
1383
1384
|
/** True when the connected Telegram Bot API supports native checklists. Set in initGatewayBot() (#2996 P0b). */
|
|
1384
1385
|
let CHECKLIST_API_AVAILABLE = false
|
|
1385
1386
|
|
|
1386
|
-
/**
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
chat_id: string
|
|
1393
|
-
title: string
|
|
1394
|
-
tasks: Array<{ text: string; done?: boolean }>
|
|
1395
|
-
message_thread_id?: number
|
|
1396
|
-
reply_to_message_id?: number
|
|
1397
|
-
protect_content?: boolean
|
|
1398
|
-
}): Promise<{ message_id: number }> {
|
|
1399
|
-
if (!CHECKLIST_API_AVAILABLE) {
|
|
1400
|
-
throw new Error('sendChecklist is not available in this grammY/Telegram Bot API version')
|
|
1401
|
-
}
|
|
1402
|
-
const MAX_TASKS = 30
|
|
1403
|
-
if (args.tasks.length > MAX_TASKS) {
|
|
1404
|
-
throw new Error(`checklist exceeds ${MAX_TASKS}-task limit (got ${args.tasks.length})`)
|
|
1405
|
-
}
|
|
1406
|
-
const result = await (_rawSendChecklist as (p: Record<string, unknown>) => Promise<{ message_id: number }>)({
|
|
1407
|
-
chat_id: Number(args.chat_id),
|
|
1408
|
-
title: args.title,
|
|
1409
|
-
tasks: args.tasks.map(t => ({ text: t.text, ...(t.done != null ? { is_completed: t.done } : {}) })),
|
|
1410
|
-
...(args.message_thread_id != null ? { message_thread_id: args.message_thread_id } : {}),
|
|
1411
|
-
...(args.reply_to_message_id != null ? { reply_to_message_id: args.reply_to_message_id } : {}),
|
|
1412
|
-
...(args.protect_content === true ? { protect_content: true } : {}),
|
|
1413
|
-
})
|
|
1387
|
+
/** bot.api.raw.sendChecklist with a payload pre-built by checklist-fallback.ts
|
|
1388
|
+
* (nested `checklist` object, per-task integer ids, business_connection_id —
|
|
1389
|
+
* the old flat shape got `400: parameter "checklist" is required`). */
|
|
1390
|
+
async function rawSendChecklist(payload: Record<string, unknown>): Promise<{ message_id: number }> {
|
|
1391
|
+
if (!CHECKLIST_API_AVAILABLE) throw new Error('sendChecklist is not available in this grammY/Telegram Bot API version')
|
|
1392
|
+
const result = await (_rawSendChecklist as (p: Record<string, unknown>) => Promise<{ message_id: number }>)(payload)
|
|
1414
1393
|
return { message_id: result.message_id }
|
|
1415
1394
|
}
|
|
1416
1395
|
|
|
1417
|
-
/**
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
await (_rawEditMessageChecklist as (p: Record<string, unknown>) => Promise<unknown>)({
|
|
1432
|
-
chat_id: Number(args.chat_id),
|
|
1433
|
-
message_id: Number(args.message_id),
|
|
1434
|
-
...(args.title != null ? { title: args.title } : {}),
|
|
1435
|
-
...(args.tasks != null
|
|
1436
|
-
? {
|
|
1437
|
-
tasks: args.tasks.map(t => ({
|
|
1438
|
-
...(t.id != null ? { id: Number(t.id) } : {}),
|
|
1439
|
-
...(t.text != null ? { text: t.text } : {}),
|
|
1440
|
-
...(t.done != null ? { is_completed: t.done } : {}),
|
|
1441
|
-
})),
|
|
1442
|
-
}
|
|
1443
|
-
: {}),
|
|
1444
|
-
})
|
|
1396
|
+
/** bot.api.raw.editMessageChecklist with a pre-built payload (the native edit REPLACES the whole checklist, so checklist-fallback.ts sends full state). */
|
|
1397
|
+
async function rawEditMessageChecklist(payload: Record<string, unknown>): Promise<void> {
|
|
1398
|
+
if (!CHECKLIST_API_AVAILABLE) throw new Error('editMessageChecklist is not available in this grammY/Telegram Bot API version')
|
|
1399
|
+
await (_rawEditMessageChecklist as (p: Record<string, unknown>) => Promise<unknown>)(payload)
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/** Per-message checklist state — update_checklist re-renders from it (process-local; post-restart updates degrade gracefully). */
|
|
1403
|
+
const checklistStore = createChecklistStore()
|
|
1404
|
+
|
|
1405
|
+
/** Native checklists are Telegram-Business-only (sendChecklist REQUIRES a business_connection_id).
|
|
1406
|
+
* No fleet config plumbing exists yet; this env var is the opt-in — unset (the normal case) means text fallback. */
|
|
1407
|
+
function resolveBusinessConnectionId(): string | undefined {
|
|
1408
|
+
const v = process.env.SWITCHROOM_TELEGRAM_BUSINESS_CONNECTION_ID?.trim()
|
|
1409
|
+
return v ? v : undefined
|
|
1445
1410
|
}
|
|
1446
1411
|
|
|
1447
1412
|
const chatLock = createChatLock()
|
|
@@ -5038,7 +5003,7 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
|
|
|
5038
5003
|
startedAt: turn.startedAt,
|
|
5039
5004
|
toolCallCount: turn.toolCallCount ?? 0,
|
|
5040
5005
|
turnId: turn.turnId,
|
|
5041
|
-
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
5006
|
+
finalAnswerDelivered: turn.finalAnswerDelivered, replyCalled: turn.replyCalled, // replyCalled: honest delivery-route signal (turn-record-status.ts computeTurnRoute)
|
|
5042
5007
|
deliveryOutcome: turn.deliveryOutcome, landedUnconfirmed: turn.landedUnconfirmed,
|
|
5043
5008
|
},
|
|
5044
5009
|
endedAt,
|
|
@@ -9704,14 +9669,14 @@ function gatewayLivenessWiringDeps() {
|
|
|
9704
9669
|
turnLiveForItsTopic,
|
|
9705
9670
|
endCurrentTurnForKey,
|
|
9706
9671
|
// Getter, not an eager read: `pendingInboundBuffer` (const) is declared
|
|
9707
|
-
// LATER
|
|
9708
|
-
//
|
|
9709
|
-
//
|
|
9710
|
-
//
|
|
9711
|
-
// getTurnsDb / ipcServer — means the binding is only read when the
|
|
9712
|
-
// fallback actually fires, well after module eval.
|
|
9672
|
+
// LATER than the module-load `startTimer` call that invokes this builder,
|
|
9673
|
+
// so a by-value capture is a temporal-dead-zone ReferenceError that
|
|
9674
|
+
// crash-loops boot (#3392). A getter (matching getInboundSpool / getTurnsDb
|
|
9675
|
+
// / ipcServer) is read only when the fallback fires, well after module eval.
|
|
9713
9676
|
getPendingInboundBuffer: () => pendingInboundBuffer,
|
|
9714
9677
|
trackRedeliveredInbound,
|
|
9678
|
+
// Two-state desync unwedge (#3927): drain the parked store the fallback else leaves latched — see liveness-wiring + stream-render drainParkedTurnStartsForChat.
|
|
9679
|
+
drainParkedTurnStarts: (chatId: string, threadId: number | null): number => drainParkedTurnStartsForChat(gatewayStreamRenderDeps(), chatId, threadId != null ? String(threadId) : null).length,
|
|
9715
9680
|
closeActivityLane,
|
|
9716
9681
|
closeProgressLane,
|
|
9717
9682
|
// Stage B: escalate a mid-tool + marker-stale fallback to a real restart.
|
|
@@ -12083,17 +12048,35 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
|
|
|
12083
12048
|
(t) => redactOutboundText(t, 'send_checklist'),
|
|
12084
12049
|
)
|
|
12085
12050
|
|
|
12086
|
-
|
|
12087
|
-
|
|
12088
|
-
|
|
12089
|
-
|
|
12051
|
+
// Graceful degradation: native sendChecklist is Business-account-only, so
|
|
12052
|
+
// ordinary chats (the fleet norm) get a formatted text render instead of a
|
|
12053
|
+
// raw Telegram 400 (rationale + orchestration in checklist-fallback.ts).
|
|
12054
|
+
const literal = (loadAccess().parseMode ?? 'html') === 'text'
|
|
12055
|
+
const sendOpts = {
|
|
12090
12056
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
12091
|
-
...(replyTo != null ? {
|
|
12057
|
+
...(replyTo != null ? { reply_parameters: { message_id: replyTo } } : {}),
|
|
12092
12058
|
...(protectContent ? { protect_content: true } : {}),
|
|
12093
|
-
}
|
|
12059
|
+
}
|
|
12060
|
+
const result = await performSendChecklist({
|
|
12061
|
+
businessConnectionId: resolveBusinessConnectionId(),
|
|
12062
|
+
nativeAvailable: CHECKLIST_API_AVAILABLE,
|
|
12063
|
+
sendNative: rawSendChecklist,
|
|
12064
|
+
sendText: (text) => robustApiCall(
|
|
12065
|
+
(): Promise<{ message_id: number }> =>
|
|
12066
|
+
literal
|
|
12067
|
+
// allow-raw-bot-api: literal checklist text-fallback send routed through robustApiCall
|
|
12068
|
+
? lockedBot.api.sendMessage(chat_id, text, sendOpts as never)
|
|
12069
|
+
// allow-raw-bot-api: checklist text-fallback send routed through robustApiCall
|
|
12070
|
+
: lockedBot.api.sendRichMessage(chat_id, richMessage(text), sendOpts as never),
|
|
12071
|
+
{ verb: 'sendMessage', chat_id, threadId },
|
|
12072
|
+
),
|
|
12073
|
+
literalText: literal,
|
|
12074
|
+
log: (l) => process.stderr.write(`telegram gateway: ${l}`),
|
|
12075
|
+
}, { title: redactedTitle!, tasks: redactedTasks!, chatId: Number(chat_id), replyToMessageId: replyTo, protectContent })
|
|
12094
12076
|
|
|
12095
|
-
|
|
12096
|
-
|
|
12077
|
+
checklistStore.set(checklistStoreKey(chat_id, result.message_id), result.state)
|
|
12078
|
+
process.stderr.write(`telegram gateway: send_checklist: sent chatId=${chat_id} messageId=${result.message_id} tasks=${tasks.length} mode=${result.mode}\n`)
|
|
12079
|
+
return { content: [{ type: 'text', text: sendChecklistToolText(result) }] }
|
|
12097
12080
|
}
|
|
12098
12081
|
|
|
12099
12082
|
/**
|
|
@@ -12180,10 +12163,27 @@ async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{
|
|
|
12180
12163
|
(t) => redactOutboundText(t, 'update_checklist'),
|
|
12181
12164
|
)
|
|
12182
12165
|
|
|
12183
|
-
|
|
12184
|
-
|
|
12185
|
-
|
|
12186
|
-
|
|
12166
|
+
// Graceful degradation (checklist-fallback.ts): the patch is applied to the
|
|
12167
|
+
// stored per-message state and the message re-rendered in the mode it was
|
|
12168
|
+
// sent in. Failures come back structured, never as a raw Telegram 400.
|
|
12169
|
+
const literal = (loadAccess().parseMode ?? 'html') === 'text'
|
|
12170
|
+
const key = checklistStoreKey(chat_id, message_id)
|
|
12171
|
+
const result = await performUpdateChecklist({
|
|
12172
|
+
state: checklistStore.get(key),
|
|
12173
|
+
businessConnectionId: resolveBusinessConnectionId(),
|
|
12174
|
+
nativeAvailable: CHECKLIST_API_AVAILABLE,
|
|
12175
|
+
editNative: rawEditMessageChecklist,
|
|
12176
|
+
// allow-raw-bot-api: checklist text-fallback edit routed through robustApiCall
|
|
12177
|
+
editText: async (text) => { await robustApiCall(() => lockedBot.api.editMessageText(chat_id, Number(message_id), literal ? text : richMessage(text))) },
|
|
12178
|
+
literalText: literal,
|
|
12179
|
+
log: (l) => process.stderr.write(`telegram gateway: ${l}`),
|
|
12180
|
+
chatId: Number(chat_id),
|
|
12181
|
+
messageId: Number(message_id),
|
|
12182
|
+
}, { title: redactedTitle, tasks: redactedTasks })
|
|
12183
|
+
|
|
12184
|
+
if (result.ok) checklistStore.set(key, result.state)
|
|
12185
|
+
process.stderr.write(`telegram gateway: update_checklist: ${result.ok ? `updated mode=${result.mode}` : `failed reason=${result.reason}`} chatId=${chat_id} messageId=${message_id}\n`)
|
|
12186
|
+
return { content: [{ type: 'text', text: updateChecklistToolText(result, Number(message_id)) }] }
|
|
12187
12187
|
}
|
|
12188
12188
|
|
|
12189
12189
|
/**
|
|
@@ -62,6 +62,7 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
62
62
|
endCurrentTurnForKey,
|
|
63
63
|
getPendingInboundBuffer,
|
|
64
64
|
trackRedeliveredInbound,
|
|
65
|
+
drainParkedTurnStarts,
|
|
65
66
|
closeActivityLane,
|
|
66
67
|
closeProgressLane,
|
|
67
68
|
hangRestart,
|
|
@@ -593,6 +594,16 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
593
594
|
getInboundSpool(),
|
|
594
595
|
trackRedeliveredInbound,
|
|
595
596
|
)
|
|
597
|
+
// Two-state desync unwedge. `pendingInboundBuffer` (above) is NOT where a
|
|
598
|
+
// wedge-behind-a-dead-lock message sits: an already-`enqueue`d message lives
|
|
599
|
+
// in stream-render's `parkedTurnStarts`, and the busy park gate stays latched
|
|
600
|
+
// while that store is non-empty. A hung REPL never emits the `dequeue`/
|
|
601
|
+
// `remove` that drain it, so drain it HERE, scoped to the wedged chat/thread,
|
|
602
|
+
// routing each envelope back through the real `dequeue` turn-start path. The
|
|
603
|
+
// count joins the `drained_buffered` field below so the log stops reading a
|
|
604
|
+
// misleading `0/0` while real user messages were stranded. Guarded getter:
|
|
605
|
+
// absent (older deps) → 0, so the fallback degrades to its prior behaviour.
|
|
606
|
+
const fbDrainedParked = drainParkedTurnStarts?.(fbChatId, fbThreadId ?? null) ?? 0
|
|
596
607
|
process.stderr.write(
|
|
597
608
|
`telegram gateway: silence-poke framework-fallback ended wedged turn ` +
|
|
598
609
|
`chat=${fbChatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs} ` +
|
|
@@ -601,6 +612,7 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
601
612
|
// keyed liveness check skipped the teardown, so the field lied.
|
|
602
613
|
`currentTurn_nulled=${tearsDownLiveTurn} ` +
|
|
603
614
|
`drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}` +
|
|
615
|
+
`${fbDrainedParked > 0 ? ` drained_parked=${fbDrainedParked}` : ''}` +
|
|
604
616
|
`${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}` +
|
|
605
617
|
`${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ''}\n`,
|
|
606
618
|
)
|
|
@@ -34,6 +34,27 @@ import {
|
|
|
34
34
|
type ModelPickerOption,
|
|
35
35
|
} from '../../src/agents/model-picker.js'
|
|
36
36
|
import { assessThinkingEffortRisk } from '../../src/config/thinking-effort-risk.js'
|
|
37
|
+
// The model-alias tables + expansion helpers live in the shared
|
|
38
|
+
// `src/agents/model-aliases.ts` module so the gateway `/model` path and
|
|
39
|
+
// scaffold-time config-default resolution expand from ONE table (#3998).
|
|
40
|
+
// Imported for this module's internal use and re-exported below to preserve the
|
|
41
|
+
// existing `model-command.js` public API (every importer keeps its entrypoint).
|
|
42
|
+
import {
|
|
43
|
+
CLAUDE_MODEL_ALIASES,
|
|
44
|
+
SR_MODEL_ALIASES,
|
|
45
|
+
expandClaudeAlias,
|
|
46
|
+
expandSrAlias,
|
|
47
|
+
canonicalModelToken,
|
|
48
|
+
expandModelAlias,
|
|
49
|
+
} from '../../src/agents/model-aliases.js'
|
|
50
|
+
export {
|
|
51
|
+
CLAUDE_MODEL_ALIASES,
|
|
52
|
+
SR_MODEL_ALIASES,
|
|
53
|
+
expandClaudeAlias,
|
|
54
|
+
expandSrAlias,
|
|
55
|
+
canonicalModelToken,
|
|
56
|
+
expandModelAlias,
|
|
57
|
+
}
|
|
37
58
|
|
|
38
59
|
/**
|
|
39
60
|
* Aliases the claude CLI resolves natively (`claude --help`: "an alias for
|
|
@@ -51,34 +72,6 @@ import { assessThinkingEffortRisk } from '../../src/config/thinking-effort-risk.
|
|
|
51
72
|
*/
|
|
52
73
|
export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'fable', 'default'] as const
|
|
53
74
|
|
|
54
|
-
/**
|
|
55
|
-
* Short SWITCHROOM-side spellings for pinned **Anthropic** model ids. These are
|
|
56
|
-
* expanded to the full `claude-*` id on the `/model` path BEFORE any
|
|
57
|
-
* Claude-vs-external classification runs, so what reaches the `.session-model`
|
|
58
|
-
* carrier (and therefore `claude --model`) is always the canonical id — the CLI
|
|
59
|
-
* never sees the short spelling.
|
|
60
|
-
*
|
|
61
|
-
* Deliberately NOT `SR_MODEL_ALIASES`: that map means "external / OpenRouter /
|
|
62
|
-
* the Anthropic OAuth header is NOT forwarded". These targets are Anthropic
|
|
63
|
-
* OAuth-passthrough models, so putting them there would flip the
|
|
64
|
-
* header-forwarding + external-billing classification and surface them under the
|
|
65
|
-
* "🌐 External models" keyboard page with an `srFriendlyLabel`.
|
|
66
|
-
*
|
|
67
|
-
* Also NOT `MODEL_ALIASES`: that list is the aliases the claude CLI resolves
|
|
68
|
-
* *itself*, and its members double as FAMILY tokens in `modelFamilyToken` /
|
|
69
|
-
* `canonicalClaudeToken` / `servedModelMatchesRequested`. A pinned-id shortcut
|
|
70
|
-
* is neither.
|
|
71
|
-
*
|
|
72
|
-
* NB the repo prefers family aliases over pinned ids (an alias tracks the
|
|
73
|
-
* current flagship, a pinned id goes stale) — these exist because an operator
|
|
74
|
-
* sometimes wants a specific pinned Opus, and typing `claude-opus-4-8` on a
|
|
75
|
-
* phone is hostile. Keys are matched case-insensitively.
|
|
76
|
-
*/
|
|
77
|
-
export const CLAUDE_MODEL_ALIASES: Record<string, string> = {
|
|
78
|
-
opus48: 'claude-opus-4-8',
|
|
79
|
-
'opus-4-8': 'claude-opus-4-8',
|
|
80
|
-
}
|
|
81
|
-
|
|
82
75
|
/**
|
|
83
76
|
* Shape gate for the model argument. This string is typed literally
|
|
84
77
|
* into the agent's tmux pane, so the gate is strict by construction:
|
|
@@ -1166,35 +1159,6 @@ export const SR_MODEL_LABELS: Record<string, string> = {
|
|
|
1166
1159
|
'sr-gpt-5.6-luna': 'GPT-5.6 Luna',
|
|
1167
1160
|
}
|
|
1168
1161
|
|
|
1169
|
-
/**
|
|
1170
|
-
* Short text-command aliases for sr-* models. These let the operator type
|
|
1171
|
-
* `/model flash`, `/model codex`, etc. instead of the full `sr-*` id.
|
|
1172
|
-
* Expanded in handleModelCommand before injection; the full sr-* id is what
|
|
1173
|
-
* reaches the agent session and LiteLLM.
|
|
1174
|
-
*/
|
|
1175
|
-
export const SR_MODEL_ALIASES: Record<string, string> = {
|
|
1176
|
-
flash: 'sr-gemini-2.5-flash',
|
|
1177
|
-
gemini: 'sr-gemini-2.5-pro',
|
|
1178
|
-
deepseek: 'sr-deepseek-v3',
|
|
1179
|
-
r1: 'sr-deepseek-r1',
|
|
1180
|
-
glm: 'sr-glm-5',
|
|
1181
|
-
codex: 'sr-codex-5.5',
|
|
1182
|
-
// Flagship keyboard buttons for the three providers added 2026-07-19. Each
|
|
1183
|
-
// points at that provider's top tier; the cheaper tiers (grok-4.3,
|
|
1184
|
-
// kimi-k2.7-code, gpt-5.6-terra/luna) stay typeable via `/model sr-<id>` and
|
|
1185
|
-
// keep a friendly label above, matching the "labels are a superset of buttons"
|
|
1186
|
-
// design (SR_MODEL_LABELS doc comment).
|
|
1187
|
-
grok: 'sr-grok-4.5',
|
|
1188
|
-
kimi: 'sr-kimi-k3',
|
|
1189
|
-
gpt: 'sr-gpt-5.6-sol',
|
|
1190
|
-
// Per-tier buttons for the GPT-5.6 line so each is a one-word shortcut
|
|
1191
|
-
// (`gpt` stays the flagship-Sol default). Added 2026-07-19.
|
|
1192
|
-
sol: 'sr-gpt-5.6-sol',
|
|
1193
|
-
terra: 'sr-gpt-5.6-terra',
|
|
1194
|
-
luna: 'sr-gpt-5.6-luna',
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
/** Expand a short alias (case-insensitive) to its full sr-* id, or return the original. */
|
|
1198
1162
|
/**
|
|
1199
1163
|
* #3042 review blocker 2a: can `token` be trusted for a boot-applied
|
|
1200
1164
|
* `.session-model` carrier persist WITHOUT a live confirmation from claude?
|
|
@@ -1230,58 +1194,6 @@ export function isOfflineTrustedModelToken(token: string): boolean {
|
|
|
1230
1194
|
return Object.values(CLAUDE_MODEL_ALIASES).includes(lower)
|
|
1231
1195
|
}
|
|
1232
1196
|
|
|
1233
|
-
export function expandSrAlias(arg: string): string {
|
|
1234
|
-
return SR_MODEL_ALIASES[arg.toLowerCase()] ?? arg
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
/**
|
|
1238
|
-
* The ONE canonical form of a model token — what `claude --model` is handed and
|
|
1239
|
-
* what every downstream gate compares against.
|
|
1240
|
-
*
|
|
1241
|
-
* Two normalizations, both narrow:
|
|
1242
|
-
* - **trim** — unconditional. `unvalidatedIdCaveat` already trimmed while
|
|
1243
|
-
* expansion did not, so the two disagreed on a padded token.
|
|
1244
|
-
* - **lowercase, `claude-*` ONLY.** Every real Anthropic model id is lowercase
|
|
1245
|
-
* and a phone autocapitalizes, so `/model Claude-Opus-4-8` used to be launched
|
|
1246
|
-
* VERBATIM as an unvalidated id while the caveat exemption (which lowercases)
|
|
1247
|
-
* suppressed the warning — a clean green ack for a token the caveat was
|
|
1248
|
-
* written for. Non-`claude-` tokens are left alone: `sr-*` ids are matched
|
|
1249
|
-
* case-insensitively by their own maps, and rewriting an arbitrary external
|
|
1250
|
-
* id's case is not ours to do.
|
|
1251
|
-
*
|
|
1252
|
-
* Applied by `expandModelAlias`, so canonicalization happens on every `/model`
|
|
1253
|
-
* path (typed apply, mid-turn queue, menu tap, queued-across-restart persist)
|
|
1254
|
-
* whether or not the token hit a shortcut map — one canonical form flows to
|
|
1255
|
-
* `claude --model`, and `unvalidatedIdCaveat` sees exactly that form.
|
|
1256
|
-
*/
|
|
1257
|
-
export function canonicalModelToken(arg: string): string {
|
|
1258
|
-
const trimmed = arg.trim()
|
|
1259
|
-
const lower = trimmed.toLowerCase()
|
|
1260
|
-
return lower.startsWith('claude-') ? lower : trimmed
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
/** Expand a short pinned-Claude spelling (case-insensitive) to its full `claude-*` id. */
|
|
1264
|
-
export function expandClaudeAlias(arg: string): string {
|
|
1265
|
-
return CLAUDE_MODEL_ALIASES[arg.trim().toLowerCase()] ?? arg
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
/**
|
|
1269
|
-
* The ONE expansion every `/model` argument goes through, on every path that
|
|
1270
|
-
* consumes a user-supplied token (typed apply, mid-turn queue, menu tap,
|
|
1271
|
-
* queued-across-restart persist). Claude shortcuts resolve first, then sr-*
|
|
1272
|
-
* shortcuts; the two key sets are disjoint by construction (a Claude shortcut
|
|
1273
|
-
* never expands to an `sr-*` id and vice versa), so order only documents intent.
|
|
1274
|
-
* The result is `canonicalModelToken`-normalized, so a miss can no longer emit a
|
|
1275
|
-
* verbatim mixed-case `claude-*` id that the caveat exemption then mis-matches.
|
|
1276
|
-
*
|
|
1277
|
-
* Expanding HERE — before `isClaudeModel` / `isSrModel` / `externalModelNames`
|
|
1278
|
-
* ever see the token — is what keeps a pinned-Claude shortcut on the Anthropic
|
|
1279
|
-
* OAuth passthrough instead of being misread as an external route.
|
|
1280
|
-
*/
|
|
1281
|
-
export function expandModelAlias(arg: string): string {
|
|
1282
|
-
return canonicalModelToken(expandSrAlias(expandClaudeAlias(arg)))
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
1197
|
export function srFriendlyLabel(srName: string): string {
|
|
1286
1198
|
return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
|
|
1287
1199
|
}
|
|
@@ -232,6 +232,63 @@ export function __parkedTurnStartCountForTest(): number {
|
|
|
232
232
|
return parkedTurnStarts.length
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Silence-fallback unwedge for the parked store (the two-state desync fix).
|
|
237
|
+
*
|
|
238
|
+
* The park gate in `case 'enqueue'` treats the session as busy while EITHER a
|
|
239
|
+
* live `currentTurn` exists OR `parkedTurnStarts` is non-empty. The 300 s
|
|
240
|
+
* framework-fallback (`liveness-wiring.ts` onFrameworkFallback) that recovers a
|
|
241
|
+
* hung turn nulls `currentTurn` and redelivers `pendingInboundBuffer` — but
|
|
242
|
+
* before this it NEVER touched `parkedTurnStarts`. When the claude REPL hangs
|
|
243
|
+
* mid-turn the CLI's own `dequeue` / `remove` events (the only real drains of
|
|
244
|
+
* this store) never arrive, so a leftover parked envelope latches the busy gate
|
|
245
|
+
* permanently and every later inbound parks unseen until a manual container
|
|
246
|
+
* restart (gymbro parked msgs 4502→4504 behind a dead lock until SIGTERM). The
|
|
247
|
+
* `drained_buffered=0/0` fallback log was honest-but-misleading: it counted the
|
|
248
|
+
* empty `pendingInboundBuffer`, not this store, where the real messages sat.
|
|
249
|
+
*
|
|
250
|
+
* This drains the parked envelopes for ONE chat/thread — scoped, never global,
|
|
251
|
+
* so a fallback fired for chat A cannot release chat B's queued messages — and
|
|
252
|
+
* hands each back into turn processing through the EXACT `beginTurn` path a real
|
|
253
|
+
* `dequeue` uses. Envelopes are re-begun in arrival order (oldest first) so the
|
|
254
|
+
* NEWEST, the message the user is actually waiting on, ends up the live turn and
|
|
255
|
+
* the older ones are cleanly superseded (never silently dropped). The store is
|
|
256
|
+
* kept module-encapsulated: callers get a function, not the array. Returns the
|
|
257
|
+
* drained envelopes so the caller can log an honest count. Idempotent w.r.t. a
|
|
258
|
+
* late CLI event: once drained, a subsequent `dequeue` finds an empty store and
|
|
259
|
+
* `takeParkedTurnStart` returns null, so a message can never be double-started.
|
|
260
|
+
*/
|
|
261
|
+
export function drainParkedTurnStartsForChat(
|
|
262
|
+
deps: StreamRenderDeps,
|
|
263
|
+
chatId: string | null,
|
|
264
|
+
threadId: string | null,
|
|
265
|
+
): TurnStartEnvelope[] {
|
|
266
|
+
const wantThread = envThreadIdNum(threadId)
|
|
267
|
+
const drained: ParkedTurnStart[] = []
|
|
268
|
+
// Splice out the matching entries, preserving arrival order (oldest first).
|
|
269
|
+
for (let i = 0; i < parkedTurnStarts.length; ) {
|
|
270
|
+
const entry = parkedTurnStarts[i]!
|
|
271
|
+
if (entry.chatId === chatId && envThreadIdNum(entry.threadId) === wantThread) {
|
|
272
|
+
drained.push(entry)
|
|
273
|
+
parkedTurnStarts.splice(i, 1)
|
|
274
|
+
} else {
|
|
275
|
+
i++
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const env of drained) {
|
|
279
|
+
process.stderr.write(
|
|
280
|
+
`telegram gateway: parked-turn-start drained on silence-fallback ` +
|
|
281
|
+
`chat=${env.chatId ?? '-'} thread=${envThreadIdNum(env.threadId) ?? '-'} ` +
|
|
282
|
+
`msg=${env.messageId ?? '-'}\n`,
|
|
283
|
+
)
|
|
284
|
+
// Reuse the dequeue turn-start path verbatim — beginTurn adopts the parked
|
|
285
|
+
// envelope's queued card (Part B) so the frozen "⏳ Queued" surface becomes
|
|
286
|
+
// the live progress card rather than lingering.
|
|
287
|
+
beginTurn(deps, env)
|
|
288
|
+
}
|
|
289
|
+
return drained
|
|
290
|
+
}
|
|
291
|
+
|
|
235
292
|
// ─── Queued-turn card send / finalize (Part B) ───────────────────────────────
|
|
236
293
|
// These use the ALREADY-injected `bot` + `robustApiCall` deps, so the whole
|
|
237
294
|
// feature lives in stream-render.ts with zero new wiring in gateway.ts. No
|
|
@@ -27,6 +27,38 @@ export type DeliveryOutcome = 'delivered' | 'failed' | 'suppressed'
|
|
|
27
27
|
/** The status strings written to turns.jsonl. `send_failed` is new in PR B. */
|
|
28
28
|
export type TurnStatus = 'complete' | 'no_reply' | 'send_failed'
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How this turn's answer actually reached (or failed to reach) the user — the
|
|
32
|
+
* honest delivery route, recorded alongside `status` so the fleet-health
|
|
33
|
+
* detector can tell a flush-recovered turn apart from a genuine silent no-op.
|
|
34
|
+
*
|
|
35
|
+
* - `reply` — the reply tool delivered the answer (the normal path), OR the
|
|
36
|
+
* flush short-circuited because reply had already delivered.
|
|
37
|
+
* - `stream` — the answer landed via the streaming/draft path without a reply
|
|
38
|
+
* tool call (final answer delivered, `replyCalled` false).
|
|
39
|
+
* - `flush` — a turn-flush / outbox-sweep backstop delivered the answer after
|
|
40
|
+
* the reply tool was bypassed (terminal prose, `tools:0`). This is
|
|
41
|
+
* the case that used to masquerade as a silent no-op.
|
|
42
|
+
* - `none` — nothing reached the user (send failed, or a genuine no-reply).
|
|
43
|
+
*/
|
|
44
|
+
export type TurnRoute = 'reply' | 'stream' | 'flush' | 'none'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Epoch of when the honest `route` field shipped on turns.jsonl rows — a FIXED
|
|
48
|
+
* literal cutoff the fleet-health detector uses to age out the pre-route legacy
|
|
49
|
+
* backlog (rows written before this field existed carry no `route`, so the
|
|
50
|
+
* detector cannot classify them and must not escalate them as silent no-ops).
|
|
51
|
+
*
|
|
52
|
+
* UNIX SECONDS, not milliseconds: turns.jsonl `ts` is written as
|
|
53
|
+
* `Math.floor(endedAt / 1000)` (see `buildTurnRecord`), and the detector
|
|
54
|
+
* compares this constant against that seconds-valued `ts`. It intentionally
|
|
55
|
+
* mirrors the units of `SILENT_NOOP_FLOOR_TS` in `src/fleet-health/detect.ts`.
|
|
56
|
+
*
|
|
57
|
+
* Value = 2026-07-31T00:00:00Z (`date -u -d @1785456000`). Fixed, not rolling:
|
|
58
|
+
* a rolling window would hide a genuine ongoing regression that drops the field.
|
|
59
|
+
*/
|
|
60
|
+
export const ROUTE_FIELD_SHIP_TS = 1_785_456_000
|
|
61
|
+
|
|
30
62
|
/**
|
|
31
63
|
* Derive the recorded turn status from the turn's flags.
|
|
32
64
|
*
|
|
@@ -58,6 +90,41 @@ export function computeTurnStatus(turn: {
|
|
|
58
90
|
}
|
|
59
91
|
}
|
|
60
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Derive the honest delivery `route` from the SAME resolved delivery state
|
|
95
|
+
* `computeTurnStatus` reads — never speculative. `deliveryOutcome` (when
|
|
96
|
+
* present) is authoritative; when absent we fall back to the legacy
|
|
97
|
+
* `finalAnswerDelivered` / `replyCalled` reading, exactly as `computeTurnStatus`
|
|
98
|
+
* does for `status`.
|
|
99
|
+
*
|
|
100
|
+
* failed → none (nothing reached the user)
|
|
101
|
+
* delivered → flush (a backstop send delivered the answer)
|
|
102
|
+
* suppressed → reply (flush short-circuited; reply already delivered)
|
|
103
|
+
* undefined (legacy) → finalAnswerDelivered
|
|
104
|
+
* ? (replyCalled ? reply : stream)
|
|
105
|
+
* : none
|
|
106
|
+
*/
|
|
107
|
+
export function computeTurnRoute(turn: {
|
|
108
|
+
finalAnswerDelivered: boolean
|
|
109
|
+
replyCalled: boolean
|
|
110
|
+
deliveryOutcome?: DeliveryOutcome
|
|
111
|
+
}): TurnRoute {
|
|
112
|
+
switch (turn.deliveryOutcome) {
|
|
113
|
+
case 'failed':
|
|
114
|
+
return 'none'
|
|
115
|
+
case 'delivered':
|
|
116
|
+
return 'flush'
|
|
117
|
+
case 'suppressed':
|
|
118
|
+
return 'reply'
|
|
119
|
+
default:
|
|
120
|
+
return turn.finalAnswerDelivered
|
|
121
|
+
? turn.replyCalled
|
|
122
|
+
? 'reply'
|
|
123
|
+
: 'stream'
|
|
124
|
+
: 'none'
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
61
128
|
/**
|
|
62
129
|
* Resolve a backstop send's outcome from what actually happened on the wire.
|
|
63
130
|
* A throw is a failure; a no-throw send that delivered fewer chunks than it
|
|
@@ -148,6 +215,13 @@ export interface TurnRecordRow {
|
|
|
148
215
|
tools: number
|
|
149
216
|
status: TurnStatus
|
|
150
217
|
turn_id: string
|
|
218
|
+
/**
|
|
219
|
+
* The honest delivery route for this turn (see `TurnRoute`). Recorded on every
|
|
220
|
+
* row so the fleet-health detector can distinguish a flush-recovered turn
|
|
221
|
+
* (`route: 'flush'`, answer delivered by a backstop after the reply tool was
|
|
222
|
+
* bypassed) from a genuine silent no-op (`route: 'none'`). Always emitted.
|
|
223
|
+
*/
|
|
224
|
+
route: TurnRoute
|
|
151
225
|
/**
|
|
152
226
|
* How many landed message ids of this turn's backstop delivery the read-back
|
|
153
227
|
* probe never corroborated (`sentIds` minus the confirmed subset). OMITTED
|
|
@@ -178,6 +252,7 @@ export function buildTurnRecord(
|
|
|
178
252
|
toolCallCount: number
|
|
179
253
|
turnId: string
|
|
180
254
|
finalAnswerDelivered: boolean
|
|
255
|
+
replyCalled?: boolean
|
|
181
256
|
deliveryOutcome?: DeliveryOutcome
|
|
182
257
|
landedUnconfirmed?: number
|
|
183
258
|
},
|
|
@@ -190,6 +265,11 @@ export function buildTurnRecord(
|
|
|
190
265
|
tools: turn.toolCallCount ?? 0,
|
|
191
266
|
status: computeTurnStatus(turn),
|
|
192
267
|
turn_id: turn.turnId,
|
|
268
|
+
route: computeTurnRoute({
|
|
269
|
+
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
270
|
+
replyCalled: turn.replyCalled ?? false,
|
|
271
|
+
deliveryOutcome: turn.deliveryOutcome,
|
|
272
|
+
}),
|
|
193
273
|
// Emitted ONLY when non-zero (see `TurnRecordRow.landed_unconfirmed`).
|
|
194
274
|
...(turn.landedUnconfirmed != null && turn.landedUnconfirmed > 0
|
|
195
275
|
? { landed_unconfirmed: turn.landedUnconfirmed }
|