switchroom 0.18.28 → 0.18.30

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.
Files changed (48) hide show
  1. package/bin/handoff-briefing.sh +15 -2
  2. package/dist/agent-scheduler/index.js +111 -7
  3. package/dist/auth-broker/index.js +154 -73
  4. package/dist/cli/autoaccept-poll.js +8 -3
  5. package/dist/cli/drive-write-pretool.mjs +8 -3
  6. package/dist/cli/ms-365-write-pretool.mjs +158 -11
  7. package/dist/cli/notion-write-pretool.mjs +103 -4
  8. package/dist/cli/switchroom.js +2712 -2219
  9. package/dist/host-control/main.js +110 -70
  10. package/dist/vault/approvals/kernel-server.js +116 -70
  11. package/dist/vault/broker/server.js +314 -202
  12. package/package.json +3 -3
  13. package/profiles/_base/start.sh.hbs +105 -34
  14. package/telegram-plugin/dist/bridge/bridge.js +71 -47
  15. package/telegram-plugin/dist/gateway/gateway.js +1128 -666
  16. package/telegram-plugin/dist/server.js +89 -64
  17. package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
  18. package/telegram-plugin/gateway/forward-origin.ts +9 -1
  19. package/telegram-plugin/gateway/gateway.ts +656 -388
  20. package/telegram-plugin/gateway/model-command.ts +331 -602
  21. package/telegram-plugin/gateway/session-model-file.ts +40 -0
  22. package/telegram-plugin/gateway/turn-record-status.ts +45 -0
  23. package/telegram-plugin/gateway/unhandled-message.ts +177 -0
  24. package/telegram-plugin/history.ts +153 -23
  25. package/telegram-plugin/llm-error-present.ts +24 -0
  26. package/telegram-plugin/model-unavailable.ts +55 -0
  27. package/telegram-plugin/operator-events.ts +113 -0
  28. package/telegram-plugin/pending-user-notice.ts +88 -0
  29. package/telegram-plugin/shared/local-time.ts +99 -0
  30. package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
  31. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
  32. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +30 -3
  34. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
  35. package/telegram-plugin/tests/history.test.ts +88 -0
  36. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
  37. package/telegram-plugin/tests/local-time.test.ts +135 -0
  38. package/telegram-plugin/tests/model-command.test.ts +427 -1512
  39. package/telegram-plugin/tests/session-model-file.test.ts +23 -0
  40. package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
  41. package/telegram-plugin/tier-downgrade.ts +4 -3
  42. package/telegram-plugin/turn-flush-safety.ts +25 -1
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
  44. package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
  45. package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
  46. package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
  47. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
  48. package/vendor/hindsight-memory/tests/test_content.py +63 -7
@@ -17019,6 +17019,81 @@ function truncateDetailPreservingRequestId(detail, max) {
17019
17019
  return `${detail.slice(0, headBudget)}${suffix}`;
17020
17020
  }
17021
17021
 
