switchroom 0.19.12 → 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
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* temporal-normalize.ts — deterministic outbound temporal normalization (#3501).
|
|
3
|
+
*
|
|
4
|
+
* Two independent, idempotent rewrites applied to outbound Telegram prose, in
|
|
5
|
+
* the shared `normalizeOutboundBody` seam (after redact/punctuation/bold,
|
|
6
|
+
* before the voice scrub):
|
|
7
|
+
*
|
|
8
|
+
* 1. UTC/Zulu datetimes → the agent's LOCAL wall clock. A raw
|
|
9
|
+
* `2026-07-23T09:15:00Z` (or `…+00:00`, or a `14:00 UTC` / `2 pm GMT`
|
|
10
|
+
* label) is rewritten to `Thu 23 Jul 7:15 pm AEST` — DST-correct by
|
|
11
|
+
* construction (Intl resolves the per-instant offset) — so the operator
|
|
12
|
+
* never has to translate UTC in their head, and the model can never re-read
|
|
13
|
+
* its own outbound as "now in UTC".
|
|
14
|
+
*
|
|
15
|
+
* 2. Relative-day accuracy. A relative day word (today / tomorrow / yesterday /
|
|
16
|
+
* tonight / this morning|afternoon|evening) written ADJACENT to an absolute
|
|
17
|
+
* date is checked against the current date in the agent's configured TZ and
|
|
18
|
+
* corrected if wrong. The incident: a cron notification said "closed
|
|
19
|
+
* tomorrow (Thu 23 Jul)" when today WAS Thursday 23 Jul — it should read
|
|
20
|
+
* "closed today (Thu 23 Jul)". A relative word with NO adjacent absolute
|
|
21
|
+
* date is left untouched.
|
|
22
|
+
*
|
|
23
|
+
* Design constraints (all enforced here):
|
|
24
|
+
* - Pure over its arguments — `tz` and `nowMs` are passed in (callers use
|
|
25
|
+
* `resolveEnvTimezone()` and `Date.now()`); NO env / clock read here.
|
|
26
|
+
* - Never-throw: a bad/unresolvable IANA `tz` degrades to the INPUT unchanged
|
|
27
|
+
* rather than crashing a turn (mirrors `fmtLocalStamp`'s contract).
|
|
28
|
+
* - Intl-only: ZERO date libraries — reuses `localDay` / `tzAbbrev` from
|
|
29
|
+
* shared/local-time.ts.
|
|
30
|
+
* - False-positive guard: code fences, inline code, markdown link hrefs, and
|
|
31
|
+
* angle-bracket autolinks are masked before either pass, so an ISO string in
|
|
32
|
+
* a log / URL / code span is never rewritten. Blockquote (`>`) lines are
|
|
33
|
+
* excluded too — forwarded/quoted third-party text is left verbatim.
|
|
34
|
+
* - Idempotent: the UTC output carries no Z/UTC/GMT token (the detector can't
|
|
35
|
+
* re-match it); the relative-day output is a fixed point once the word and
|
|
36
|
+
* date agree.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { localDay, tzAbbrev } from './shared/local-time.js'
|
|
40
|
+
|
|
41
|
+
// ── Combined false-positive masker ─────────────────────────────────────────
|
|
42
|
+
// Covers the SAME regions normalizePunctuation protects (fenced blocks, inline
|
|
43
|
+
// code, `](href)` link destinations, `<scheme:…>` autolinks) — lifted here as a
|
|
44
|
+
// single shared masker because `maskCodeRegions` (format.ts) alone does NOT
|
|
45
|
+
// cover link hrefs / autolinks (that logic lives inline inside normalizePunctuation).
|
|
46
|
+
interface MaskedRegions {
|
|
47
|
+
masked: string
|
|
48
|
+
restore: (s: string) => string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function maskTemporalRegions(text: string): MaskedRegions {
|
|
52
|
+
const nonce = Math.random().toString(36).slice(2)
|
|
53
|
+
const PH = `\x00TN${nonce}_`
|
|
54
|
+
const masks: string[] = []
|
|
55
|
+
const push = (s: string): string => {
|
|
56
|
+
masks.push(s)
|
|
57
|
+
return `${PH}${masks.length - 1}\x00`
|
|
58
|
+
}
|
|
59
|
+
const masked = text
|
|
60
|
+
// Fenced blocks first, only when CLOSED (matching ```), so an unclosed
|
|
61
|
+
// fence is left intact rather than misparsed by the inline pass.
|
|
62
|
+
.replace(/```[\s\S]*?```/g, (m) => push(m))
|
|
63
|
+
// Inline code spans.
|
|
64
|
+
.replace(/`[^`\n]+`/g, (m) => push(m))
|
|
65
|
+
// Markdown inline-link DESTINATIONS `](href)` — only the href inside the
|
|
66
|
+
// parens is masked; the visible label still normalizes like prose.
|
|
67
|
+
.replace(/(\]\()([^)\n]*)(\))/g, (_m, open: string, href: string, close: string) =>
|
|
68
|
+
`${open}${push(href)}${close}`,
|
|
69
|
+
)
|
|
70
|
+
// GFM angle-bracket autolink destinations `<scheme:…>`.
|
|
71
|
+
.replace(/(<)([a-zA-Z][a-zA-Z0-9+.-]*:[^>\s]*)(>)/g, (_m, lt: string, uri: string, gt: string) =>
|
|
72
|
+
`${lt}${push(uri)}${gt}`,
|
|
73
|
+
)
|
|
74
|
+
const esc = nonce.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
75
|
+
const re = new RegExp(`\x00TN${esc}_(\\d+)\x00`, 'g')
|
|
76
|
+
const restore = (s: string): string => s.replace(re, (_m, idx: string) => masks[Number(idx)] ?? _m)
|
|
77
|
+
return { masked, restore }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A blockquote line (mirrors format.ts `isBlockquoteLine`). */
|
|
81
|
+
function isBlockquoteLine(line: string): boolean {
|
|
82
|
+
return line.trimStart().startsWith('>')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Run a per-line transform on masked text, leaving blockquote lines verbatim. */
|
|
86
|
+
function perNonQuoteLine(masked: string, fn: (line: string) => string): string {
|
|
87
|
+
return masked
|
|
88
|
+
.split('\n')
|
|
89
|
+
.map((line) => (isBlockquoteLine(line) ? line : fn(line)))
|
|
90
|
+
.join('\n')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Local rendering ─────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/** `Thu 23 Jul` — weekday + day + short month in `tz`, no locale punctuation. */
|
|
96
|
+
function renderLocalDate(ms: number, tz: string): string {
|
|
97
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
98
|
+
timeZone: tz,
|
|
99
|
+
weekday: 'short',
|
|
100
|
+
day: 'numeric',
|
|
101
|
+
month: 'short',
|
|
102
|
+
}).formatToParts(new Date(ms))
|
|
103
|
+
const get = (t: string): string => parts.find((p) => p.type === t)?.value ?? ''
|
|
104
|
+
return `${get('weekday')} ${get('day')} ${get('month')}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `7:15 pm` — spaced, lowercased am/pm wall clock in `tz`. */
|
|
108
|
+
function renderLocalClock(ms: number, tz: string): string {
|
|
109
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
110
|
+
timeZone: tz,
|
|
111
|
+
hour: 'numeric',
|
|
112
|
+
minute: '2-digit',
|
|
113
|
+
hour12: true,
|
|
114
|
+
})
|
|
115
|
+
.format(new Date(ms))
|
|
116
|
+
.replace(/\s([AP])M$/, (_m, p: string) => ` ${p.toLowerCase()}m`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `Thu 23 Jul 7:15 pm AEST` — full local datetime for a converted instant. */
|
|
120
|
+
function renderLocalDatetime(ms: number, tz: string): string {
|
|
121
|
+
return `${renderLocalDate(ms, tz)} ${renderLocalClock(ms, tz)} ${tzAbbrev(ms, tz)}`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Pass 1: UTC / Zulu datetime → local ─────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
// Full ISO-8601 with a Z or +00:00 zero-offset. Seconds + fractional seconds
|
|
127
|
+
// optional. The trailing marker guarantees the output (which carries none)
|
|
128
|
+
// cannot re-match — idempotent by construction.
|
|
129
|
+
const ISO_UTC_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|\+00:00)/g
|
|
130
|
+
|
|
131
|
+
// Labelled clock time tagged UTC/GMT. The minutes / am|pm are OPTIONAL in the
|
|
132
|
+
// pattern but the replacer REQUIRES at least one of them (colon-minutes OR
|
|
133
|
+
// am/pm) before converting — so a bare "5 GMT" (as in "9 to 5 GMT") is left
|
|
134
|
+
// untouched, matching the tightened grammar.
|
|
135
|
+
const LABELLED_UTC_RE = /\b(\d{1,2})(?::(\d{2}))?\s?(am|pm)?\s?(UTC|GMT)\b/gi
|
|
136
|
+
|
|
137
|
+
function convertUtcInLine(line: string, tz: string, nowMs: number): string {
|
|
138
|
+
let out = line.replace(ISO_UTC_RE, (m) => {
|
|
139
|
+
const ms = Date.parse(m)
|
|
140
|
+
if (Number.isNaN(ms)) return m
|
|
141
|
+
return renderLocalDatetime(ms, tz)
|
|
142
|
+
})
|
|
143
|
+
out = out.replace(LABELLED_UTC_RE, (m, hh: string, mm: string | undefined, ampm: string | undefined) => {
|
|
144
|
+
// Tightened grammar: require colon-minutes OR am/pm — reject bare "5 GMT".
|
|
145
|
+
if (mm == null && ampm == null) return m
|
|
146
|
+
let h = Number(hh)
|
|
147
|
+
const pm = ampm != null && /pm/i.test(ampm)
|
|
148
|
+
if (ampm != null) {
|
|
149
|
+
if (h > 12) return m // "14 pm" is nonsensical — leave untouched
|
|
150
|
+
if (h === 12) h = pm ? 12 : 0
|
|
151
|
+
else if (pm) h += 12
|
|
152
|
+
}
|
|
153
|
+
if (h > 23) return m
|
|
154
|
+
const minute = mm != null ? Number(mm) : 0
|
|
155
|
+
if (minute > 59) return m
|
|
156
|
+
const now = new Date(nowMs)
|
|
157
|
+
// Interpret the labelled time as UTC on nowMs's UTC calendar date, then
|
|
158
|
+
// render the LOCAL wall clock. No date is emitted (the source carried none).
|
|
159
|
+
const ms = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), h, minute)
|
|
160
|
+
return `${renderLocalClock(ms, tz)} ${tzAbbrev(ms, tz)}`
|
|
161
|
+
})
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Rewrite UTC/Zulu datetimes in `text` to the local wall clock in `tz`.
|
|
167
|
+
* Pure / never-throws: an invalid `tz` (or any internal failure) returns the
|
|
168
|
+
* input unchanged.
|
|
169
|
+
*/
|
|
170
|
+
export function normalizeUtcDatetimes(text: string, tz: string, nowMs: number): string {
|
|
171
|
+
if (!text) return text
|
|
172
|
+
try {
|
|
173
|
+
// Fast-path: nothing that could be a UTC datetime.
|
|
174
|
+
if (!/\d{4}-\d{2}-\d{2}T|\b(?:UTC|GMT)\b/i.test(text)) return text
|
|
175
|
+
const { masked, restore } = maskTemporalRegions(text)
|
|
176
|
+
const out = perNonQuoteLine(masked, (line) => convertUtcInLine(line, tz, nowMs))
|
|
177
|
+
return restore(out)
|
|
178
|
+
} catch {
|
|
179
|
+
return text
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Pass 2: relative-day accuracy ───────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
const REL = '(today|tonight|this\\s+morning|this\\s+afternoon|this\\s+evening|tomorrow|yesterday)'
|
|
186
|
+
// A small, strict separator set: whitespace / commas, an optional "on ", and an
|
|
187
|
+
// optional opening paren. Bounded (only spaces/commas), so the effective window
|
|
188
|
+
// stays small — no `.{0,15}` wildcard that could swallow arbitrary prose.
|
|
189
|
+
const SEP = '([\\s,]*(?:on\\s+)?\\(?\\s*)'
|
|
190
|
+
const WD = '(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*'
|
|
191
|
+
const MON = '(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*'
|
|
192
|
+
// One optional weekday token (captured, with its trailing separator), then one
|
|
193
|
+
// absolute-date core (captured whole; re-parsed in the replacer).
|
|
194
|
+
const WD_GROUP = `((?:${WD})[\\s,]+)?`
|
|
195
|
+
const DATE_CORE =
|
|
196
|
+
`((?:\\d{1,2}\\s+${MON})|(?:${MON}\\s+\\d{1,2})|(?:\\d{4}-\\d{2}-\\d{2})|(?:\\d{1,2}\\/\\d{2}))`
|
|
197
|
+
|
|
198
|
+
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
|
|
199
|
+
const WEEKDAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
200
|
+
|
|
201
|
+
function buildRelRegex(): RegExp {
|
|
202
|
+
return new RegExp(REL + SEP + WD_GROUP + DATE_CORE, 'gi')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
interface ParsedDate {
|
|
206
|
+
year: number | null
|
|
207
|
+
month: number // 1-12
|
|
208
|
+
day: number
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Parse a matched absolute-date core into {year?, month, day}, or null. */
|
|
212
|
+
function parseDateCore(core: string): ParsedDate | null {
|
|
213
|
+
let m: RegExpExecArray | null
|
|
214
|
+
if ((m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(core))) {
|
|
215
|
+
return { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) }
|
|
216
|
+
}
|
|
217
|
+
if ((m = /^(\d{1,2})\/(\d{2})$/.exec(core))) {
|
|
218
|
+
// DD/MM (fleet convention — matches the "23/07" nice-to-have).
|
|
219
|
+
return { year: null, month: Number(m[2]), day: Number(m[1]) }
|
|
220
|
+
}
|
|
221
|
+
if ((m = /^(\d{1,2})\s+([a-z]+)$/i.exec(core))) {
|
|
222
|
+
const mon = MONTHS.indexOf(m[2].slice(0, 3).toLowerCase())
|
|
223
|
+
if (mon < 0) return null
|
|
224
|
+
return { year: null, month: mon + 1, day: Number(m[1]) }
|
|
225
|
+
}
|
|
226
|
+
if ((m = /^([a-z]+)\s+(\d{1,2})$/i.exec(core))) {
|
|
227
|
+
const mon = MONTHS.indexOf(m[1].slice(0, 3).toLowerCase())
|
|
228
|
+
if (mon < 0) return null
|
|
229
|
+
return { year: null, month: mon + 1, day: Number(m[2]) }
|
|
230
|
+
}
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** UTC-midnight day index for a Y-M-D (calendar-day math, DST-irrelevant). */
|
|
235
|
+
function dayIndex(y: number, mo1: number, d: number): number {
|
|
236
|
+
return Math.floor(Date.UTC(y, mo1 - 1, d) / 86_400_000)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Resolve a possibly-yearless date to the occurrence nearest today-in-tz. */
|
|
240
|
+
function resolveYear(parsed: ParsedDate, todayY: number, todayIdx: number): number {
|
|
241
|
+
if (parsed.year != null) return parsed.year
|
|
242
|
+
let best = todayY
|
|
243
|
+
let bestAbs = Infinity
|
|
244
|
+
for (const y of [todayY - 1, todayY, todayY + 1]) {
|
|
245
|
+
const diff = Math.abs(dayIndex(y, parsed.month, parsed.day) - todayIdx)
|
|
246
|
+
if (diff < bestAbs) {
|
|
247
|
+
bestAbs = diff
|
|
248
|
+
best = y
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return best
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Preserve the original casing pattern of a rewritten day word. */
|
|
255
|
+
function matchCase(sample: string, replacement: string): string {
|
|
256
|
+
if (sample.length > 0 && sample[0] === sample[0].toUpperCase() && sample[0] !== sample[0].toLowerCase()) {
|
|
257
|
+
return replacement.charAt(0).toUpperCase() + replacement.slice(1)
|
|
258
|
+
}
|
|
259
|
+
return replacement
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const TIME_OF_DAY_RE = /^(tonight|this\s+)/i
|
|
263
|
+
|
|
264
|
+
function rewriteRelativeInLine(line: string, tz: string, nowMs: number): string {
|
|
265
|
+
const today = localDay(nowMs, tz) // YYYY-MM-DD (throws first on a bad tz)
|
|
266
|
+
const [ty, tm, td] = today.split('-').map(Number)
|
|
267
|
+
const todayIdx = dayIndex(ty, tm, td)
|
|
268
|
+
|
|
269
|
+
return line.replace(buildRelRegex(), (full, rel: string, sep: string, wdGroup: string | undefined, core: string) => {
|
|
270
|
+
const parsed = parseDateCore(core)
|
|
271
|
+
if (parsed == null) return full
|
|
272
|
+
const year = resolveYear(parsed, ty, todayIdx)
|
|
273
|
+
const targetIdx = dayIndex(year, parsed.month, parsed.day)
|
|
274
|
+
const diff = targetIdx - todayIdx
|
|
275
|
+
|
|
276
|
+
// Out-of-range (|diff| > 1): leave the ENTIRE match untouched — do NOT
|
|
277
|
+
// invent a word and do NOT auto-delete (open question 3 resolution).
|
|
278
|
+
if (diff < -1 || diff > 1) return full
|
|
279
|
+
|
|
280
|
+
const isTimeOfDay = TIME_OF_DAY_RE.test(rel)
|
|
281
|
+
|
|
282
|
+
// Determine the corrected relative word.
|
|
283
|
+
let newRel = rel
|
|
284
|
+
if (isTimeOfDay) {
|
|
285
|
+
// tonight / this morning|afternoon|evening are a correct SUBSET of today.
|
|
286
|
+
// Only valid when the date resolves to today (diff==0); never remap them
|
|
287
|
+
// to bare tomorrow/yesterday. Otherwise leave the whole match untouched.
|
|
288
|
+
if (diff !== 0) return full
|
|
289
|
+
// diff==0: the word is already correct — keep it verbatim.
|
|
290
|
+
} else {
|
|
291
|
+
const want = diff === 1 ? 'tomorrow' : diff === 0 ? 'today' : 'yesterday'
|
|
292
|
+
newRel = matchCase(rel, want)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Weekday repair: if a weekday token precedes the date and disagrees with
|
|
296
|
+
// the true weekday of the resolved date in-tz, correct it.
|
|
297
|
+
let newWdGroup = wdGroup
|
|
298
|
+
if (wdGroup != null) {
|
|
299
|
+
const trueDow = new Date(Date.UTC(year, parsed.month - 1, parsed.day)).getUTCDay()
|
|
300
|
+
const trueShort = WEEKDAY_SHORT[trueDow]
|
|
301
|
+
// Preserve the original separator tail after the weekday token.
|
|
302
|
+
const wdMatch = /^([a-z]+)([\s,]+)$/i.exec(wdGroup)
|
|
303
|
+
if (wdMatch != null) {
|
|
304
|
+
const writtenShort = wdMatch[1].slice(0, 3).toLowerCase()
|
|
305
|
+
if (writtenShort !== trueShort.toLowerCase()) {
|
|
306
|
+
const wasLong = wdMatch[1].length > 3
|
|
307
|
+
const corrected = wasLong ? longWeekday(trueDow) : trueShort
|
|
308
|
+
newWdGroup = matchCase(wdMatch[1], corrected) + wdMatch[2]
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return `${newRel}${sep}${newWdGroup ?? ''}${core}`
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const WEEKDAY_LONG = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
|
318
|
+
function longWeekday(dow: number): string {
|
|
319
|
+
return WEEKDAY_LONG[dow]
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Correct relative-day words that are wrong for the absolute date they sit next
|
|
324
|
+
* to, resolved against `nowMs` in `tz`. Pure / never-throws.
|
|
325
|
+
*/
|
|
326
|
+
export function normalizeRelativeDays(text: string, tz: string, nowMs: number): string {
|
|
327
|
+
if (!text) return text
|
|
328
|
+
try {
|
|
329
|
+
if (!/\b(today|tonight|this\s+(?:morning|afternoon|evening)|tomorrow|yesterday)\b/i.test(text)) {
|
|
330
|
+
return text
|
|
331
|
+
}
|
|
332
|
+
const { masked, restore } = maskTemporalRegions(text)
|
|
333
|
+
const out = perNonQuoteLine(masked, (line) => rewriteRelativeInLine(line, tz, nowMs))
|
|
334
|
+
return restore(out)
|
|
335
|
+
} catch {
|
|
336
|
+
return text
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Convenience composition: UTC→local FIRST (so it produces absolute local
|
|
342
|
+
* datetimes the relative-day pass can then check), THEN relative-day accuracy.
|
|
343
|
+
* Pure / never-throws — each sub-pass degrades to its input on failure.
|
|
344
|
+
*/
|
|
345
|
+
export function normalizeTemporal(text: string, tz: string, nowMs: number): string {
|
|
346
|
+
return normalizeRelativeDays(normalizeUtcDatetimes(text, tz, nowMs), tz, nowMs)
|
|
347
|
+
}
|
|
@@ -53,29 +53,39 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
|
|
|
53
53
|
// delegates via `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`
|
|
54
54
|
// — the injected redactor still runs at entry, before the stderr preview.
|
|
55
55
|
const start = sendPathSrc.indexOf('export async function sendReply(')
|
|
56
|
-
const redactIdx = sendPathSrc.indexOf(`normalizeOutboundBody(rawText, 'reply', redactOutboundText
|
|
56
|
+
const redactIdx = sendPathSrc.indexOf(`normalizeOutboundBody(rawText, 'reply', redactOutboundText`, start)
|
|
57
57
|
const previewIdx = sendPathSrc.indexOf('reply: invoked chatId=', start)
|
|
58
58
|
expect(start).toBeGreaterThan(0)
|
|
59
59
|
expect(redactIdx).toBeGreaterThan(start)
|
|
60
60
|
expect(previewIdx).toBeGreaterThan(redactIdx) // mask BEFORE the preview is logged
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
-
it('edit_message: scrubs at entry, before the
|
|
63
|
+
it('edit_message: scrubs via the shared seam at entry, before the send (#3501)', () => {
|
|
64
|
+
// #3501: edit_message routes through normalizeOutboundBody, passing the
|
|
65
|
+
// injected redactor with the 'edit_message' site. The seam runs redact
|
|
66
|
+
// internally (pinned in outbound-send-path.ts), so the mask still fires at
|
|
67
|
+
// entry — before the editMessageText send below.
|
|
64
68
|
const start = src.indexOf('async function executeEditMessage(')
|
|
65
|
-
const
|
|
66
|
-
const
|
|
69
|
+
const seamIdx = src.indexOf(`'edit_message',`, start)
|
|
70
|
+
const redactArgIdx = src.indexOf('redactOutboundText,', seamIdx)
|
|
71
|
+
const sendIdx = src.indexOf('editMessageText(', start)
|
|
67
72
|
expect(start).toBeGreaterThan(0)
|
|
68
|
-
expect(
|
|
69
|
-
expect(
|
|
73
|
+
expect(seamIdx).toBeGreaterThan(start)
|
|
74
|
+
expect(redactArgIdx).toBeGreaterThan(seamIdx) // redactor passed into the seam
|
|
75
|
+
expect(sendIdx).toBeGreaterThan(redactArgIdx) // mask BEFORE the send
|
|
70
76
|
})
|
|
71
77
|
|
|
72
|
-
it('turn-flush backstop: scrubs the model terminal prose before send', () => {
|
|
78
|
+
it('turn-flush backstop: scrubs the model terminal prose before send (#3501)', () => {
|
|
73
79
|
// Turn-flush delivers the model's answer when it skipped reply/stream_reply
|
|
74
|
-
// — arbitrary agent free-text that hits the wire + stderr preview.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
80
|
+
// — arbitrary agent free-text that hits the wire + stderr preview. #3501:
|
|
81
|
+
// it routes through normalizeOutboundBody('turn_flush', redactOutboundText),
|
|
82
|
+
// which redacts internally before the voice-scrub + send.
|
|
83
|
+
const seamIdx = streamSrc.indexOf('normalizeOutboundBody(')
|
|
84
|
+
const siteIdx = streamSrc.indexOf(`'turn_flush',`, seamIdx)
|
|
85
|
+
const redactArgIdx = streamSrc.indexOf('redactOutboundText,', seamIdx)
|
|
86
|
+
expect(seamIdx).toBeGreaterThan(0)
|
|
87
|
+
expect(siteIdx).toBeGreaterThan(seamIdx) // seam invoked with the turn_flush site
|
|
88
|
+
expect(redactArgIdx).toBeGreaterThan(seamIdx) // redactor passed into the seam
|
|
79
89
|
})
|
|
80
90
|
|
|
81
91
|
it('progress_update: scrubs at entry, BEFORE the 300-char truncation', () => {
|
|
@@ -79,6 +79,46 @@ function referenceResplit(chunk: string): string[] {
|
|
|
79
79
|
|
|
80
80
|
const injectedRedact = (text: string, _site: string): string => redact(text)
|
|
81
81
|
|
|
82
|
+
// ── Verbatim inline reference for the FORMER edit_message pipeline ──────────
|
|
83
|
+
// (executeEditMessage, pre-#3501): repair → [paragraph-break if !literal] →
|
|
84
|
+
// redact → [addParagraphSpacers(stripExcessBold(normalizePunctuation)) if
|
|
85
|
+
// !literal] → voice scrub. Consolidated into normalizeOutboundBody's
|
|
86
|
+
// {literalText, addSpacers} options; this reference pins byte-equivalence.
|
|
87
|
+
function referenceEditNormalize(
|
|
88
|
+
rawText: string,
|
|
89
|
+
literalText: boolean,
|
|
90
|
+
): { text: string; voiceReplaced: number } {
|
|
91
|
+
let editRawText = repairEscapedWhitespace(rawText)
|
|
92
|
+
if (!literalText) editRawText = normalizeParagraphBreaks(editRawText)
|
|
93
|
+
editRawText = redact(editRawText)
|
|
94
|
+
if (!literalText) editRawText = addParagraphSpacers(stripExcessBold(normalizePunctuation(editRawText)))
|
|
95
|
+
let voiceReplaced = 0
|
|
96
|
+
const scrub = scrubVoice(editRawText)
|
|
97
|
+
if (scrub.replaced > 0) {
|
|
98
|
+
editRawText = scrub.scrubbed
|
|
99
|
+
voiceReplaced = scrub.replaced
|
|
100
|
+
}
|
|
101
|
+
return { text: editRawText, voiceReplaced }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Verbatim inline reference for the FORMER turn-flush pipeline ────────────
|
|
105
|
+
// (stream-render.ts turn-flush branch, pre-#3501): normalizeParagraphBreaks(
|
|
106
|
+
// repairEscapedWhitespace) → redact('turn_flush') → stripExcessBold(
|
|
107
|
+
// normalizePunctuation) → voice scrub. This is normalizeOutboundBody's DEFAULT
|
|
108
|
+
// shape — identical to the reply pipeline. Consolidated to a single call.
|
|
109
|
+
function referenceTurnFlushNormalize(rawText: string): { text: string; voiceReplaced: number } {
|
|
110
|
+
let text = normalizeParagraphBreaks(repairEscapedWhitespace(rawText))
|
|
111
|
+
text = redact(text)
|
|
112
|
+
text = stripExcessBold(normalizePunctuation(text))
|
|
113
|
+
let voiceReplaced = 0
|
|
114
|
+
const scrub = scrubVoice(text)
|
|
115
|
+
if (scrub.replaced > 0) {
|
|
116
|
+
text = scrub.scrubbed
|
|
117
|
+
voiceReplaced = scrub.replaced
|
|
118
|
+
}
|
|
119
|
+
return { text, voiceReplaced }
|
|
120
|
+
}
|
|
121
|
+
|
|
82
122
|
// ── Representative outbound fixtures ────────────────────────────────────────
|
|
83
123
|
|
|
84
124
|
const FIXTURES: Record<string, string> = {
|
|
@@ -121,6 +161,73 @@ describe('outbound-send-path — normalizeOutboundBody parity with inline pipeli
|
|
|
121
161
|
})
|
|
122
162
|
})
|
|
123
163
|
|
|
164
|
+
// #3501 — the edit_message and turn-flush sites were hand-mirrored inline
|
|
165
|
+
// pipelines that now route through normalizeOutboundBody. These pin that the
|
|
166
|
+
// {literalText, addSpacers} options reproduce the former inline output
|
|
167
|
+
// byte-for-byte, so the consolidation is provably behaviour-neutral.
|
|
168
|
+
describe('outbound-send-path — edit_message option parity (#3501)', () => {
|
|
169
|
+
for (const [name, raw] of Object.entries(FIXTURES)) {
|
|
170
|
+
it(`rich edit (addSpacers) byte-identical: ${name}`, () => {
|
|
171
|
+
const ref = referenceEditNormalize(raw, false)
|
|
172
|
+
const got = normalizeOutboundBody(raw, 'edit_message', injectedRedact, {
|
|
173
|
+
literalText: false,
|
|
174
|
+
addSpacers: true,
|
|
175
|
+
})
|
|
176
|
+
expect(got.text).toBe(ref.text)
|
|
177
|
+
expect(got.voiceReplaced).toBe(ref.voiceReplaced)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it(`literal edit (literalText) byte-identical: ${name}`, () => {
|
|
181
|
+
const ref = referenceEditNormalize(raw, true)
|
|
182
|
+
const got = normalizeOutboundBody(raw, 'edit_message', injectedRedact, {
|
|
183
|
+
literalText: true,
|
|
184
|
+
addSpacers: false,
|
|
185
|
+
})
|
|
186
|
+
expect(got.text).toBe(ref.text)
|
|
187
|
+
expect(got.voiceReplaced).toBe(ref.voiceReplaced)
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
it('literal edit skips paragraph/punctuation/bold/spacer formatting', () => {
|
|
192
|
+
// A literal edit must NOT promote lone newlines, strip bold, or add
|
|
193
|
+
// spacers — only repair + redact + voice scrub run.
|
|
194
|
+
const raw = 'First line.\nSecond line with **bold** kept.'
|
|
195
|
+
const got = normalizeOutboundBody(raw, 'edit_message', injectedRedact, {
|
|
196
|
+
literalText: true,
|
|
197
|
+
})
|
|
198
|
+
expect(got.text).toBe(raw) // no transform touched it
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('rich edit DOES add the U+00A0 paragraph spacer that reply omits', () => {
|
|
202
|
+
const raw = FIXTURES.multiParagraph
|
|
203
|
+
const richEdit = normalizeOutboundBody(raw, 'edit_message', injectedRedact, {
|
|
204
|
+
addSpacers: true,
|
|
205
|
+
}).text
|
|
206
|
+
const reply = normalizeOutboundBody(raw, 'reply', injectedRedact).text
|
|
207
|
+
// The edit path folds addParagraphSpacers in; the reply path does not.
|
|
208
|
+
expect(richEdit).toBe(addParagraphSpacers(reply))
|
|
209
|
+
expect(richEdit).not.toBe(reply)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('outbound-send-path — turn_flush option parity (#3501)', () => {
|
|
214
|
+
for (const [name, raw] of Object.entries(FIXTURES)) {
|
|
215
|
+
it(`turn-flush default byte-identical: ${name}`, () => {
|
|
216
|
+
const ref = referenceTurnFlushNormalize(raw)
|
|
217
|
+
const got = normalizeOutboundBody(raw, 'turn_flush', injectedRedact)
|
|
218
|
+
expect(got.text).toBe(ref.text)
|
|
219
|
+
expect(got.voiceReplaced).toBe(ref.voiceReplaced)
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
it('turn-flush default equals the reply pipeline (same shape)', () => {
|
|
224
|
+
const raw = FIXTURES.emDashes
|
|
225
|
+
const flush = normalizeOutboundBody(raw, 'turn_flush', injectedRedact).text
|
|
226
|
+
const reply = normalizeOutboundBody(raw, 'reply', injectedRedact).text
|
|
227
|
+
expect(flush).toBe(reply)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
124
231
|
describe('outbound-send-path — effective text + chunk parity', () => {
|
|
125
232
|
for (const [name, raw] of Object.entries(FIXTURES)) {
|
|
126
233
|
for (const literalText of [true, false]) {
|
|
@@ -222,3 +329,87 @@ describe('outbound-send-path — cross-surface dedup suppression', () => {
|
|
|
222
329
|
expect(dedup.check(chatId, undefined, text, t0 + 500, 'turn-2')).toBeNull()
|
|
223
330
|
})
|
|
224
331
|
})
|
|
332
|
+
|
|
333
|
+
// ── #3501 temporal-normalization wiring into the shared seam ────────────────
|
|
334
|
+
import { readFileSync } from 'node:fs'
|
|
335
|
+
import { normalizeTemporal } from '../temporal-normalize.js'
|
|
336
|
+
|
|
337
|
+
const TZ_MEL = 'Australia/Melbourne'
|
|
338
|
+
const NOW_THU = Date.UTC(2026, 6, 23, 2, 0, 0) // Thu 2026-07-23 midday Melbourne
|
|
339
|
+
|
|
340
|
+
describe('outbound-send-path — temporal pass wiring (#3501)', () => {
|
|
341
|
+
it('fires when tz+nowMs are supplied — fixes the cron/mail-watcher incident', () => {
|
|
342
|
+
const incident = 'Heads up: the office is closed tomorrow (Thu 23 Jul).'
|
|
343
|
+
const got = normalizeOutboundBody(incident, 'turn_flush', injectedRedact, {
|
|
344
|
+
tz: TZ_MEL,
|
|
345
|
+
nowMs: NOW_THU,
|
|
346
|
+
})
|
|
347
|
+
expect(got.text).toBe('Heads up: the office is closed today (Thu 23 Jul).')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('is a NO-OP when tz is omitted (keeps the seam pure/clock-free by default)', () => {
|
|
351
|
+
const incident = 'closed tomorrow (Thu 23 Jul)'
|
|
352
|
+
// No tz → temporal must not fire; output is the plain formatted pipeline.
|
|
353
|
+
const got = normalizeOutboundBody(incident, 'reply', injectedRedact)
|
|
354
|
+
expect(got.text).toBe(incident)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('REVIEW FIX 3 — a literal edit does NOT run the temporal pass', () => {
|
|
358
|
+
const incident = 'closed tomorrow (Thu 23 Jul)'
|
|
359
|
+
const got = normalizeOutboundBody(incident, 'edit_message', injectedRedact, {
|
|
360
|
+
literalText: true,
|
|
361
|
+
tz: TZ_MEL,
|
|
362
|
+
nowMs: NOW_THU,
|
|
363
|
+
})
|
|
364
|
+
// Literal edit lands byte-for-byte — the stale relative word is preserved.
|
|
365
|
+
expect(got.text).toBe(incident)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('golden pipeline ORDER — temporal output is not mangled by the voice scrub', () => {
|
|
369
|
+
// A UTC datetime is rewritten to "Thu 23 Jul 7:15 pm AEST"; the voice scrub
|
|
370
|
+
// (em/en dash → comma) that runs AFTER must not touch it.
|
|
371
|
+
const raw = 'window 2026-07-23T09:15:00Z opens'
|
|
372
|
+
const got = normalizeOutboundBody(raw, 'reply', injectedRedact, {
|
|
373
|
+
tz: TZ_MEL,
|
|
374
|
+
nowMs: NOW_THU,
|
|
375
|
+
})
|
|
376
|
+
expect(got.text).toBe('window Thu 23 Jul 7:15 pm AEST opens')
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('temporal runs AFTER punctuation/bold and BEFORE scrubVoice (structural pin)', () => {
|
|
380
|
+
const src = readFileSync(new URL('../gateway/outbound-send-path.ts', import.meta.url), 'utf8')
|
|
381
|
+
const start = src.indexOf('export function normalizeOutboundBody(')
|
|
382
|
+
const boldIdx = src.indexOf('stripExcessBold(normalizePunctuation(text))', start)
|
|
383
|
+
const temporalIdx = src.indexOf('normalizeTemporal(text, tz, nowMs)', start)
|
|
384
|
+
const scrubIdx = src.indexOf('scrubVoice(text)', start)
|
|
385
|
+
expect(boldIdx).toBeGreaterThan(start)
|
|
386
|
+
expect(temporalIdx).toBeGreaterThan(boldIdx) // AFTER punctuation/bold
|
|
387
|
+
expect(scrubIdx).toBeGreaterThan(temporalIdx) // BEFORE the voice scrub
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('per-site wiring pin — all three prose sends pass tz+nowMs into the seam', () => {
|
|
391
|
+
const sendPath = readFileSync(new URL('../gateway/outbound-send-path.ts', import.meta.url), 'utf8')
|
|
392
|
+
const gateway = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
|
|
393
|
+
const streamRender = readFileSync(new URL('../gateway/stream-render.ts', import.meta.url), 'utf8')
|
|
394
|
+
// Reply site (sendReply) passes tz+nowMs.
|
|
395
|
+
const replyStart = sendPath.indexOf(`normalizeOutboundBody(rawText, 'reply', redactOutboundText`)
|
|
396
|
+
expect(replyStart).toBeGreaterThan(0)
|
|
397
|
+
expect(sendPath.indexOf('tz: resolveEnvTimezone()', replyStart)).toBeGreaterThan(replyStart)
|
|
398
|
+
// edit_message site passes tz+nowMs.
|
|
399
|
+
const editStart = gateway.indexOf(`'edit_message',`)
|
|
400
|
+
expect(gateway.indexOf('tz: resolveEnvTimezone()', editStart)).toBeGreaterThan(editStart)
|
|
401
|
+
// turn-flush site passes tz+nowMs.
|
|
402
|
+
const flushStart = streamRender.indexOf(`'turn_flush',`)
|
|
403
|
+
expect(streamRender.indexOf('tz: resolveEnvTimezone()', flushStart)).toBeGreaterThan(flushStart)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('temporal wiring matches the standalone module output (no drift)', () => {
|
|
407
|
+
const raw = 'ships tomorrow (Thu 23 Jul), at 2026-07-23T09:15:00Z'
|
|
408
|
+
const viaSeam = normalizeOutboundBody(raw, 'reply', injectedRedact, {
|
|
409
|
+
tz: TZ_MEL,
|
|
410
|
+
nowMs: NOW_THU,
|
|
411
|
+
}).text
|
|
412
|
+
const viaModule = normalizeTemporal(raw, TZ_MEL, NOW_THU)
|
|
413
|
+
expect(viaSeam).toBe(viaModule)
|
|
414
|
+
})
|
|
415
|
+
})
|