switchroom 0.19.11 → 0.19.13
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/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +5 -0
- package/profiles/_base/start.sh.hbs +11 -0
- package/telegram-plugin/dist/gateway/gateway.js +1382 -1032
- package/telegram-plugin/final-answer-detect.ts +23 -0
- package/telegram-plugin/gateway/gateway.ts +29 -36
- package/telegram-plugin/gateway/outbound-send-path.ts +109 -8
- package/telegram-plugin/gateway/outbox-sweep.ts +282 -0
- package/telegram-plugin/gateway/stream-render.ts +26 -50
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +94 -2
- package/telegram-plugin/hooks/silent-end-scan.mjs +279 -0
- package/telegram-plugin/outbox.ts +472 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +91 -0
- package/telegram-plugin/temporal-normalize.ts +347 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +22 -12
- package/telegram-plugin/tests/outbound-send-path.test.ts +191 -0
- package/telegram-plugin/tests/outbox-capture-scan.test.ts +222 -0
- package/telegram-plugin/tests/outbox-delivery.test.ts +278 -0
- package/telegram-plugin/tests/outbox-hook-capture.test.ts +112 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +60 -63
- package/telegram-plugin/tests/silent-end.test.ts +21 -28
- package/telegram-plugin/tests/temporal-normalize.test.ts +222 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +23 -18
|
@@ -55,7 +55,8 @@
|
|
|
55
55
|
|
|
56
56
|
import { createAnswerStream } from '../answer-stream.js'
|
|
57
57
|
import { LivenessTracker, isContextExhaustionText } from '../context-exhaustion.js'
|
|
58
|
-
import {
|
|
58
|
+
import { normalizeOutboundBody } from './outbound-send-path.js'
|
|
59
|
+
import { resolveEnvTimezone } from '../shared/local-time.js'
|
|
59
60
|
import { hasOutboundDeliveredSince, recordOutbound } from '../history.js'
|
|
60
61
|
import { isReplyTool } from '../narrative-dedup.js'
|
|
61
62
|
import { NarrativeFlushController } from '../narrative-flush.js'
|
|
@@ -65,7 +66,6 @@ import { richMessage } from '../rich-send.js'
|
|
|
65
66
|
import { emitRuntimeMetric } from '../runtime-metrics.js'
|
|
66
67
|
import { CAPTURED_PROSE_MIN_CHARS, clearSilentEndState, decideCapturedProseDelivery, recordUndeliveredTurnEnd, silentEndFallbackText, writeSilentEndState } from '../silent-end.js'
|
|
67
68
|
import { logStreamingEvent } from '../streaming-metrics.js'
|
|
68
|
-
import { scrubVoice } from '../text-voice-scrub.js'
|
|
69
69
|
import { appendActivityLabel } from '../tool-activity-summary.js'
|
|
70
70
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
71
71
|
import { decideTurnFlush } from '../turn-flush-safety.js'
|
|
@@ -1523,19 +1523,6 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1523
1523
|
}
|
|
1524
1524
|
|
|
1525
1525
|
if (turnEndDecision === 'flush' && flushDecision.kind === 'flush') {
|
|
1526
|
-
let capturedText = flushDecision.text
|
|
1527
|
-
// #2798 — turn-flush delivers the model's terminal prose when it
|
|
1528
|
-
// skipped reply/stream_reply, but historically bypassed the reply
|
|
1529
|
-
// path's markdown normalization entirely. Mirror executeReply's front
|
|
1530
|
-
// of pipeline here so the backstop renders identically: repair LLM
|
|
1531
|
-
// JSON-escape bungles (literal `\n`), then promote lone prose paragraph
|
|
1532
|
-
// breaks into GFM hard breaks so the Bot API 10.1 rich path doesn't
|
|
1533
|
-
// collapse them (lists/tables/code left untouched). Runs BEFORE the
|
|
1534
|
-
// redact/scrub below, exactly as reply orders it (repair → normalize →
|
|
1535
|
-
// redact → scrub), so masking sees the repaired text. Paragraph gaps
|
|
1536
|
-
// are the plain `\n\n` normalizeParagraphBreaks guarantees — no spacer
|
|
1537
|
-
// pass runs on the send side any more (removed in the #2669 follow-up).
|
|
1538
|
-
capturedText = normalizeParagraphBreaks(repairEscapedWhitespace(capturedText))
|
|
1539
1526
|
// Component 3 — origin-thread backstop. `chatId`/`threadId` are
|
|
1540
1527
|
// captured from the turn atom (turn.sessionChatId/sessionThreadId)
|
|
1541
1528
|
// at the top of this turn_end handler, NOT from the live
|
|
@@ -1547,41 +1534,30 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1547
1534
|
const backstopThreadId = threadId
|
|
1548
1535
|
const backstopCtrl = ctrl
|
|
1549
1536
|
|
|
1550
|
-
//
|
|
1551
|
-
// terminal
|
|
1552
|
-
//
|
|
1553
|
-
//
|
|
1554
|
-
//
|
|
1555
|
-
//
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
{
|
|
1575
|
-
const scrub = scrubVoice(capturedText)
|
|
1576
|
-
if (scrub.replaced > 0) {
|
|
1577
|
-
capturedText = scrub.scrubbed
|
|
1578
|
-
emitRuntimeMetric({
|
|
1579
|
-
kind: 'voice_scrub_applied',
|
|
1580
|
-
chatKey: statusKey(backstopChatId, backstopThreadId),
|
|
1581
|
-
replaced: scrub.replaced,
|
|
1582
|
-
site: 'turn_flush',
|
|
1583
|
-
})
|
|
1584
|
-
}
|
|
1537
|
+
// #3501: route through the single shared outbound seam. Turn-flush
|
|
1538
|
+
// delivers the model's terminal prose when it skipped reply/stream_reply
|
|
1539
|
+
// and used to hand-mirror the reply pipeline inline (repair → paragraph-
|
|
1540
|
+
// break → redact → punctuation/bold → voice scrub). `normalizeOutboundBody`
|
|
1541
|
+
// IS that pipeline with the same load-bearing order — the metric side
|
|
1542
|
+
// effect (voice_scrub_applied on a non-zero replacement) is emitted here
|
|
1543
|
+
// by the caller, exactly as the reply/edit sites do.
|
|
1544
|
+
const _flushNorm = normalizeOutboundBody(
|
|
1545
|
+
flushDecision.text,
|
|
1546
|
+
'turn_flush',
|
|
1547
|
+
redactOutboundText,
|
|
1548
|
+
// #3501 temporal pass — a cron/mail-watcher notification that skipped
|
|
1549
|
+
// reply is delivered here, so this is where "closed tomorrow (Thu 23
|
|
1550
|
+
// Jul)" gets corrected against the agent's local date.
|
|
1551
|
+
{ tz: resolveEnvTimezone(), nowMs: Date.now() },
|
|
1552
|
+
)
|
|
1553
|
+
let capturedText = _flushNorm.text
|
|
1554
|
+
if (_flushNorm.voiceReplaced > 0) {
|
|
1555
|
+
emitRuntimeMetric({
|
|
1556
|
+
kind: 'voice_scrub_applied',
|
|
1557
|
+
chatKey: statusKey(backstopChatId, backstopThreadId),
|
|
1558
|
+
replaced: _flushNorm.voiceReplaced,
|
|
1559
|
+
site: 'turn_flush',
|
|
1560
|
+
})
|
|
1585
1561
|
}
|
|
1586
1562
|
|
|
1587
1563
|
// #1664 — turn-flush only fires when !replyCalled (decideTurnFlush
|
|
@@ -54,12 +54,16 @@
|
|
|
54
54
|
* every session close.
|
|
55
55
|
*/
|
|
56
56
|
|
|
57
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
57
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
58
58
|
import { join } from 'node:path'
|
|
59
59
|
import { homedir } from 'node:os'
|
|
60
60
|
|
|
61
|
+
import { createHash } from 'node:crypto'
|
|
62
|
+
import { renameSync } from 'node:fs'
|
|
63
|
+
|
|
61
64
|
import {
|
|
62
65
|
scanTurnForFinalReply,
|
|
66
|
+
scanForOutboxCapture,
|
|
63
67
|
decideStopHookDisposition,
|
|
64
68
|
isTurnFlushSafetyEnabledEnv,
|
|
65
69
|
isCapturedProseDeliveryEnabledEnv,
|
|
@@ -131,6 +135,64 @@ function writeElectedState(statePath, base, decision) {
|
|
|
131
135
|
}
|
|
132
136
|
}
|
|
133
137
|
|
|
138
|
+
const JOURNAL_FILE = 'delivered.jsonl'
|
|
139
|
+
|
|
140
|
+
/** Is `nonce` already in the outbox delivered-keys journal? (best-effort) */
|
|
141
|
+
function outboxAlreadyDelivered(outboxDir, nonce) {
|
|
142
|
+
const path = join(outboxDir, JOURNAL_FILE)
|
|
143
|
+
if (!existsSync(path)) return false
|
|
144
|
+
try {
|
|
145
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
146
|
+
if (!line) continue
|
|
147
|
+
try {
|
|
148
|
+
if (JSON.parse(line)?.turnNonce === nonce) return true
|
|
149
|
+
} catch {
|
|
150
|
+
/* skip corrupt */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
/* best-effort */
|
|
155
|
+
}
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Write the durable outbox record for a captured undelivered final answer
|
|
161
|
+
* (atomic tmp+rename), mirroring `writeOutboxRecordAtomic` in `../outbox.ts`.
|
|
162
|
+
* The gateway heartbeat sweep is the single deliverer. Best-effort; never
|
|
163
|
+
* throws. Returns true when a record exists on disk for the nonce afterward.
|
|
164
|
+
*/
|
|
165
|
+
function writeOutboxRecord(stateDir, capture) {
|
|
166
|
+
const outboxDir = join(stateDir, 'outbox')
|
|
167
|
+
try {
|
|
168
|
+
mkdirSync(outboxDir, { recursive: true })
|
|
169
|
+
const finalPath = join(outboxDir, `${capture.turnNonce}.json`)
|
|
170
|
+
if (existsSync(finalPath)) return true
|
|
171
|
+
// Already delivered by another machine (reply/flush) under this nonce.
|
|
172
|
+
if (outboxAlreadyDelivered(outboxDir, capture.turnNonce)) return true
|
|
173
|
+
const record = {
|
|
174
|
+
turnNonce: capture.turnNonce,
|
|
175
|
+
chatId: capture.chatId,
|
|
176
|
+
threadId: capture.threadId,
|
|
177
|
+
text: capture.text,
|
|
178
|
+
textSha256: createHash('sha256').update(capture.text, 'utf8').digest('hex'),
|
|
179
|
+
createdAt: Date.now(),
|
|
180
|
+
source: capture.source,
|
|
181
|
+
anchorContent: capture.chatId == null ? capture.anchorContent : undefined,
|
|
182
|
+
// F2: per-session origin chat for envelope-less routing (fail-closed).
|
|
183
|
+
originChatId: capture.chatId == null ? (capture.originChatId ?? null) : undefined,
|
|
184
|
+
originThreadId: capture.chatId == null ? (capture.originThreadId ?? null) : undefined,
|
|
185
|
+
}
|
|
186
|
+
const tmpPath = join(outboxDir, `.${capture.turnNonce}.${process.pid}.tmp`)
|
|
187
|
+
writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
|
|
188
|
+
renameSync(tmpPath, finalPath)
|
|
189
|
+
return true
|
|
190
|
+
} catch (err) {
|
|
191
|
+
process.stderr.write(`[silent-end-interrupt] failed to write outbox record: ${err.message}\n`)
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
134
196
|
function main() {
|
|
135
197
|
const raw = readStdin().trim()
|
|
136
198
|
if (!raw) process.exit(0)
|
|
@@ -161,6 +223,37 @@ function main() {
|
|
|
161
223
|
process.exit(0)
|
|
162
224
|
}
|
|
163
225
|
|
|
226
|
+
const stateDir = getStateDir()
|
|
227
|
+
|
|
228
|
+
// ── Outbox capture (guaranteed final-message delivery) ────────────────
|
|
229
|
+
// Class-agnostic: fires on EVERY main-session turn end. When the turn ended
|
|
230
|
+
// with substantive undelivered trailing prose (Telegram inbound,
|
|
231
|
+
// task-notification handback, cron, or a future/unknown wake shape), write a
|
|
232
|
+
// durable outbox record. The gateway heartbeat sweep is the single deliverer
|
|
233
|
+
// — no CurrentTurn, no election, no re-prompt needed. This is the deterministic
|
|
234
|
+
// guarantee: the model calling `reply` is no longer required. Fail-open on any
|
|
235
|
+
// error (never loop the session).
|
|
236
|
+
//
|
|
237
|
+
// Kill switch symmetry: `SWITCHROOM_TG_OUTBOX_DELIVERY=0` disables the gateway
|
|
238
|
+
// sweep (the deliverer). Capture MUST honour the SAME gate — otherwise, with
|
|
239
|
+
// the flag off, capture would write records that nothing delivers AND
|
|
240
|
+
// short-circuit the legacy re-prompt path below = silent loss. Flag off ⇒ skip
|
|
241
|
+
// capture entirely and fall through to the legacy block/re-prompt behaviour.
|
|
242
|
+
if (process.env.SWITCHROOM_TG_OUTBOX_DELIVERY !== '0') try {
|
|
243
|
+
const capture = scanForOutboxCapture(jsonl)
|
|
244
|
+
if (capture.capture === true) {
|
|
245
|
+
writeOutboxRecord(stateDir, capture)
|
|
246
|
+
process.stderr.write(
|
|
247
|
+
`[silent-end-interrupt] captured undelivered final answer to outbox ` +
|
|
248
|
+
`(nonce=${capture.turnNonce} source=${capture.source} chars=${capture.text.length}) — ` +
|
|
249
|
+
`sweep will deliver; allowing stop\n`,
|
|
250
|
+
)
|
|
251
|
+
process.exit(0)
|
|
252
|
+
}
|
|
253
|
+
} catch (err) {
|
|
254
|
+
process.stderr.write(`[silent-end-interrupt] outbox capture error (fail-open): ${err.message}\n`)
|
|
255
|
+
}
|
|
256
|
+
|
|
164
257
|
const decision = scanTurnForFinalReply(jsonl)
|
|
165
258
|
|
|
166
259
|
// 'allow' (qualifying reply or silent marker) and 'unknown' (no
|
|
@@ -175,7 +268,6 @@ function main() {
|
|
|
175
268
|
// transcript above. If a state file exists from a prior turn that
|
|
176
269
|
// never got cleared (clean shutdown not perfect), this read still
|
|
177
270
|
// works; if absent, retryCount defaults to 0.
|
|
178
|
-
const stateDir = getStateDir()
|
|
179
271
|
const statePath = join(stateDir, 'silent-end-pending.json')
|
|
180
272
|
|
|
181
273
|
let state = {}
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
// re-verify only if a new outbound-delivery tool is added to bridge.ts.
|
|
58
58
|
import { statSync } from 'node:fs'
|
|
59
59
|
import { join } from 'node:path'
|
|
60
|
+
import { createHash } from 'node:crypto'
|
|
60
61
|
|
|
61
62
|
const REPLY_TOOLS = new Set([
|
|
62
63
|
'mcp__switchroom-telegram__reply',
|
|
@@ -718,3 +719,281 @@ export function isGatewayHeartbeatFresh(stateDir, now = Date.now()) {
|
|
|
718
719
|
return false
|
|
719
720
|
}
|
|
720
721
|
}
|
|
722
|
+
|
|
723
|
+
// ── Outbox capture (guaranteed final-message delivery) ───────────────────────
|
|
724
|
+
//
|
|
725
|
+
// The Stop hook fires on EVERY main-session turn end, regardless of what woke
|
|
726
|
+
// the turn (Telegram inbound, `<task-notification>` handback, cron, or a future
|
|
727
|
+
// harness wake type). `scanForOutboxCapture` decides — class-agnostically —
|
|
728
|
+
// whether this turn ended with substantive undelivered trailing prose that must
|
|
729
|
+
// be captured into the durable outbox for the gateway sweep to deliver.
|
|
730
|
+
//
|
|
731
|
+
// Fail CLOSED (H4): capture keys on "a turn ended with unsent trailing prose",
|
|
732
|
+
// never on recognising the wake shape. An anchor-less transcript tail still
|
|
733
|
+
// captures (source='unknown') rather than silently allowing a lost answer.
|
|
734
|
+
//
|
|
735
|
+
// Cron (H5): NOT exempted — a cron turn that ends with prose is captured and
|
|
736
|
+
// guaranteed-delivered, same as any other class. Only NO_REPLY stays silent.
|
|
737
|
+
//
|
|
738
|
+
// H6: a turn ending with real prose followed by a stray bare `NO_REPLY` still
|
|
739
|
+
// captures the prose (the marker doesn't suppress a genuine answer), while a
|
|
740
|
+
// legitimately-silent turn (pure NO_REPLY / narration-only) writes no record.
|
|
741
|
+
|
|
742
|
+
/** Mirror of `deriveTurnNonce` in `../outbox.ts` — MUST stay in sync (a .mjs
|
|
743
|
+
* can't import the .ts). Test `outbox-nonce-parity` pins the equality. */
|
|
744
|
+
export function deriveTurnNonce({ chatId, threadId, messageId, anchorTimestampMs, anchorContent }) {
|
|
745
|
+
if (chatId != null && messageId != null && messageId !== '' && String(messageId) !== '0') {
|
|
746
|
+
const key = `${chatId}:${threadId == null || threadId === 0 ? '_' : threadId}`
|
|
747
|
+
return `${key}#${messageId}`
|
|
748
|
+
}
|
|
749
|
+
return createHash('sha256').update(`${anchorTimestampMs}\n${anchorContent}`, 'utf8').digest('hex')
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Strip a trailing bare silent-marker line (NO_REPLY / HEARTBEAT_OK) from a text
|
|
754
|
+
* block, returning the prose that precedes it (H6). If the whole block is the
|
|
755
|
+
* marker, prose is ''. If there is no trailing marker, `hadMarker` is false and
|
|
756
|
+
* prose is the block unchanged.
|
|
757
|
+
*
|
|
758
|
+
* @param {string} text
|
|
759
|
+
* @returns {{ prose: string, hadMarker: boolean }}
|
|
760
|
+
*/
|
|
761
|
+
export function stripTrailingSilentMarker(text) {
|
|
762
|
+
if (typeof text !== 'string') return { prose: '', hadMarker: false }
|
|
763
|
+
const rawLines = text.split('\n')
|
|
764
|
+
// Find the last non-empty line.
|
|
765
|
+
let lastIdx = -1
|
|
766
|
+
for (let i = rawLines.length - 1; i >= 0; i--) {
|
|
767
|
+
if (rawLines[i].trim().length > 0) {
|
|
768
|
+
lastIdx = i
|
|
769
|
+
break
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (lastIdx === -1) return { prose: '', hadMarker: false }
|
|
773
|
+
if (!SILENT_MARKER_RE.test(rawLines[lastIdx].trim())) {
|
|
774
|
+
return { prose: text.trim(), hadMarker: false }
|
|
775
|
+
}
|
|
776
|
+
const prose = rawLines.slice(0, lastIdx).join('\n').trim()
|
|
777
|
+
return { prose, hadMarker: true }
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Resolve the turn-start anchor for capture. Primary: the most-recent
|
|
782
|
+
* `queue-operation`/`enqueue` line. H4 fallbacks (fail closed) when none is
|
|
783
|
+
* found: the last non-sidechain `user`-type line, else the last
|
|
784
|
+
* `queue-operation` of any operation, else the whole scanned range (startIdx=-1
|
|
785
|
+
* → scan from the top). `degraded` marks a non-primary anchor (source unknown).
|
|
786
|
+
*
|
|
787
|
+
* @param {string[]} lines
|
|
788
|
+
* @returns {{ startIdx: number, envelope: ReturnType<typeof parseChannelEnvelope>, anchorTimestampMs: number, anchorContent: string, degraded: boolean }}
|
|
789
|
+
*/
|
|
790
|
+
function resolveCaptureAnchor(lines) {
|
|
791
|
+
let enqueueIdx = -1
|
|
792
|
+
let anyQueueIdx = -1
|
|
793
|
+
let userIdx = -1
|
|
794
|
+
const parsed = new Array(lines.length)
|
|
795
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
796
|
+
const line = lines[i]
|
|
797
|
+
if (!line || line[0] !== '{') continue
|
|
798
|
+
let obj
|
|
799
|
+
try { obj = JSON.parse(line) } catch { continue }
|
|
800
|
+
parsed[i] = obj
|
|
801
|
+
if (obj?.type === 'queue-operation') {
|
|
802
|
+
if (obj.operation === 'enqueue' && enqueueIdx === -1) enqueueIdx = i
|
|
803
|
+
if (anyQueueIdx === -1) anyQueueIdx = i
|
|
804
|
+
}
|
|
805
|
+
if (obj?.type === 'user' && obj?.isSidechain !== true && userIdx === -1) userIdx = i
|
|
806
|
+
}
|
|
807
|
+
const anchorTs = (obj) => {
|
|
808
|
+
const t = obj?.timestamp
|
|
809
|
+
if (typeof t === 'number' && Number.isFinite(t)) return t
|
|
810
|
+
if (typeof t === 'string') {
|
|
811
|
+
const ms = Date.parse(t)
|
|
812
|
+
if (Number.isFinite(ms)) return ms
|
|
813
|
+
}
|
|
814
|
+
return Date.now()
|
|
815
|
+
}
|
|
816
|
+
if (enqueueIdx !== -1) {
|
|
817
|
+
const obj = parsed[enqueueIdx]
|
|
818
|
+
const content = typeof obj.content === 'string' ? obj.content : ''
|
|
819
|
+
return {
|
|
820
|
+
startIdx: enqueueIdx,
|
|
821
|
+
envelope: parseChannelEnvelope(content),
|
|
822
|
+
anchorTimestampMs: anchorTs(obj),
|
|
823
|
+
anchorContent: content,
|
|
824
|
+
degraded: false,
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (userIdx !== -1) {
|
|
828
|
+
const obj = parsed[userIdx]
|
|
829
|
+
const content = typeof obj?.message?.content === 'string' ? obj.message.content : JSON.stringify(obj?.message?.content ?? '')
|
|
830
|
+
return {
|
|
831
|
+
startIdx: userIdx,
|
|
832
|
+
envelope: { chatId: null, threadId: null, messageId: null, source: 'unknown' },
|
|
833
|
+
anchorTimestampMs: anchorTs(obj),
|
|
834
|
+
anchorContent: content,
|
|
835
|
+
degraded: true,
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (anyQueueIdx !== -1) {
|
|
839
|
+
const obj = parsed[anyQueueIdx]
|
|
840
|
+
const content = typeof obj.content === 'string' ? obj.content : ''
|
|
841
|
+
return {
|
|
842
|
+
startIdx: anyQueueIdx,
|
|
843
|
+
envelope: parseChannelEnvelope(content),
|
|
844
|
+
anchorTimestampMs: anchorTs(obj),
|
|
845
|
+
anchorContent: content,
|
|
846
|
+
degraded: true,
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
// No anchor at all — scan the whole range (fail closed on unknown shape).
|
|
850
|
+
return {
|
|
851
|
+
startIdx: -1,
|
|
852
|
+
envelope: { chatId: null, threadId: null, messageId: null, source: 'unknown' },
|
|
853
|
+
anchorTimestampMs: Date.now(),
|
|
854
|
+
anchorContent: '',
|
|
855
|
+
degraded: true,
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Resolve THIS session's origin chat — the most-recent non-sidechain enqueue
|
|
861
|
+
* line whose content carries a real Telegram `<channel>` envelope with a
|
|
862
|
+
* chatId. Scoped to the session's OWN transcript, this is the conversation of
|
|
863
|
+
* record for an envelope-less handback turn (F2): the sweep routes an
|
|
864
|
+
* unresolved-registry record here instead of a gateway-global "last chat anyone
|
|
865
|
+
* messaged" fallback, so a DM-origin handback can never leak into an unrelated
|
|
866
|
+
* chat. Null when the session has no prior channel inbound → the record fails
|
|
867
|
+
* CLOSED (held, never delivered to an arbitrary chat).
|
|
868
|
+
*
|
|
869
|
+
* @param {string[]} lines
|
|
870
|
+
* @returns {{ chatId: string | null, threadId: number | null }}
|
|
871
|
+
*/
|
|
872
|
+
function resolveSessionOriginChat(lines) {
|
|
873
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
874
|
+
const line = lines[i]
|
|
875
|
+
if (!line || line[0] !== '{') continue
|
|
876
|
+
let obj
|
|
877
|
+
try { obj = JSON.parse(line) } catch { continue }
|
|
878
|
+
if (obj?.isSidechain === true) continue
|
|
879
|
+
if (obj?.type !== 'queue-operation') continue
|
|
880
|
+
const content = typeof obj.content === 'string' ? obj.content : ''
|
|
881
|
+
const env = parseChannelEnvelope(content)
|
|
882
|
+
if (env.chatId != null && env.chatId !== '') {
|
|
883
|
+
return { chatId: env.chatId, threadId: env.threadId ?? null }
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return { chatId: null, threadId: null }
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Class-agnostic capture scan. Walks the turn from its anchor, tracking the last
|
|
891
|
+
* delivery event (qualifying reply / silent marker) and the substantive prose
|
|
892
|
+
* blocks that trail it. Returns a capture descriptor when the turn ended with
|
|
893
|
+
* undelivered final-answer prose, else `{ capture: false }`.
|
|
894
|
+
*
|
|
895
|
+
* @param {string} jsonl
|
|
896
|
+
* @param {number} [now]
|
|
897
|
+
* @returns {{ capture: false, reason: string } | { capture: true, text: string, turnNonce: string, chatId: string|null, threadId: number|null, source: string, anchorContent: string }}
|
|
898
|
+
*/
|
|
899
|
+
export function scanForOutboxCapture(jsonl, now = Date.now()) {
|
|
900
|
+
const lines = jsonl.split('\n')
|
|
901
|
+
const anchor = resolveCaptureAnchor(lines)
|
|
902
|
+
const { envelope } = anchor
|
|
903
|
+
|
|
904
|
+
const blocks = []
|
|
905
|
+
for (let i = anchor.startIdx + 1; i < lines.length; i++) {
|
|
906
|
+
const line = lines[i]
|
|
907
|
+
if (!line || line[0] !== '{') continue
|
|
908
|
+
let obj
|
|
909
|
+
try { obj = JSON.parse(line) } catch { continue }
|
|
910
|
+
if (obj?.isSidechain === true) continue
|
|
911
|
+
if (obj?.type !== 'assistant') continue
|
|
912
|
+
const content = obj?.message?.content
|
|
913
|
+
if (!Array.isArray(content)) continue
|
|
914
|
+
for (const c of content) {
|
|
915
|
+
if (c?.type === 'text') {
|
|
916
|
+
const raw = String(c.text ?? '')
|
|
917
|
+
// H6: strip a trailing bare marker; if substantive non-narration prose
|
|
918
|
+
// remains, it is a genuine (undelivered) answer — capture it. If nothing
|
|
919
|
+
// but the marker (or narration) remains, the block is a silence event.
|
|
920
|
+
const { prose, hadMarker } = stripTrailingSilentMarker(raw)
|
|
921
|
+
if (hadMarker) {
|
|
922
|
+
// H6: a block ending "…real answer…\nNO_REPLY" carries a genuine
|
|
923
|
+
// answer — capture the prose and do NOT treat the block as a silence
|
|
924
|
+
// event (which would mask the answer). When the prose is empty or pure
|
|
925
|
+
// narration the marker is an intentional silence, but it must NOT emit
|
|
926
|
+
// a 'deliver' event: a *separate* bare-`NO_REPLY` block arriving AFTER
|
|
927
|
+
// a real (undelivered) prose block would otherwise move the
|
|
928
|
+
// last-delivery cursor past that prose and mask it (multi-block H6).
|
|
929
|
+
// A bare/narration marker is simply not a delivery — push nothing.
|
|
930
|
+
if (prose.length > 0 && !isNarrationBlock(prose)) {
|
|
931
|
+
blocks.push({ kind: 'text', chars: prose.length, text: prose })
|
|
932
|
+
}
|
|
933
|
+
} else if (prose.length > 0) {
|
|
934
|
+
blocks.push({ kind: 'text', chars: prose.length, text: prose })
|
|
935
|
+
}
|
|
936
|
+
continue
|
|
937
|
+
}
|
|
938
|
+
if (c?.type !== 'tool_use') continue
|
|
939
|
+
if (!REPLY_TOOLS.has(c.name)) continue
|
|
940
|
+
const input = c.input ?? {}
|
|
941
|
+
const text = String(input.text ?? '')
|
|
942
|
+
if (SILENT_MARKER_RE.test(text.trim()) || endsWithSilentMarker(text)) {
|
|
943
|
+
blocks.push({ kind: 'deliver', reason: 'silent-marker' })
|
|
944
|
+
continue
|
|
945
|
+
}
|
|
946
|
+
if (isFinalAnswerReply({
|
|
947
|
+
text,
|
|
948
|
+
disableNotification: input.disable_notification === true,
|
|
949
|
+
done: input.done === true,
|
|
950
|
+
})) {
|
|
951
|
+
blocks.push({ kind: 'deliver', reason: 'final-reply' })
|
|
952
|
+
}
|
|
953
|
+
// interim ack — neither delivery nor undelivered prose.
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
let lastDeliverIdx = -1
|
|
958
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
959
|
+
if (blocks[i].kind === 'deliver') lastDeliverIdx = i
|
|
960
|
+
}
|
|
961
|
+
const trailing = blocks
|
|
962
|
+
.slice(lastDeliverIdx + 1)
|
|
963
|
+
.filter((b) => b.kind === 'text' && typeof b.text === 'string' && b.text.length > 0)
|
|
964
|
+
.map((b) => b.text)
|
|
965
|
+
|
|
966
|
+
const text = selectBridgePendingText(trailing)
|
|
967
|
+
if (text == null || text.trim().length < FINAL_ANSWER_MIN_CHARS) {
|
|
968
|
+
return { capture: false, reason: trailing.length === 0 ? 'no-trailing-prose' : 'below-floor' }
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const turnNonce = deriveTurnNonce({
|
|
972
|
+
chatId: envelope.chatId,
|
|
973
|
+
threadId: envelope.threadId,
|
|
974
|
+
messageId: envelope.messageId,
|
|
975
|
+
anchorTimestampMs: anchor.anchorTimestampMs,
|
|
976
|
+
anchorContent: anchor.anchorContent,
|
|
977
|
+
})
|
|
978
|
+
let source = envelope.source
|
|
979
|
+
if (source == null) {
|
|
980
|
+
if (anchor.degraded) source = 'unknown'
|
|
981
|
+
else if (/task-notification|task-id/.test(anchor.anchorContent)) source = 'task-notification'
|
|
982
|
+
else source = 'channel'
|
|
983
|
+
}
|
|
984
|
+
// F2: for an envelope-less record, stamp THIS session's own origin chat so the
|
|
985
|
+
// sweep can route it without a gateway-global fallback. For an envelope-
|
|
986
|
+
// bearing record the anchor chatId already routes it (origin is redundant).
|
|
987
|
+
const origin = envelope.chatId != null ? { chatId: null, threadId: null } : resolveSessionOriginChat(lines)
|
|
988
|
+
return {
|
|
989
|
+
capture: true,
|
|
990
|
+
text: text.trim(),
|
|
991
|
+
turnNonce,
|
|
992
|
+
chatId: envelope.chatId,
|
|
993
|
+
threadId: envelope.threadId ?? null,
|
|
994
|
+
source,
|
|
995
|
+
anchorContent: anchor.anchorContent,
|
|
996
|
+
originChatId: origin.chatId,
|
|
997
|
+
originThreadId: origin.threadId,
|
|
998
|
+
}
|
|
999
|
+
}
|