17022
+ // quota-check.ts
17023
+ var init_quota_check = () => {};
17024
+
17025
+ // text-voice-scrub.ts
17026
+ var NULL = "\x00", FENCE_PH, INLINE_PH, HTML_CODE_PH, HTML_PRE_PH, URL_PH;
17027
+ var init_text_voice_scrub = __esm(() => {
17028
+ FENCE_PH = `${NULL}VS_FENCE`;
17029
+ INLINE_PH = `${NULL}VS_INLINE`;
17030
+ HTML_CODE_PH = `${NULL}VS_HTMLCODE`;
17031
+ HTML_PRE_PH = `${NULL}VS_HTMLPRE`;
17032
+ URL_PH = `${NULL}VS_URL`;
17033
+ });
17034
+
17035
+ // card-format.ts
17036
+ var init_card_format = __esm(() => {
17037
+ init_format();
17038
+ init_text_voice_scrub();
17039
+ });
17040
+
17041
+ // model-unavailable.ts
17042
+ function isTransientUpstreamSignal(text) {
17043
+ if (typeof text !== "string" || text.length === 0)
17044
+ return false;
17045
+ const sample = text.length > 16384 ? text.slice(0, 16384) : text;
17046
+ const lower = sample.toLowerCase();
17047
+ return transientUpstreamSignals.some((s) => lower.includes(s));
17048
+ }
17049
+ function isLitellmProxyLocal429(text) {
17050
+ if (typeof text !== "string" || text.length === 0)
17051
+ return false;
17052
+ const sample = text.length > 16384 ? text.slice(0, 16384) : text;
17053
+ const lower = sample.toLowerCase();
17054
+ if (litellmProxyLocal429Signals.some((s) => lower.includes(s)))
17055
+ return true;
17056
+ return litellmV3LimiterSignalPair.every((s) => lower.includes(s));
17057
+ }
17058
+ function isLitellmProxyAuthMisconfig(text) {
17059
+ if (typeof text !== "string" || text.length === 0)
17060
+ return false;
17061
+ const sample = text.length > 16384 ? text.slice(0, 16384) : text;
17062
+ const lower = sample.toLowerCase();
17063
+ if (lower.includes("x-api-key header is required"))
17064
+ return true;
17065
+ const isAuthErr = lower.includes("authentication_error") || lower.includes("authenticationerror");
17066
+ if (!isAuthErr)
17067
+ return false;
17068
+ return lower.includes("fallback") && lower.includes("x-api-key");
17069
+ }
17070
+ var transientUpstreamSignals, litellmProxyLocal429Signals, litellmV3LimiterSignalPair;
17071
+ var init_model_unavailable = __esm(() => {
17072
+ init_quota_check();
17073
+ init_card_format();
17074
+ transientUpstreamSignals = [
17075
+ "not your usage limit",
17076
+ "not your account",
17077
+ "not your account's",
17078
+ "temporarily limiting requests",
17079
+ "temporarily rate",
17080
+ "server is temporarily",
17081
+ "would exceed your account\u2019s rate limit",
17082
+ "would exceed your account's rate limit"
17083
+ ];
17084
+ litellmProxyLocal429Signals = [
17085
+ "deployment over user-defined ratelimit",
17086
+ "model rate limit exceeded. tpm limit",
17087
+ "model rate limit exceeded. rpm limit",
17088
+ "deployment over defined rpm limit",
17089
+ "no deployments available for selected model",
17090
+ "litellm rate limit handler",
17091
+ "crossed tpm / rpm",
17092
+ "max parallel request limit reached"
17093
+ ];
17094
+ litellmV3LimiterSignalPair = ["rate limit exceeded for ", "limit type:"];
17095
+ });
17096
+
17022
17097
  // operator-events.ts
