switchroom 0.20.22 → 0.21.1
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 +1952 -1538
- package/dist/host-control/main.js +285 -121
- package/package.json +1 -1
- package/skills/switchroom-release/SKILL.md +12 -1
- package/telegram-plugin/dist/gateway/gateway.js +1551 -850
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +2 -2
- package/telegram-plugin/gateway/boot-beacon.ts +9 -2
- package/telegram-plugin/gateway/gateway-heartbeat.ts +4 -3
- package/telegram-plugin/gateway/gateway.ts +50 -15
- package/telegram-plugin/gateway/inbound-router.ts +31 -6
- package/telegram-plugin/gateway/missed-approvals-store.ts +2 -2
- package/telegram-plugin/gateway/pending-card-store.ts +2 -2
- package/telegram-plugin/gateway/privacy-state.ts +2 -2
- package/telegram-plugin/gateway/scoped-grant-store.ts +2 -2
- package/telegram-plugin/gateway/system-message-observer.ts +418 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +3 -4
- package/telegram-plugin/history.ts +178 -11
- package/telegram-plugin/registry/turns-schema.ts +8 -2
- package/telegram-plugin/shared/sent-text-capture.ts +191 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +12 -1
- package/telegram-plugin/tests/card-history-lane.test.ts +514 -0
- package/telegram-plugin/tests/sent-text-capture.test.ts +526 -0
- package/telegram-plugin/tests/system-message-observer.test.ts +225 -0
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
|
|
61
61
|
import { writeFileSync, unlinkSync } from 'node:fs'
|
|
62
62
|
import { join } from 'node:path'
|
|
63
|
-
import {
|
|
63
|
+
import { atomicWriteStateFileSync } from '../../src/util/state-owner.js'
|
|
64
64
|
import {
|
|
65
65
|
preserveUnreadableStoreFile,
|
|
66
66
|
quarantineCorruptStoreFile,
|
|
@@ -182,7 +182,7 @@ export function computeBackoffMs(attempts: number, retryAfterMs?: number): numbe
|
|
|
182
182
|
*/
|
|
183
183
|
export const atomicWriteSeam = ((path, data, opts) => {
|
|
184
184
|
const mode = typeof opts === 'object' && opts !== null && typeof opts.mode === 'number' ? opts.mode : 0o600
|
|
185
|
-
|
|
185
|
+
atomicWriteStateFileSync(path as string, data as string, mode)
|
|
186
186
|
}) as typeof writeFileSync
|
|
187
187
|
|
|
188
188
|
export function createAlwaysAllowPersistQueue(
|
|
@@ -68,7 +68,6 @@ import { randomUUID } from 'node:crypto'
|
|
|
68
68
|
import {
|
|
69
69
|
closeSync,
|
|
70
70
|
fsyncSync,
|
|
71
|
-
mkdirSync,
|
|
72
71
|
openSync,
|
|
73
72
|
readFileSync,
|
|
74
73
|
renameSync,
|
|
@@ -76,6 +75,7 @@ import {
|
|
|
76
75
|
writeFileSync,
|
|
77
76
|
} from 'node:fs'
|
|
78
77
|
import { join } from 'node:path'
|
|
78
|
+
import { adoptStateOwnershipFd, mkdirStateSync } from '../../src/util/state-owner.js'
|
|
79
79
|
|
|
80
80
|
/** Filename under `TELEGRAM_STATE_DIR`. */
|
|
81
81
|
export const BOOT_BEACON_FILE = 'gateway-beacon.json'
|
|
@@ -297,12 +297,19 @@ export function writeBootBeaconFile(stateDir: string, beacon: BootBeacon): boole
|
|
|
297
297
|
// gateway creates it 0o700 at boot. If it were ever missing here, a
|
|
298
298
|
// default-mode recreate by this 5s tick would silently loosen the
|
|
299
299
|
// permissions on that whole directory.
|
|
300
|
-
|
|
300
|
+
mkdirStateSync(stateDir, { recursive: true, mode: 0o700 })
|
|
301
301
|
fd = openSync(tmp, 'w', 0o600)
|
|
302
302
|
// writeFileSync (not writeSync) on the fd: it loops internally, so a short
|
|
303
303
|
// write can't leave a truncated beacon that we then fsync and rename into
|
|
304
304
|
// place as if it were whole.
|
|
305
305
|
writeFileSync(fd, serializeBootBeacon(beacon))
|
|
306
|
+
// Ownership on the TEMPFILE FD, before the rename: a root-running gateway
|
|
307
|
+
// re-creates this file every 5s, so it is the loudest source of root-owned
|
|
308
|
+
// state in an agent-owned dir. Doing it on the fd (never a path) means no
|
|
309
|
+
// symlink can be raced in, and doing it before the rename means the file
|
|
310
|
+
// is never visible at its final name under the wrong owner. No-op off the
|
|
311
|
+
// root path.
|
|
312
|
+
adoptStateOwnershipFd(fd, stateDir)
|
|
306
313
|
fsyncSync(fd)
|
|
307
314
|
closeSync(fd)
|
|
308
315
|
fd = undefined
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
* conservatively BLOCKS (re-prompt), which is the safe direction.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import {
|
|
24
|
+
import { utimesSync } from 'node:fs'
|
|
25
25
|
import { join } from 'node:path'
|
|
26
|
+
import { mkdirStateSync, writeStateFileSync } from '../../src/util/state-owner.js'
|
|
26
27
|
|
|
27
28
|
/** Filename under `TELEGRAM_STATE_DIR`. MUST stay in sync with
|
|
28
29
|
* `GATEWAY_HEARTBEAT_FILE` in `hooks/silent-end-scan.mjs`. */
|
|
@@ -47,8 +48,8 @@ export function touchGatewayHeartbeat(stateDir: string): void {
|
|
|
47
48
|
} catch {
|
|
48
49
|
// File doesn't exist yet (or unstattable) — create it.
|
|
49
50
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
mkdirStateSync(stateDir, { recursive: true })
|
|
52
|
+
writeStateFileSync(path, `${Date.now()}\n`, { mode: 0o600 })
|
|
52
53
|
} catch {
|
|
53
54
|
// Best-effort — a heartbeat write failure makes the hook BLOCK
|
|
54
55
|
// (re-prompt), which is the safe direction.
|
|
@@ -303,6 +303,7 @@ import { installEditFloodFuse, editFloodFuseConfigFromEnv } from '../edit-flood-
|
|
|
303
303
|
import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
|
|
304
304
|
import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
|
|
305
305
|
import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags } from '../shared/bot-runtime.js'
|
|
306
|
+
import { installSentTextCapture } from '../shared/sent-text-capture.js'
|
|
306
307
|
import {
|
|
307
308
|
floodStatePath,
|
|
308
309
|
floodWindowsPath,
|
|
@@ -395,7 +396,9 @@ import {
|
|
|
395
396
|
pruneMessagesOlderThanDays,
|
|
396
397
|
hasOutboundDeliveredSince,
|
|
397
398
|
hasOutboundWithText,
|
|
399
|
+
recordSystemOutbound, updateSystemOutboundText,
|
|
398
400
|
} from '../history.js'
|
|
401
|
+
import { makeSystemMessageObserver } from './system-message-observer.js'
|
|
399
402
|
import {
|
|
400
403
|
runRegistryReaper,
|
|
401
404
|
resolveRetentionDays as resolveRegistryRetentionDays,
|
|
@@ -658,7 +661,8 @@ import { createStatusPinApi, type PinCapableBot, type RobustApiSeam } from './st
|
|
|
658
661
|
import { collectSweepTargets, type StalePinSweeper, type SweepTarget } from './stale-pin-sweep.js'
|
|
659
662
|
import { createGatewayStalePinSweeper } from './stale-pin-sweep-wiring.js'
|
|
660
663
|
import { reseedSweepLedger } from './stale-pin-sweep-store.js'
|
|
661
|
-
|
|
664
|
+
// Ownership choke point for every state-dir write; zero-syscall no-op off the root path. Rationale: src/util/state-owner.ts.
|
|
665
|
+
import { appendStateFileSync, atomicWriteStateFileSync, mkdirStateSync, reconcileStateDirOwnershipLogged, writeStateFileSync } from '../../src/util/state-owner.js'
|
|
662
666
|
import { startWebhookIngestServer } from './webhook-ingest-server.js'
|
|
663
667
|
import { recordWebhookEvent } from '../../src/web/webhook-gateway-record.js'
|
|
664
668
|
|
|
@@ -2148,6 +2152,8 @@ function runHistoryReaperNow(reason: 'boot' | 'periodic'): void {
|
|
|
2148
2152
|
process.stderr.write(`telegram gateway: history-reaper (${reason}) failed: ${(err as Error).message}\n`)
|
|
2149
2153
|
}
|
|
2150
2154
|
}
|
|
2155
|
+
// State-dir ownership backfill (never throws; rides this EXISTING boot/6h tick). See src/util/state-owner.ts.
|
|
2156
|
+
reconcileStateDirOwnershipLogged(STATE_DIR, reason, { prefix: 'telegram gateway' })
|
|
2151
2157
|
}
|
|
2152
2158
|
// Run once at boot to catch up long-stopped agents. (#2996 P0c: gated.)
|
|
2153
2159
|
if (isGatewayMain) runHistoryReaperNow('boot')
|
|
@@ -2668,7 +2674,7 @@ function noteAgentOutputAt(key: string, ts: number): void {
|
|
|
2668
2674
|
const OBLIGATION_STORE_PATH = join(STATE_DIR, 'obligations.json')
|
|
2669
2675
|
const obligationStoreFs = {
|
|
2670
2676
|
readFileSync: (p: string) => readFileSync(p, 'utf8'),
|
|
2671
|
-
writeFileSync: (p: string, d: string) =>
|
|
2677
|
+
writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
|
|
2672
2678
|
renameSync: (a: string, b: string) => renameSync(a, b),
|
|
2673
2679
|
existsSync: (p: string) => existsSync(p),
|
|
2674
2680
|
fsyncFileSync: fsyncPathSync, fsyncDirSync: fsyncPathSync, unlinkSync,
|
|
@@ -5595,10 +5601,28 @@ const rawRobustApiCall = createRetryApiCall({
|
|
|
5595
5601
|
floodWaitRemainingMs: probeFloodWaitRemainingMs,
|
|
5596
5602
|
})
|
|
5597
5603
|
|
|
5604
|
+
// #4571 — card/system-surface history lane. Every card the gateway posts
|
|
5605
|
+
// (activity card, status pin, approval/boot/issues/worker-feed cards, progress
|
|
5606
|
+
// lines, notices) goes through `robustApiCall`, so observing its RESOLVED
|
|
5607
|
+
// result is the one chokepoint that makes every posted message id resolvable
|
|
5608
|
+
// when the operator quote-replies to it. Never throws; see
|
|
5609
|
+
// system-message-observer.ts for the send-vs-edit and throttling contract.
|
|
5610
|
+
// Gated on the SAME condition as `initHistory` above — a non-main gateway
|
|
5611
|
+
// process never opens the DB, so an observer there would be pure noise.
|
|
5612
|
+
// The empty-body alarm (#4576) is the observer's own default — see
|
|
5613
|
+
// `defaultEmptyCardTextWarning` in system-message-observer.ts.
|
|
5614
|
+
const observeSentMessage = isGatewayMain && HISTORY_ENABLED
|
|
5615
|
+
? makeSystemMessageObserver({ insert: recordSystemOutbound, updateText: updateSystemOutboundText })
|
|
5616
|
+
: undefined
|
|
5617
|
+
|
|
5598
5618
|
const robustApiCall = <T>(
|
|
5599
5619
|
fn: () => Promise<T>,
|
|
5600
5620
|
opts?: Parameters<typeof rawRobustApiCall<T>>[1],
|
|
5601
|
-
): Promise<T> =>
|
|
5621
|
+
): Promise<T> => {
|
|
5622
|
+
const p = sendGate.gate(() => rawRobustApiCall(fn, opts), opts)
|
|
5623
|
+
if (observeSentMessage == null) return p
|
|
5624
|
+
return p.then((res) => { observeSentMessage(res, opts); return res })
|
|
5625
|
+
}
|
|
5602
5626
|
|
|
5603
5627
|
// Fire-and-forget wrapper for outbound surfaces that previously had
|
|
5604
5628
|
// `.catch(() => {})` directly on `bot.api.*` calls. Resolves to undefined
|
|
@@ -8384,7 +8408,7 @@ const statusPinRightsCache = new PinRightsCache()
|
|
|
8384
8408
|
const STATUS_PIN_STORE_PATH = join(STATE_DIR, 'status-pins.json')
|
|
8385
8409
|
const statusPinStoreFs = {
|
|
8386
8410
|
readFileSync: (p: string) => readFileSync(p, 'utf8'),
|
|
8387
|
-
writeFileSync: (p: string, d: string) =>
|
|
8411
|
+
writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
|
|
8388
8412
|
renameSync: (a: string, b: string) => renameSync(a, b),
|
|
8389
8413
|
existsSync: (p: string) => existsSync(p),
|
|
8390
8414
|
}
|
|
@@ -8400,7 +8424,7 @@ const statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING
|
|
|
8400
8424
|
const ACTIVITY_CARD_STORE_PATH = join(STATE_DIR, 'activity-cards-pending.json')
|
|
8401
8425
|
const activityCardStoreFs: ActivityCardStoreFsSeam = {
|
|
8402
8426
|
readFileSync: (p: string) => readFileSync(p, 'utf8'),
|
|
8403
|
-
writeFileSync: (p: string, d: string) =>
|
|
8427
|
+
writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
|
|
8404
8428
|
renameSync: (a: string, b: string) => renameSync(a, b),
|
|
8405
8429
|
existsSync: (p: string) => existsSync(p),
|
|
8406
8430
|
}
|
|
@@ -8419,7 +8443,7 @@ const activityCardPersistEnabled = !STATIC
|
|
|
8419
8443
|
const QUEUED_CARD_STORE_PATH = join(STATE_DIR, 'queued-cards-pending.json')
|
|
8420
8444
|
const queuedCardStoreFs: QueuedCardStoreFsSeam = {
|
|
8421
8445
|
readFileSync: (p: string) => readFileSync(p, 'utf8'),
|
|
8422
|
-
writeFileSync: (p: string, d: string) =>
|
|
8446
|
+
writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
|
|
8423
8447
|
renameSync: (a: string, b: string) => renameSync(a, b),
|
|
8424
8448
|
existsSync: (p: string) => existsSync(p),
|
|
8425
8449
|
}
|
|
@@ -9054,7 +9078,7 @@ let stalePinSweepEligible = false
|
|
|
9054
9078
|
const STALE_PIN_SWEEP_STORE_PATH = join(STATE_DIR, 'stale-pin-sweep.json')
|
|
9055
9079
|
const sweepStoreFs = {
|
|
9056
9080
|
readFileSync: (p: string) => readFileSync(p, 'utf-8'),
|
|
9057
|
-
writeFileSync: (p: string, data: string) =>
|
|
9081
|
+
writeFileSync: (p: string, data: string) => atomicWriteStateFileSync(p, data, 0o600),
|
|
9058
9082
|
existsSync: (p: string) => existsSync(p),
|
|
9059
9083
|
}
|
|
9060
9084
|
const stalePinSweeper: StalePinSweeper = createGatewayStalePinSweeper({
|
|
@@ -9152,7 +9176,7 @@ let workerActivityFeed: ReturnType<typeof createWorkerActivityFeed> | null = nul
|
|
|
9152
9176
|
// ─── IPC server ───────────────────────────────────────────────────────────
|
|
9153
9177
|
const SOCKET_PATH = process.env.SWITCHROOM_GATEWAY_SOCKET ?? join(STATE_DIR, 'gateway.sock')
|
|
9154
9178
|
// Ensure the directory for the socket exists (#2996 P0c: gated — disk write).
|
|
9155
|
-
if (isGatewayMain)
|
|
9179
|
+
if (isGatewayMain) mkdirStateSync(STATE_DIR, { recursive: true, mode: 0o700 })
|
|
9156
9180
|
|
|
9157
9181
|
// PID file + session marker. See pid-file.ts and session-marker.ts for
|
|
9158
9182
|
// the 2026-04-22 incident that motivates these. The PID file lets the
|
|
@@ -9739,9 +9763,9 @@ if (isGatewayMain) inboundSpool = STATIC
|
|
|
9739
9763
|
: createInboundSpool({
|
|
9740
9764
|
path: join(STATE_DIR, 'inbound-spool.jsonl'),
|
|
9741
9765
|
fs: {
|
|
9742
|
-
appendFileSync: (p, d) =>
|
|
9766
|
+
appendFileSync: (p, d) => appendStateFileSync(p, d),
|
|
9743
9767
|
readFileSync: (p) => readFileSync(p, 'utf8'),
|
|
9744
|
-
writeFileSync: (p, d) =>
|
|
9768
|
+
writeFileSync: (p, d) => writeStateFileSync(p, d),
|
|
9745
9769
|
renameSync: (a, b) => renameSync(a, b),
|
|
9746
9770
|
existsSync: (p) => existsSync(p),
|
|
9747
9771
|
statSizeSync: (p) => statSync(p).size,
|
|
@@ -12848,7 +12872,7 @@ async function publishToTelegraph(
|
|
|
12848
12872
|
}
|
|
12849
12873
|
account = created.value
|
|
12850
12874
|
try {
|
|
12851
|
-
|
|
12875
|
+
mkdirStateSync(STATE_DIR, { recursive: true, mode: 0o700 })
|
|
12852
12876
|
writeFileSync(accountPath, JSON.stringify(account, null, 2), { mode: 0o600 })
|
|
12853
12877
|
} catch (err) {
|
|
12854
12878
|
process.stderr.write(`telegram gateway: telegraph cache write failed: ${(err as Error).message}\n`)
|
|
@@ -15048,13 +15072,18 @@ export async function handleInbound(
|
|
|
15048
15072
|
replyToText,
|
|
15049
15073
|
replyToTextEscaped,
|
|
15050
15074
|
replyToRole,
|
|
15075
|
+
replyToKind,
|
|
15051
15076
|
} = resolveReplyToFromBuffer({
|
|
15052
15077
|
replyToMessageId,
|
|
15053
15078
|
replyToText: replyForwardCtx.replyToText,
|
|
15054
15079
|
replyToTextEscaped: replyForwardCtx.replyToTextEscaped,
|
|
15055
15080
|
historyEnabled: HISTORY_ENABLED,
|
|
15056
15081
|
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
15057
|
-
|
|
15082
|
+
// includeSystem (#4571): a quote-reply to the live activity card / a status
|
|
15083
|
+
// pin / an approval card must resolve. Scoped to THIS lookup — the
|
|
15084
|
+
// reaction-trigger's bot-authored predicate keeps the default (cards
|
|
15085
|
+
// invisible), so its behaviour is unchanged.
|
|
15086
|
+
lookup: (messageId) => lookupMessageRoleAndText(chat_id, messageId, { includeSystem: true }),
|
|
15058
15087
|
})
|
|
15059
15088
|
|
|
15060
15089
|
if (HISTORY_ENABLED) {
|
|
@@ -15132,6 +15161,7 @@ export async function handleInbound(
|
|
|
15132
15161
|
replyToMessageId,
|
|
15133
15162
|
replyToTextEscaped,
|
|
15134
15163
|
replyToRole,
|
|
15164
|
+
replyToKind,
|
|
15135
15165
|
forwardOriginMeta,
|
|
15136
15166
|
topicFramingEnabled: TOPIC_FRAMING_ENABLED,
|
|
15137
15167
|
personDirectory: PERSON_DIRECTORY,
|
|
@@ -16061,7 +16091,7 @@ function spawnSwitchroomDetached(
|
|
|
16061
16091
|
const logPath = join(STATE_DIR, 'detached-spawn.log')
|
|
16062
16092
|
let outFd: number | null = null
|
|
16063
16093
|
try {
|
|
16064
|
-
|
|
16094
|
+
mkdirStateSync(STATE_DIR, { recursive: true })
|
|
16065
16095
|
outFd = openSync(logPath, 'a')
|
|
16066
16096
|
writeFileSync(logPath, `\n[${new Date().toISOString()}] spawn ${SWITCHROOM_CLI} ${fullArgs.join(' ')}\n`, { flag: 'a' })
|
|
16067
16097
|
} catch {}
|
|
@@ -17577,9 +17607,9 @@ function buildFolderPickerDeps(): FolderPickerHandlerDeps {
|
|
|
17577
17607
|
// existing on-disk lockouts age out via DEFAULT_FALLBACK_COOLDOWN_MS.
|
|
17578
17608
|
const lockoutOps: LockoutPersistOps = {
|
|
17579
17609
|
readFileSync: (p, enc) => readFileSync(p, enc),
|
|
17580
|
-
writeFileSync: (p, data, opts) =>
|
|
17610
|
+
writeFileSync: (p, data, opts) => writeStateFileSync(p, data, opts),
|
|
17581
17611
|
existsSync: (p) => existsSync(p),
|
|
17582
|
-
mkdirSync: (p, opts) =>
|
|
17612
|
+
mkdirSync: (p, opts) => mkdirStateSync(p, opts),
|
|
17583
17613
|
joinPath: (...parts) => join(...parts),
|
|
17584
17614
|
}
|
|
17585
17615
|
|
|
@@ -22850,6 +22880,11 @@ async function initGatewayBot(): Promise<void> {
|
|
|
22850
22880
|
|
|
22851
22881
|
bot = new Bot(TOKEN)
|
|
22852
22882
|
installTgPostLogger(bot); installRichMarkdownGuard(bot) // #3252/#3463: universal fmt guard installed after logger (composes outermost); see installRichMarkdownGuard docblock
|
|
22883
|
+
// #4576 follow-up: FALLBACK card body. The observer takes the stored body off
|
|
22884
|
+
// the RESPONSE (`rich_message` → `text`/`caption`); this stamps the REQUEST body
|
|
22885
|
+
// on the resolved Message for the shapes a response can't supply. After the fmt
|
|
22886
|
+
// guard so it composes OUTSIDE it. See sent-text-capture.ts.
|
|
22887
|
+
installSentTextCapture(bot)
|
|
22853
22888
|
// #3620 flood fuse — installed LAST so it composes OUTERMOST: the one seam no
|
|
22854
22889
|
// outbound call can bypass (grammY has no route to the network that skips the
|
|
22855
22890
|
// transformer stack). Kill-switch SWITCHROOM_EDIT_FUSE=0; see edit-flood-fuse.ts.
|
|
@@ -208,8 +208,14 @@ export interface ReplyToBufferFallbackParams {
|
|
|
208
208
|
/** REPLY_TO_TEXT_MAX. */
|
|
209
209
|
replyToTextMax: number
|
|
210
210
|
/** `lookupMessageRoleAndText` bound to the chat, or any equivalent. May
|
|
211
|
-
* throw (e.g. requireDb when history disabled mid-run) — this is caught.
|
|
212
|
-
|
|
211
|
+
* throw (e.g. requireDb when history disabled mid-run) — this is caught.
|
|
212
|
+
*
|
|
213
|
+
* Bind it with `includeSystem: true` (#4571) so a reply that points at a
|
|
214
|
+
* CARD — the activity card, a status pin, an approval card — resolves
|
|
215
|
+
* instead of returning null. `kind` carries the card family. */
|
|
216
|
+
lookup: (
|
|
217
|
+
messageId: number,
|
|
218
|
+
) => { role: 'user' | 'assistant' | 'system'; text: string; kind?: string | null } | null
|
|
213
219
|
}
|
|
214
220
|
|
|
215
221
|
/**
|
|
@@ -232,15 +238,22 @@ export interface ReplyToBufferFallbackParams {
|
|
|
232
238
|
export function resolveReplyToFromBuffer(p: ReplyToBufferFallbackParams): {
|
|
233
239
|
replyToText: string | undefined
|
|
234
240
|
replyToTextEscaped: string | undefined
|
|
235
|
-
replyToRole: 'user' | 'assistant' | undefined
|
|
241
|
+
replyToRole: 'user' | 'assistant' | 'system' | undefined
|
|
242
|
+
/** Card family (`activity-summary`, `approval-card`, …) when the antecedent
|
|
243
|
+
* is a system-lane row. Undefined otherwise. #4571. */
|
|
244
|
+
replyToKind: string | undefined
|
|
236
245
|
} {
|
|
237
246
|
let replyToText = p.replyToText
|
|
238
247
|
let replyToTextEscaped = p.replyToTextEscaped
|
|
239
|
-
let replyToRole: 'user' | 'assistant' | undefined
|
|
248
|
+
let replyToRole: 'user' | 'assistant' | 'system' | undefined
|
|
249
|
+
let replyToKind: string | undefined
|
|
240
250
|
const liveTextEmpty = replyToTextEscaped == null || replyToTextEscaped.length === 0
|
|
241
251
|
if (p.historyEnabled && p.replyToMessageId != null && liveTextEmpty) {
|
|
242
252
|
try {
|
|
243
253
|
const recovered = p.lookup(p.replyToMessageId)
|
|
254
|
+
if (recovered != null && recovered.role === 'system' && recovered.kind) {
|
|
255
|
+
replyToKind = recovered.kind
|
|
256
|
+
}
|
|
244
257
|
if (recovered && recovered.text.length > 0) {
|
|
245
258
|
replyToText =
|
|
246
259
|
recovered.text.length > p.replyToTextMax
|
|
@@ -258,7 +271,7 @@ export function resolveReplyToFromBuffer(p: ReplyToBufferFallbackParams): {
|
|
|
258
271
|
// silently to the id-only inputs (current behavior).
|
|
259
272
|
}
|
|
260
273
|
}
|
|
261
|
-
return { replyToText, replyToTextEscaped, replyToRole }
|
|
274
|
+
return { replyToText, replyToTextEscaped, replyToRole, replyToKind }
|
|
262
275
|
}
|
|
263
276
|
|
|
264
277
|
/** Inputs for {@link buildInboundEnvelope} — every field is an at-call
|
|
@@ -288,7 +301,11 @@ export interface EnvelopeBuildParams {
|
|
|
288
301
|
* message, 'user' = a person's), when known — recovered from the history
|
|
289
302
|
* buffer by handleInbound's reply-to fallback. Undefined when the role
|
|
290
303
|
* can't be determined (e.g. text came live off the update, not the DB). */
|
|
291
|
-
replyToRole: 'user' | 'assistant' | undefined
|
|
304
|
+
replyToRole: 'user' | 'assistant' | 'system' | undefined
|
|
305
|
+
/** #4571 — when the antecedent is a CARD the gateway posted (activity card,
|
|
306
|
+
* status pin, approval card…), the card family. Emitted as `reply_to_kind`
|
|
307
|
+
* alongside `reply_to_role="system"`. */
|
|
308
|
+
replyToKind?: string | undefined
|
|
292
309
|
forwardOriginMeta: Record<string, string>
|
|
293
310
|
/** TOPIC_FRAMING_ENABLED — fixed constant. */
|
|
294
311
|
topicFramingEnabled: boolean
|
|
@@ -390,6 +407,14 @@ export function buildInboundEnvelope(p: EnvelopeBuildParams): InboundMessage {
|
|
|
390
407
|
// guessing the antecedent. Free: the history-buffer lookup that recovers
|
|
391
408
|
// reply_to_text already returns the role. Emitted only when known.
|
|
392
409
|
...(p.replyToRole != null ? { reply_to_role: p.replyToRole } : {}),
|
|
410
|
+
// #4571 — the antecedent is one of the bot's own CARDS, not a reply.
|
|
411
|
+
// `reply_to_role="system"` + `reply_to_kind="activity-summary"` tells the
|
|
412
|
+
// agent the operator tapped the live working card (the most recent thing
|
|
413
|
+
// on their screen for most of a turn) rather than an answer, so it can
|
|
414
|
+
// read `reply_to_text` as the card body instead of reporting amnesia.
|
|
415
|
+
...(p.replyToKind != null && p.replyToKind.length > 0
|
|
416
|
+
? { reply_to_kind: p.replyToKind }
|
|
417
|
+
: {}),
|
|
393
418
|
// Forwarded-message origin (server-stamped, attrs-only — see above).
|
|
394
419
|
// forwarded_from / forwarded_from_type / forwarded_from_id /
|
|
395
420
|
// forwarded_date, plus numbered _2.. siblings for a multi-origin
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
import { unlinkSync } from 'node:fs'
|
|
33
33
|
import { join } from 'node:path'
|
|
34
|
-
import {
|
|
34
|
+
import { atomicWriteStateFileSync } from '../../src/util/state-owner.js'
|
|
35
35
|
import {
|
|
36
36
|
preserveUnreadableStoreFile,
|
|
37
37
|
quarantineCorruptStoreFile,
|
|
@@ -149,7 +149,7 @@ export function createMissedApprovalsStore(
|
|
|
149
149
|
unreadable = false
|
|
150
150
|
}
|
|
151
151
|
// tmp + fsync + rename — never truncate the destination in place.
|
|
152
|
-
|
|
152
|
+
atomicWriteStateFileSync(filePath, JSON.stringify(f), 0o600)
|
|
153
153
|
} catch (err) {
|
|
154
154
|
log(`telegram gateway: missed-approvals-store write failed: ${(err as Error).message}\n`)
|
|
155
155
|
}
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
|
|
45
45
|
import { unlinkSync } from 'node:fs'
|
|
46
46
|
import { join } from 'node:path'
|
|
47
|
-
import {
|
|
47
|
+
import { atomicWriteStateFileSync } from '../../src/util/state-owner.js'
|
|
48
48
|
import {
|
|
49
49
|
preserveUnreadableStoreFile,
|
|
50
50
|
quarantineCorruptStoreFile,
|
|
@@ -162,7 +162,7 @@ export function createPendingCardStore(
|
|
|
162
162
|
// tmp + fsync + rename, mode pinned to 0600 on the tempfile fd (so an
|
|
163
163
|
// existing file can't keep laxer perms, and a crash mid-write leaves
|
|
164
164
|
// the previous good file untouched).
|
|
165
|
-
|
|
165
|
+
atomicWriteStateFileSync(filePath, JSON.stringify(entries), 0o600)
|
|
166
166
|
} catch (err) {
|
|
167
167
|
log(`telegram gateway: pending-card-store write failed: ${(err as Error).message}\n`)
|
|
168
168
|
}
|
|
@@ -39,7 +39,7 @@ import { readFileSync, mkdirSync } from 'node:fs'
|
|
|
39
39
|
import { homedir } from 'node:os'
|
|
40
40
|
import { join } from 'node:path'
|
|
41
41
|
|
|
42
|
-
import {
|
|
42
|
+
import { atomicWriteStateFileSync } from '../../src/util/state-owner.js'
|
|
43
43
|
|
|
44
44
|
/** One half-open privacy interval. `end: null` = still open ("private now"). */
|
|
45
45
|
export interface PrivacyInterval {
|
|
@@ -130,7 +130,7 @@ function writePrivacyState(state: PrivacyState, stateDir: string): void {
|
|
|
130
130
|
/* dir may already exist / be unwritable — the write below reports */
|
|
131
131
|
}
|
|
132
132
|
try {
|
|
133
|
-
|
|
133
|
+
atomicWriteStateFileSync(privacyStatePath(stateDir), JSON.stringify(state), 0o600)
|
|
134
134
|
} catch (err) {
|
|
135
135
|
process.stderr.write(
|
|
136
136
|
`telegram gateway: privacy-state write failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { join } from 'node:path'
|
|
31
|
-
import {
|
|
31
|
+
import { atomicWriteStateFileSync } from '../../src/util/state-owner.js'
|
|
32
32
|
import {
|
|
33
33
|
preserveUnreadableStoreFile,
|
|
34
34
|
quarantineCorruptStoreFile,
|
|
@@ -105,7 +105,7 @@ export function createScopedGrantStore(
|
|
|
105
105
|
}
|
|
106
106
|
// tmp + fsync + rename — a crash mid-persist leaves the previous
|
|
107
107
|
// grant set intact rather than a torn file that reads as "no grants".
|
|
108
|
-
|
|
108
|
+
atomicWriteStateFileSync(filePath, JSON.stringify(serializeScopedGrants(store)), 0o600)
|
|
109
109
|
} catch (err) {
|
|
110
110
|
log(`telegram gateway: scoped-grant-store write failed: ${(err as Error).message}\n`)
|
|
111
111
|
}
|