switchroom 0.19.41 → 0.19.43
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 +23 -3
- package/dist/auth-broker/index.js +54 -9
- package/dist/cli/notion-write-pretool.mjs +23 -3
- package/dist/cli/switchroom.js +1956 -900
- package/dist/host-control/main.js +227 -30
- package/dist/vault/approvals/kernel-server.js +54 -9
- package/dist/vault/broker/server.js +54 -9
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +82 -4
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1318 -693
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +56 -58
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +62 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- 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
|
@@ -152,12 +152,14 @@ export interface IpcServerOptions {
|
|
|
152
152
|
) => Promise<void>;
|
|
153
153
|
/**
|
|
154
154
|
* #2726 Part 2 — hostd asks the gateway to EDIT a previously-posted rollout
|
|
155
|
-
* status message as later phases arrive.
|
|
155
|
+
* status message as later phases arrive. Optional. #4065: the handler replies
|
|
156
|
+
* with a `rollout_status_edited` outcome so hostd can tell an applied edit
|
|
157
|
+
* from an edit into a deleted card; the roll never blocks on that reply.
|
|
156
158
|
*/
|
|
157
159
|
onRolloutStatusEdit?: (
|
|
158
160
|
client: IpcClient,
|
|
159
161
|
msg: RolloutStatusEditMessage,
|
|
160
|
-
) => void
|
|
162
|
+
) => void | Promise<void>;
|
|
161
163
|
log?: (msg: string) => void;
|
|
162
164
|
/**
|
|
163
165
|
* How long (in ms) to wait without a heartbeat before force-closing the
|
|
@@ -813,14 +815,21 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
813
815
|
case "rollout_status_edit":
|
|
814
816
|
if (onRolloutStatusEdit) {
|
|
815
817
|
try {
|
|
816
|
-
|
|
818
|
+
void Promise.resolve(
|
|
819
|
+
onRolloutStatusEdit(client, msg as RolloutStatusEditMessage),
|
|
820
|
+
).catch((err) => {
|
|
821
|
+
log(
|
|
822
|
+
`rollout_status_edit handler rejected (client=${client.id}): ${(err as Error).message}`,
|
|
823
|
+
);
|
|
824
|
+
});
|
|
817
825
|
} catch (err) {
|
|
818
826
|
log(
|
|
819
827
|
`rollout_status_edit handler threw (client=${client.id}): ${(err as Error).message}`,
|
|
820
828
|
);
|
|
821
829
|
}
|
|
822
830
|
}
|
|
823
|
-
//
|
|
831
|
+
// The handler replies `rollout_status_edited` (#4065) when it can; an
|
|
832
|
+
// unwired gateway sends nothing and hostd's bounded wait expires.
|
|
824
833
|
break;
|
|
825
834
|
case "update_placeholder":
|
|
826
835
|
// Legacy recall.py IPC — placeholder UX was removed in #553 PR 5.
|
|
@@ -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
|
}
|
|
@@ -43,6 +43,11 @@ export interface ObligationStoreFsSeam {
|
|
|
43
43
|
* entry lives in the parent's own cached metadata and is otherwise lost
|
|
44
44
|
* in a power cut. */
|
|
45
45
|
fsyncDirSync: (path: string) => void
|
|
46
|
+
/** #4146: remove a snapshot this process will not maintain. Optional so any
|
|
47
|
+
* existing seam (tests, other callers) keeps compiling and keeps working —
|
|
48
|
+
* an absent `unlinkSync` degrades to "no cleanup", which is what those
|
|
49
|
+
* callers do today. */
|
|
50
|
+
unlinkSync?: (path: string) => void
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
interface SnapshotEnvelope {
|
|
@@ -126,6 +131,56 @@ export function loadObligations(path: string, fs: ObligationStoreFsSeam): Obliga
|
|
|
126
131
|
return env.obligations.filter(isObligationRow).map(sanitizeCapturedDelivery)
|
|
127
132
|
}
|
|
128
133
|
|
|
134
|
+
/**
|
|
135
|
+
* #4146 — drop a snapshot this process is NOT going to maintain.
|
|
136
|
+
*
|
|
137
|
+
* When the gateway is STATIC or `SWITCHROOM_OBLIGATION_LEDGER=0`, the ledger's
|
|
138
|
+
* `onChange` persister is never wired, so obligations open and close purely in
|
|
139
|
+
* memory while any previously-written `obligations.json` sits on disk
|
|
140
|
+
* unchanged. That leftover is not harmless: the W1-d audience classifier
|
|
141
|
+
* (`hooks/audience-classify.mjs`) reads this exact file to decide whether
|
|
142
|
+
* anyone is waiting on a chat, and an empty-but-stale open set reads there as
|
|
143
|
+
* POSITIVE evidence that nobody is — which is the one input that can make the
|
|
144
|
+
* classifier suppress a message owed to a human.
|
|
145
|
+
*
|
|
146
|
+
* Removing the file restores that classifier's stated invariant ("absent ⇒
|
|
147
|
+
* unknown ⇒ deliver") deterministically, with no freshness heuristic and no
|
|
148
|
+
* clock. Note this is safe precisely because the snapshot has exactly one
|
|
149
|
+
* writer: with the ledger ON, a merely-stopped gateway leaves a snapshot that
|
|
150
|
+
* is accurate rather than stale, so nothing is lost by only cleaning up in the
|
|
151
|
+
* modes where persistence is disabled.
|
|
152
|
+
*
|
|
153
|
+
* Best-effort and never throws — a failure degrades to the reader-side
|
|
154
|
+
* ledger-off guard in the hook.
|
|
155
|
+
*
|
|
156
|
+
* @returns `true` iff a file was actually removed.
|
|
157
|
+
*/
|
|
158
|
+
export function removeUnmaintainedSnapshot(
|
|
159
|
+
path: string,
|
|
160
|
+
fs: ObligationStoreFsSeam,
|
|
161
|
+
reason = 'persistence disabled',
|
|
162
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
163
|
+
): boolean {
|
|
164
|
+
const unlink = fs.unlinkSync
|
|
165
|
+
if (unlink == null) return false
|
|
166
|
+
try {
|
|
167
|
+
if (!fs.existsSync(path)) return false
|
|
168
|
+
unlink(path)
|
|
169
|
+
log(
|
|
170
|
+
`obligation-store: persistence is off (${reason}) — removed unmaintained ` +
|
|
171
|
+
`snapshot path=${path} so it cannot be read as "nobody is waiting" (#4146)\n`,
|
|
172
|
+
)
|
|
173
|
+
return true
|
|
174
|
+
} catch (err) {
|
|
175
|
+
log(
|
|
176
|
+
`obligation-store: could not remove unmaintained snapshot path=${path}: ` +
|
|
177
|
+
`${(err as Error).message} — audience classification falls back to the ` +
|
|
178
|
+
`reader-side ledger-off guard (#4146)\n`,
|
|
179
|
+
)
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
129
184
|
/**
|
|
130
185
|
* Persist the open set atomically (write sibling tmp → rename over the real
|
|
131
186
|
* path). Best-effort relative to fs availability: a write failure is logged but
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
stripExcessBold,
|
|
30
30
|
addParagraphSpacers,
|
|
31
31
|
splitMarkdownChunks,
|
|
32
|
+
splitPlainTextToCap,
|
|
32
33
|
hardSliceToCap,
|
|
33
34
|
RICH_MESSAGE_MAX_CHARS,
|
|
34
35
|
} from '../format.js'
|
|
@@ -36,6 +37,12 @@ import { scrubVoice } from '../text-voice-scrub.js'
|
|
|
36
37
|
import { normalizeTemporal } from '../temporal-normalize.js'
|
|
37
38
|
import { resolveEnvTimezone } from '../shared/local-time.js'
|
|
38
39
|
import { isMessageTooLongError, isHtmlParseRejectError } from '../retry-api-call.js'
|
|
40
|
+
import {
|
|
41
|
+
buildReplyParameters,
|
|
42
|
+
dropQuoteFromSendOpts,
|
|
43
|
+
isQuoteRejectionError,
|
|
44
|
+
sendOptsHaveQuote,
|
|
45
|
+
} from '../reply-quote.js'
|
|
39
46
|
|
|
40
47
|
// ── send-orchestration façade imports (#2996 P2) ──
|
|
41
48
|
// Pure/deterministic helpers are imported; stateful or side-effecting gateway
|
|
@@ -52,6 +59,14 @@ import {
|
|
|
52
59
|
} from '../format.js'
|
|
53
60
|
import { richMessage } from '../rich-send.js'
|
|
54
61
|
import { journalExternalDelivery } from './outbox-sweep.js'
|
|
62
|
+
import { sha256Hex } from '../outbox.js'
|
|
63
|
+
// #4141: the ONE shared framing implementation, also used by the outbox sweep
|
|
64
|
+
// and (for the classifier half) the unbundled Stop hooks.
|
|
65
|
+
import {
|
|
66
|
+
applyReplyThrowFraming,
|
|
67
|
+
formatReplyThrowFraming,
|
|
68
|
+
shouldFrameReplyThrow,
|
|
69
|
+
} from '../hooks/audience-classify.mjs'
|
|
55
70
|
import { queueFloodBlockedReply } from './flood-reply-queue.js'
|
|
56
71
|
import { resolveChatIdFallback } from './chat-id-fallback.js'
|
|
57
72
|
import { isFinalAnswerReply, isSubstantiveFinalReply, shouldJournalReplySiteDelivery } from '../final-answer-detect.js'
|
|
@@ -416,16 +431,58 @@ export async function sendReplyChunks(
|
|
|
416
431
|
// rejects our markdown — better an unformatted answer than a
|
|
417
432
|
// vanished one. The raw markdown source is itself readable prose, so
|
|
418
433
|
// we send it verbatim rather than strip anything.
|
|
434
|
+
//
|
|
435
|
+
// #4043: chunks were split for the RICH cap (up to RICH_MESSAGE_MAX_CHARS =
|
|
436
|
+
// 32768), but this fallback ships them through the PLAIN `sendMessage`
|
|
437
|
+
// endpoint, which caps at 4096. Without a re-cap, the rescue send itself
|
|
438
|
+
// 400s with `message is too long` and the answer vanishes — the exact
|
|
439
|
+
// failure the fallback exists to prevent. Re-cap at safe boundaries first.
|
|
440
|
+
//
|
|
441
|
+
// Per-piece trailers: `opts` was built for ONE message, so shipping it
|
|
442
|
+
// verbatim to every piece duplicates the MESSAGE-scoped params.
|
|
443
|
+
// - `reply_markup` (an inline keyboard) would render a LIVE button on
|
|
444
|
+
// every piece. `single_use` only strips the keyboard from the message
|
|
445
|
+
// that was tapped, so the user can fire the same action N times. It
|
|
446
|
+
// rides the FINAL piece only — the same treatment the sweep's own
|
|
447
|
+
// fallback applies (`outbox-sweep.ts` `sendChunkRich`/`withoutMarkup`),
|
|
448
|
+
// and the same message the caller attached it to (the last chunk).
|
|
449
|
+
// - `reply_parameters` would quote-reply from every piece. Only the
|
|
450
|
+
// FIRST piece quotes, matching the caller's `replyMode !== 'all'`
|
|
451
|
+
// rule that quotes chunk 0 only.
|
|
452
|
+
// Thread / link-preview / protect_content / disable_notification are
|
|
453
|
+
// per-message policy and correctly ride every piece, unchanged.
|
|
419
454
|
const sendChunkPlainText = async (opts: Record<string, unknown>): Promise<void> => {
|
|
420
455
|
const plain =
|
|
421
456
|
chunks[i].length > 0
|
|
422
457
|
? chunks[i]
|
|
423
458
|
: '⚠️ (a fragment could not be rendered for Telegram)'
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
459
|
+
const pieces = splitPlainTextToCap(plain)
|
|
460
|
+
const pieceOpts = (p: number): Record<string, unknown> => {
|
|
461
|
+
// Single piece ⇒ it is both first and final; pass `opts` through
|
|
462
|
+
// byte-identically so the non-split path stays exactly as it was.
|
|
463
|
+
if (pieces.length === 1) return opts
|
|
464
|
+
const o = { ...opts }
|
|
465
|
+
if (p !== pieces.length - 1) delete o.reply_markup
|
|
466
|
+
if (p !== 0) delete o.reply_parameters
|
|
467
|
+
return o
|
|
468
|
+
}
|
|
469
|
+
for (let p = 0; p < pieces.length; p++) {
|
|
470
|
+
const sent = await deps.sendLiteralRaw(pieceOpts(p), pieces[p])
|
|
471
|
+
sentIds.push(sent.message_id)
|
|
472
|
+
deps.logOutbound(
|
|
473
|
+
'reply',
|
|
474
|
+
chatId,
|
|
475
|
+
sent.message_id,
|
|
476
|
+
pieces[p].length,
|
|
477
|
+
pieces.length > 1
|
|
478
|
+
? `chunk=${i + 1}/${chunks.length} plaintext-fallback piece=${p + 1}/${pieces.length}`
|
|
479
|
+
: `chunk=${i + 1}/${chunks.length} plaintext-fallback`,
|
|
480
|
+
)
|
|
481
|
+
}
|
|
427
482
|
deps.stderr(
|
|
428
|
-
`telegram gateway: markdown parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text
|
|
483
|
+
`telegram gateway: markdown parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text` +
|
|
484
|
+
(pieces.length > 1 ? ` in ${pieces.length} piece(s) (4096-char plain cap)` : '') +
|
|
485
|
+
`\n`,
|
|
429
486
|
)
|
|
430
487
|
}
|
|
431
488
|
|
|
@@ -499,6 +556,20 @@ export async function sendReplyChunks(
|
|
|
499
556
|
else if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
|
|
500
557
|
else throw retryErr
|
|
501
558
|
}
|
|
559
|
+
} else if (isQuoteRejectionError(err) && sendOptsHaveQuote(sendOpts)) {
|
|
560
|
+
// Surgical-quote rejection: the quote must be an EXACT substring of the
|
|
561
|
+
// message being replied to, and Telegram 400s when it isn't found (a
|
|
562
|
+
// model paraphrase, a re-rendered fragment, an emoji/entity mismatch).
|
|
563
|
+
// That is a failure of the HIGHLIGHT, not of the answer — so drop the
|
|
564
|
+
// quote and land the reply as an ordinary reply to the same message,
|
|
565
|
+
// rather than throwing the composed answer away.
|
|
566
|
+
const retryOpts = dropQuoteFromSendOpts(sendOpts)
|
|
567
|
+
const sent = await sendChunk(retryOpts, false)
|
|
568
|
+
sentIds.push(sent.message_id)
|
|
569
|
+
deps.logOutbound('reply', chatId, sent.message_id, chunks[i].length, `chunk=${i + 1}/${chunks.length} quote-dropped`)
|
|
570
|
+
deps.stderr(
|
|
571
|
+
`telegram gateway: quote not found in the replied-to message — resent chunk ${i + 1}/${chunks.length} without the quote\n`,
|
|
572
|
+
)
|
|
502
573
|
} else if (isMessageTooLongError(err)) {
|
|
503
574
|
await sendChunkResplit(sendOpts)
|
|
504
575
|
} else if (isHtmlParseRejectError(err)) {
|
|
@@ -1949,10 +2020,10 @@ export async function sendReply(
|
|
|
1949
2020
|
return {
|
|
1950
2021
|
...(shouldReplyTo
|
|
1951
2022
|
? {
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
2023
|
+
// `quote` is a Bot API String and `quote_position` a separate
|
|
2024
|
+
// sibling Integer — see reply-quote.ts. Emitting an object here
|
|
2025
|
+
// 400'd every quoted reply the fleet ever sent.
|
|
2026
|
+
reply_parameters: buildReplyParameters(reply_to!, quoteText),
|
|
1956
2027
|
}
|
|
1957
2028
|
: {}),
|
|
1958
2029
|
...(tid != null ? { message_thread_id: tid } : {}),
|
|
@@ -2514,6 +2585,15 @@ export async function deliverCapturedProse(
|
|
|
2514
2585
|
text: string
|
|
2515
2586
|
/** Turn elapsed for the honest "(waited Ns)" apology clause; optional. */
|
|
2516
2587
|
turnDurationMs?: number
|
|
2588
|
+
/**
|
|
2589
|
+
* #4141 — this turn's reply tool threw, so `text` is prose the model wrote
|
|
2590
|
+
* AFTER its delivery attempt failed, not text it chose to send as the answer.
|
|
2591
|
+
* When `true` the send is prefixed with a one-line provenance banner. This is
|
|
2592
|
+
* the ELECTED-path counterpart of the outbox sweep's framing: on this path
|
|
2593
|
+
* the Stop hook defers to the single-writer election and writes NO outbox
|
|
2594
|
+
* record, so the sweep's framing can never reach the message.
|
|
2595
|
+
*/
|
|
2596
|
+
replyToolThrewThisTurn?: boolean
|
|
2517
2597
|
},
|
|
2518
2598
|
): Promise<void> {
|
|
2519
2599
|
const {
|
|
@@ -2523,6 +2603,18 @@ export async function deliverCapturedProse(
|
|
|
2523
2603
|
} = deps
|
|
2524
2604
|
const { chatId, threadId, statusKeyStr, registryKey, originTurnId, text, turnDurationMs } = args
|
|
2525
2605
|
const now = Date.now()
|
|
2606
|
+
// #4141 — framing is a rendering concern, decided ONCE here and applied only
|
|
2607
|
+
// to the bytes handed to Telegram. Everything keyed on identity (the dedup
|
|
2608
|
+
// check + record below, and the journal's `textSha256`) deliberately keeps
|
|
2609
|
+
// using the RAW `text`, so the label cannot change what counts as "the same
|
|
2610
|
+
// answer" — a later reply-tool retry of the same prose still dedups, and the
|
|
2611
|
+
// exactly-once-among-backstops journal key is unchanged. Same
|
|
2612
|
+
// additive-by-construction property the sweep path has, re-established here
|
|
2613
|
+
// because this path has its own send ladder and its own dedup.
|
|
2614
|
+
const framed = shouldFrameReplyThrow(
|
|
2615
|
+
{ replyToolThrewThisTurn: args.replyToolThrewThisTurn },
|
|
2616
|
+
{ frameEnabled: process.env.SWITCHROOM_TG_OUTBOX_PROVENANCE_FRAMING !== '0' },
|
|
2617
|
+
)
|
|
2526
2618
|
// #3228 Finding 1 — the three settlement points (sent / skipped-dedup /
|
|
2527
2619
|
// failed) all funnel through the pure `settleCapturedProseDelivery` core so
|
|
2528
2620
|
// the failure posture is deterministic and unit-tested. `outcome` is set on
|
|
@@ -2530,7 +2622,7 @@ export async function deliverCapturedProse(
|
|
|
2530
2622
|
let outcome: CapturedProseSendOutcome
|
|
2531
2623
|
const already = outboundDedup.check(chatId, threadId, text, now, registryKey)
|
|
2532
2624
|
if (already == null) {
|
|
2533
|
-
let out = normalizeParagraphBreaks(repairEscapedWhitespace(text))
|
|
2625
|
+
let out = normalizeParagraphBreaks(repairEscapedWhitespace(framed ? applyReplyThrowFraming(text) : text))
|
|
2534
2626
|
out = redactOutboundText(out, 'captured_prose')
|
|
2535
2627
|
const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
|
|
2536
2628
|
const sentIds: number[] = []
|
|
@@ -2577,11 +2669,31 @@ export async function deliverCapturedProse(
|
|
|
2577
2669
|
// journal line from this site following a reply-tool line under the same
|
|
2578
2670
|
// nonce (replyAlreadyDeliveredThisTurn:true) is direct, journal-only
|
|
2579
2671
|
// proof of a bridge double-send.
|
|
2580
|
-
journalExternalDelivery({
|
|
2672
|
+
journalExternalDelivery({
|
|
2673
|
+
turnNonce: originTurnId,
|
|
2674
|
+
text,
|
|
2675
|
+
tgMessageId: sentIds[sentIds.length - 1],
|
|
2676
|
+
replyAlreadyDeliveredThisTurn: false,
|
|
2677
|
+
// #4141 durable terminal stamp — parity with the sweep's
|
|
2678
|
+
// `DeliveredEntry.framedProvenance`, so "was this labelled?" is
|
|
2679
|
+
// answerable from the journal alone, hours later.
|
|
2680
|
+
...(framed ? { framedProvenance: 'reply-throw' as const } : {}),
|
|
2681
|
+
})
|
|
2581
2682
|
process.stderr.write(
|
|
2582
2683
|
`telegram gateway: captured-prose delivery — sent ${out.length} chars recovered from ` +
|
|
2583
2684
|
`transcript scan (chat=${chatId} origin=${originTurnId})\n`,
|
|
2584
2685
|
)
|
|
2686
|
+
if (framed) {
|
|
2687
|
+
process.stderr.write(
|
|
2688
|
+
formatReplyThrowFraming({
|
|
2689
|
+
turnNonce: originTurnId,
|
|
2690
|
+
turnId: originTurnId,
|
|
2691
|
+
chatId,
|
|
2692
|
+
textSha256: sha256Hex(text),
|
|
2693
|
+
source: 'captured-prose-bridge',
|
|
2694
|
+
}),
|
|
2695
|
+
)
|
|
2696
|
+
}
|
|
2585
2697
|
outcome = 'sent'
|
|
2586
2698
|
} catch (err) {
|
|
2587
2699
|
// #3228 Finding 1 — the send threw, so the answer did NOT reach the user.
|
|
@@ -2660,7 +2772,11 @@ export async function deliverCapturedProse(
|
|
|
2660
2772
|
`(chat=${chatId} origin=${originTurnId})\n`,
|
|
2661
2773
|
)
|
|
2662
2774
|
const plain = redactOutboundText(text, 'captured_prose')
|
|
2663
|
-
|
|
2775
|
+
// #4043: these pieces go through the PLAIN `sendMessage` endpoint (4096
|
|
2776
|
+
// cap), not the rich one — splitting at RICH_MESSAGE_MAX_CHARS produced
|
|
2777
|
+
// pieces Telegram rejects with `message is too long`, so a long recovered
|
|
2778
|
+
// answer fell through to the generic apology and was lost.
|
|
2779
|
+
const plainChunks = splitPlainTextToCap(plain)
|
|
2664
2780
|
try {
|
|
2665
2781
|
let liveThreadId: number | undefined = threadId
|
|
2666
2782
|
const plainSentIds: number[] = []
|