17023
17098
  function classifyClaudeError(raw) {
17024
17099
  try {
@@ -17036,6 +17111,12 @@ function classifyInner(raw) {
17036
17111
  const message = extractString(obj, "message") ?? extractString(getNestedObj(obj, "error"), "message") ?? (typeof raw === "string" ? raw : "") ?? "";
17037
17112
  const status = extractNumber(obj, "status") ?? extractNumber(obj, "statusCode") ?? extractNumber(obj, "status_code") ?? null;
17038
17113
  const sdkCode = extractString(obj, "error_code") ?? "";
17114
+ if (isLitellmProxyAuthMisconfig(`${errorType}
17115
+ ${errorCode}
17116
+ ${sdkCode}
17117
+ ${message}`)) {
17118
+ return "proxy-misconfig";
17119
+ }
17039
17120
  if (errorType === "authentication_error" || errorCode === "authentication_error" || sdkCode === "authentication_error" || message.toLowerCase().includes("authentication_error")) {
17040
17121
  const msg = message.toLowerCase();
17041
17122
  if (msg.includes("expired") || msg.includes("refresh")) {
@@ -17081,74 +17162,18 @@ function getNestedObj(obj, key) {
17081
17162
  const v = obj[key];
17082
17163
  return typeof v === "object" && v != null ? v : {};
17083
17164
  }
17084
- var DEFAULT_OPERATOR_EVENT_COOLDOWN_MS, cooldownMap;
17165
+ var DEFAULT_OPERATOR_EVENT_COOLDOWN_MS, cooldownMap, OPERATOR_ACTIONABLE_KINDS;
17085
17166
  var init_operator_events = __esm(() => {
17086
17167
  init_format();
17168
+ init_model_unavailable();
17087
17169
  DEFAULT_OPERATOR_EVENT_COOLDOWN_MS = 5 * 60000;
17088
17170
  cooldownMap = new Map;
17089
- });
17090
-
17091
- // quota-check.ts
17092
- var init_quota_check = () => {};
17093
-
17094
- // text-voice-scrub.ts
17095
- var NULL = "\x00", FENCE_PH, INLINE_PH, HTML_CODE_PH, HTML_PRE_PH, URL_PH;
17096
- var init_text_voice_scrub = __esm(() => {
17097
- FENCE_PH = `${NULL}VS_FENCE`;
17098
- INLINE_PH = `${NULL}VS_INLINE`;
17099
- HTML_CODE_PH = `${NULL}VS_HTMLCODE`;
17100
- HTML_PRE_PH = `${NULL}VS_HTMLPRE`;
17101
- URL_PH = `${NULL}VS_URL`;
17102
- });
17103
-
17104
- // card-format.ts
17105
- var init_card_format = __esm(() => {
17106
- init_format();
17107
- init_text_voice_scrub();
17108
- });
17109
-
17110
- // model-unavailable.ts
17111
- function isTransientUpstreamSignal(text) {
17112
- if (typeof text !== "string" || text.length === 0)
17113
- return false;
17114
- const sample = text.length > 16384 ? text.slice(0, 16384) : text;
17115
- const lower = sample.toLowerCase();
17116
- return transientUpstreamSignals.some((s) => lower.includes(s));
17117
- }
17118
- function isLitellmProxyLocal429(text) {
17119
- if (typeof text !== "string" || text.length === 0)
17120
- return false;
17121
- const sample = text.length > 16384 ? text.slice(0, 16384) : text;
17122
- const lower = sample.toLowerCase();
17123
- if (litellmProxyLocal429Signals.some((s) => lower.includes(s)))
17124
- return true;
17125
- return litellmV3LimiterSignalPair.every((s) => lower.includes(s));
17126
- }
17127
- var transientUpstreamSignals, litellmProxyLocal429Signals, litellmV3LimiterSignalPair;
17128
- var init_model_unavailable = __esm(() => {
17129
- init_quota_check();
17130
- init_card_format();
17131
- transientUpstreamSignals = [
17132
- "not your usage limit",
17133
- "not your account",
17134
- "not your account's",
17135
- "temporarily limiting requests",
17136
- "temporarily rate",
17137
- "server is temporarily",
17138
- "would exceed your account\u2019s rate limit",
17139
- "would exceed your account's rate limit"
17140
- ];
17141
- litellmProxyLocal429Signals = [
17142
- "deployment over user-defined ratelimit",
17143
- "model rate limit exceeded. tpm limit",
17144
- "model rate limit exceeded. rpm limit",
17145
- "deployment over defined rpm limit",
17146
- "no deployments available for selected model",
17147
- "litellm rate limit handler",
17148
- "crossed tpm / rpm",
17149
- "max parallel request limit reached"
17150
- ];
17151
- litellmV3LimiterSignalPair = ["rate limit exceeded for ", "limit type:"];
17171
+ OPERATOR_ACTIONABLE_KINDS = new Set([
17172
+ "credentials-expired",
17173
+ "credentials-invalid",
17174
+ "credit-exhausted",
17175
+ "proxy-misconfig"
17176
+ ]);
17152
17177
  });
17153
17178
 
17154
17179
  // tool-label-sidecar.ts
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Deterministic turn-flush backstop delivery core (#3276).
3
+ *
4
+ * The turn-flush backstop historically delivered a flushed answer by EDITING
5
+ * the ephemeral progress card (`editMessageText` onto the taken-over card) and
6
+ * then counted that card-edit as an answer delivery (`sentIds` included the
7
+ * card message id). The card is garbage-collected ~60-90s later, so the turn
8
+ * record said `complete` while nothing durable ever reached the chat.
9
+ *
10
+ * This module owns the pure, side-effect-free arbitration + accounting the
11
+ * backstop needs so it can be unit-tested without the 30k-line gateway:
12
+ *
13
+ * - a per-chunk sent LEDGER with a pre-send pending marker (guard 6) so a
14
+ * retry after a partial send or a lost ack resumes at the first unsent
15
+ * chunk and NEVER re-sends chunk 0;
16
+ * - a bounded in-turn RETRY orchestrator (`runBackstopDelivery`) that is the
17
+ * live caller of the ledger's resume machinery — the real fix for the
18
+ * `send_failed` silent-drop: it retries mid-chunk before giving up, and
19
+ * reports whether the answer was actually delivered so the caller can leave
20
+ * the delivery obligation OPEN on terminal failure;
21
+ * - a RECEIPT GATE (guard 7) that counts only fresh, non-card chat message ids
22
+ * — a card message id can never satisfy the delivery obligation;
23
+ * - a once-per-turn double-fire LATCH (guard 5) so a turn that already fired a
24
+ * backstop (answer-ready quiescence, then the turn-end backstop) does not
25
+ * deliver twice.
26
+ *
27
+ * Every function here is pure over its arguments (the ledger is an explicit
28
+ * per-turn store the caller owns); nothing reads gateway module state.
29
+ */
30
+
31
+ /**
32
+ * Per-turn double-fire latch + per-chunk idempotency ledger for the turn-flush
33
+ * backstop. One instance is held at gateway module scope; entries are keyed by
34
+ * the per-turn `turnId` nonce so distinct turns never collide.
35
+ */
36
+ export class BackstopDeliveryLedger {
37
+ /** turnIds that have claimed the backstop double-fire latch (guard 5). */
38
+ private latched = new Set<string>()
39
+ /** turnId -> (chunkIndex -> landed message ids for that chunk). */
40
+ private chunks = new Map<string, Map<number, number[]>>()
41
+ /** turnId -> chunk indices with an in-flight (pre-ack) send (guard 6). */
42
+ private pending = new Map<string, Set<number>>()
43
+
44
+ /**
45
+ * Guard 5 — once-per-turn BACKSTOP double-fire latch. Returns `true` on the
46
+ * FIRST claim for this `turnId` and `false` on every subsequent claim.
47
+ * Synchronous, so the backstop can arm it at flush-fire time BEFORE any
48
+ * `await` in the send: a second fire for the same turn (answer-ready
49
+ * quiescence followed by the turn-end backstop) is deterministically a no-op.
50
+ *
51
+ * NOTE (scope honesty): this arbitrates backstop-vs-backstop only. The
52
+ * backstop-vs-late-reply arbitration is NOT this latch — it is
53
+ * `flushedTurnSupersede` (real message ids) plus the `turn.answerDelivered`
54
+ * flag, exactly as on `main`. This latch is redundant-but-cheap with the
55
+ * `currentTurn == null` bail the synthetic turn_end already performs.
56
+ */
57
+ claim(turnId: string): boolean {
58
+ if (this.latched.has(turnId)) return false
59
+ this.latched.add(turnId)
60
+ return true
61
+ }
62
+
63
+ /**
64
+ * Release the latch for a turn whose send delivered NOTHING (retries
65
+ * exhausted with zero landed chunks). Leaving it armed would let a spurious
66
+ * re-fire stay suppressed; releasing lets the normal recovery paths run.
67
+ */
68
+ release(turnId: string): void {
69
+ this.latched.delete(turnId)
70
+ }
71
+
72
+ /** Guard 6 — mark a chunk as in-flight BEFORE the wire call, so a crash/retry
73
+ * between the send and the ack can tell "attempted" from "landed". */
74
+ markPending(turnId: string, index: number): void {
75
+ let set = this.pending.get(turnId)
76
+ if (set == null) {
77
+ set = new Set()
78
+ this.pending.set(turnId, set)
79
+ }
80
+ set.add(index)
81
+ }
82
+
83
+ /** Guard 6 — record the landed message id(s) for a chunk and clear its
84
+ * pending marker. Idempotent: recording the same index twice overwrites with
85
+ * the latest landed ids (a resumed send re-delivering an un-acked chunk). */
86
+ recordChunk(turnId: string, index: number, messageIds: number[]): void {
87
+ let m = this.chunks.get(turnId)
88
+ if (m == null) {
89
+ m = new Map()
90
+ this.chunks.set(turnId, m)
91
+ }
92
+ m.set(index, messageIds.slice())
93
+ this.pending.get(turnId)?.delete(index)
94
+ }
95
+
96
+ /** True once a chunk index has landed at least one message id — the resume
97
+ * predicate that stops a retry from re-sending an already-delivered chunk. */
98
+ hasChunk(turnId: string, index: number): boolean {
99
+ return (this.chunks.get(turnId)?.get(index)?.length ?? 0) > 0
100
+ }
101
+
102
+ /** All landed message ids for this turn, in chunk-index order. */
103
+ sentIds(turnId: string): number[] {
104
+ const m = this.chunks.get(turnId)
105
+ if (m == null) return []
106
+ const out: number[] = []
107
+ for (const index of Array.from(m.keys()).sort((a, b) => a - b)) {
108
+ out.push(...(m.get(index) ?? []))
109
+ }
110
+ return out
111
+ }
112
+
113
+ /** Landed {index, messageIds} entries in chunk-index order — lets the caller
114
+ * build a history `texts` array ALIGNED to the actual sent ids (guard fix:
115
+ * a length-resplit chunk lands >1 id, so a naive `chunks.slice(0, n)` zip
116
+ * misaligns). */
117
+ entries(turnId: string): Array<{ index: number; messageIds: number[] }> {
118
+ const m = this.chunks.get(turnId)
119
+ if (m == null) return []
120
+ return Array.from(m.keys())
121
+ .sort((a, b) => a - b)
122
+ .map(index => ({ index, messageIds: (m.get(index) ?? []).slice() }))
123
+ }
124
+
125
+ /** The chunk indices (0..chunkCount-1) NOT yet landed — the resume set a
126
+ * retry must send, in order. */
127
+ unsentIndices(turnId: string, chunkCount: number): number[] {
128
+ const out: number[] = []
129
+ for (let i = 0; i < chunkCount; i++) {
130
+ if (!this.hasChunk(turnId, i)) out.push(i)
131
+ }
132
+ return out
133
+ }
134
+
135
+ /** Drop all state for a turn (post-delivery GC; keeps the map bounded). */
136
+ clear(turnId: string): void {
137
+ this.latched.delete(turnId)
138
+ this.chunks.delete(turnId)
139
+ this.pending.delete(turnId)
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Guard 7 — the RECEIPT gate. Given the raw delivered ids and the progress-card
145
+ * message id (or null), return only the FRESH, non-card chat message ids. A
146
+ * card-edit id can never count toward delivery: the card is swept ~60-90s
147
+ * later, so an answer "delivered" only onto the card reaches the user as
148
+ * nothing.
149
+ */
150
+ export function backstopReceiptIds(
151
+ sentIds: readonly number[],
152
+ cardMessageId: number | null,
153
+ ): number[] {
154
+ return sentIds.filter(id => cardMessageId == null || id !== cardMessageId)
155
+ }
156
+
157
+ /**
158
+ * Guard 7 — the delivery predicate the turn-record status derives from: a
159
+ * backstop delivered its answer IFF at least one fresh non-card chat id landed.
160
+ * Empty ⇒ the turn is `send_failed`, never `complete`.
161
+ */
162
+ export function backstopDelivered(
163
+ sentIds: readonly number[],
164
+ cardMessageId: number | null,
165
+ ): boolean {
166
+ return backstopReceiptIds(sentIds, cardMessageId).length > 0
167
+ }
168
+
169
+ /** Injected effects for {@link runBackstopDelivery}. Pure over its arguments;
170
+ * the gateway supplies real `sendChunk` (via `sendReplyChunks`) + history. */
171
+ export interface BackstopDeliveryDeps {
172
+ /** Send ONE chunk. Resolves to the landed message id(s) (a length-resplit
173
+ * chunk may land >1). Rejects on an unrecoverable send failure. */
174
+ sendChunk: (chunkIndex: number, text: string) => Promise<number[]>
175
+ /** Record the delivered ids + aligned texts to history (called once, after
176
+ * the attempts resolve, with the full landed set). */
177
+ recordOutbound?: (messageIds: number[], texts: string[]) => void
178
+ /** Optional stderr sink for progress/resume logging. */
179
+ stderr?: (s: string) => void
180
+ }
181
+
182
+ export interface BackstopDeliveryResult {
183
+ /** All landed fresh chat message ids (card id already excluded upstream —
184
+ * `sendChunk` never targets the card). In chunk-index order. */
185
+ sentIds: number[]
186
+ /** Number of input chunks the answer was split into. */
187
+ chunkCount: number
188
+ /** True IFF every chunk landed at least one fresh non-card id. */
189
+ delivered: boolean
190
+ /** How many attempts ran (1..maxAttempts). */
191
+ attempts: number
192
+ /** True when retries were exhausted without full delivery (terminal fail). */
193
+ exhausted: boolean
194
+ }
195
+
196
+ /**
197
+ * Bounded in-turn retry orchestrator (guard 6's live caller, finding-1 fix).
198
+ *
199
+ * Sends `chunks` via `deps.sendChunk`, up to `maxAttempts` times. Each attempt
200
+ * consults the ledger and RESUMES at the first unsent chunk — a chunk that
201
+ * already landed on a prior attempt is never re-sent (so chunk 0 is delivered
202
+ * exactly once even across retries). Only after all attempts are exhausted
203
+ * without full delivery does it report `exhausted: true` / `delivered: false`,
204
+ * so the caller can leave the delivery obligation OPEN for the liveness floor
205
+ * to re-present — instead of the old silent `send_failed` drop.
206
+ *
207
+ * `recordOutbound` (when provided) fires ONCE at the end with the full landed
208
+ * set and a `texts` array ALIGNED to the actual sent ids (via `ledger.entries`).
209
+ * The ledger is NOT cleared here — the caller clears it only after success or
210
+ * exhaustion, so a resume can always read prior progress.
211
+ */
212
+ export async function runBackstopDelivery(
213
+ ledger: BackstopDeliveryLedger,
214
+ turnId: string,
215
+ chunks: readonly string[],
216
+ cardMessageId: number | null,
217
+ deps: BackstopDeliveryDeps,
218
+ maxAttempts = 3,
219
+ ): Promise<BackstopDeliveryResult> {
220
+ const stderr = deps.stderr ?? (() => {})
221
+ const chunkCount = chunks.length
222
+ let attempts = 0
223
+
224
+ for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt++) {
225
+ attempts = attempt
226
+ const resume = ledger.unsentIndices(turnId, chunkCount)
227
+ if (resume.length === 0) break // everything already landed
228
+ if (attempt > 1) {
229
+ stderr(
230
+ `telegram gateway: backstop delivery retry ${attempt}/${maxAttempts} — ` +
231
+ `resuming at unsent chunk(s) [${resume.join(', ')}] for turn ${turnId}\n`,
232
+ )
233
+ }
234
+ let attemptThrew = false
235
+ try {
236
+ for (const i of resume) {
237
+ // Guard 6 — never re-send a chunk that already landed for this turn.
238
+ if (ledger.hasChunk(turnId, i)) continue
239
+ ledger.markPending(turnId, i)
240
+ const ids = await deps.sendChunk(i, chunks[i])
241
+ ledger.recordChunk(turnId, i, ids)
242
+ }
243
+ } catch (err) {
244
+ attemptThrew = true
245
+ stderr(
246
+ `telegram gateway: backstop delivery attempt ${attempt}/${maxAttempts} failed: ` +
247
+ `${err instanceof Error ? err.message : String(err)}\n`,
248
+ )
249
+ }
250
+ if (!attemptThrew && ledger.unsentIndices(turnId, chunkCount).length === 0) break
251
+ }
252
+
253
+ const sentIds = ledger.sentIds(turnId)
254
+ const delivered =
255
+ chunkCount > 0 && ledger.unsentIndices(turnId, chunkCount).length === 0 &&
256
+ backstopReceiptIds(sentIds, cardMessageId).length > 0
257
+ const exhausted = !delivered
258
+
259
+ if (deps.recordOutbound && sentIds.length > 0) {
260
+ const texts: string[] = []
261
+ const ids: number[] = []
262
+ for (const { index, messageIds } of ledger.entries(turnId)) {
263
+ for (const id of messageIds) {
264
+ ids.push(id)
265
+ texts.push(chunks[index] ?? '')
266
+ }
267
+ }
268
+ deps.recordOutbound(ids, texts)
269
+ }
270
+
271
+ return { sentIds, chunkCount, delivered, attempts, exhausted }
272
+ }
@@ -19,6 +19,8 @@
19
19
  * authenticated identity.
20
20
  */
21
21
 
22
+ import { fmtLocalStamp, resolveEnvTimezone } from '../shared/local-time.js'
23
+
22
24
  import type { MessageOrigin } from 'grammy/types'
23
25
  import { escapeXmlAttribute } from '../steering.js'
24
26
 
@@ -222,7 +224,13 @@ export function buildForwardOriginMeta(
222
224
  out[`forwarded_from_type${suffix}`] = o.type
223
225
  if (o.id != null) out[`forwarded_from_id${suffix}`] = String(o.id)
224
226
  if (o.date != null) {
225
- out[`forwarded_date${suffix}`] = new Date(o.date * 1000).toISOString()
227
+ // Model-facing channel attribute render the agent's LOCAL am/pm
228
+ // wall-clock (NOT UTC ISO) so it never competes with the local-time
229
+ // hint. The machine/storage copy stays ISO via `forwardOriginDateIso`.
230
+ out[`forwarded_date${suffix}`] = fmtLocalStamp(
231
+ o.date * 1000,
232
+ resolveEnvTimezone(),
233
+ )
226
234
  }
227
235
  })
228
236
  return out