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.
- package/bin/handoff-briefing.sh +15 -2
- package/dist/agent-scheduler/index.js +111 -7
- package/dist/auth-broker/index.js +154 -73
- package/dist/cli/autoaccept-poll.js +8 -3
- package/dist/cli/drive-write-pretool.mjs +8 -3
- package/dist/cli/ms-365-write-pretool.mjs +158 -11
- package/dist/cli/notion-write-pretool.mjs +103 -4
- package/dist/cli/switchroom.js +2712 -2219
- package/dist/host-control/main.js +110 -70
- package/dist/vault/approvals/kernel-server.js +116 -70
- package/dist/vault/broker/server.js +314 -202
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +105 -34
- package/telegram-plugin/dist/bridge/bridge.js +71 -47
- package/telegram-plugin/dist/gateway/gateway.js +1128 -666
- package/telegram-plugin/dist/server.js +89 -64
- package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
- package/telegram-plugin/gateway/forward-origin.ts +9 -1
- package/telegram-plugin/gateway/gateway.ts +656 -388
- package/telegram-plugin/gateway/model-command.ts +331 -602
- package/telegram-plugin/gateway/session-model-file.ts +40 -0
- package/telegram-plugin/gateway/turn-record-status.ts +45 -0
- package/telegram-plugin/gateway/unhandled-message.ts +177 -0
- package/telegram-plugin/history.ts +153 -23
- package/telegram-plugin/llm-error-present.ts +24 -0
- package/telegram-plugin/model-unavailable.ts +55 -0
- package/telegram-plugin/operator-events.ts +113 -0
- package/telegram-plugin/pending-user-notice.ts +88 -0
- package/telegram-plugin/shared/local-time.ts +99 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
- package/telegram-plugin/tests/forward-origin.test.ts +30 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
- package/telegram-plugin/tests/history.test.ts +88 -0
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
- package/telegram-plugin/tests/local-time.test.ts +135 -0
- package/telegram-plugin/tests/model-command.test.ts +427 -1512
- package/telegram-plugin/tests/session-model-file.test.ts +23 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
- package/telegram-plugin/tier-downgrade.ts +4 -3
- package/telegram-plugin/turn-flush-safety.ts +25 -1
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
- package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
- 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
|
-
|
|
17092
|
-
|
|
17093
|
-
|
|
17094
|
-
|
|
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
|
-
|
|
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
|