switchroom 0.20.21 → 0.21.0
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/auth-broker/index.js +1 -1
- package/dist/cli/switchroom.js +1953 -1539
- package/dist/host-control/main.js +286 -122
- package/dist/vault/approvals/kernel-server.js +1 -1
- package/dist/vault/broker/server.js +1 -1
- package/package.json +1 -1
- package/skills/switchroom-release/SKILL.md +12 -1
- package/telegram-plugin/dist/gateway/gateway.js +1405 -851
- 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 +42 -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 +242 -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/tests/buzz-mirror.test.ts +12 -1
- package/telegram-plugin/tests/card-history-lane.test.ts +394 -0
- package/telegram-plugin/tests/system-message-observer.test.ts +216 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +88 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +112 -11
- package/vendor/hindsight-memory/scripts/recall.py +11 -2
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +36 -0
- package/vendor/hindsight-memory/scripts/retain.py +6 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_retain_env.py +99 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_types_filter.py +81 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +113 -0
- package/vendor/hindsight-memory/settings.json +2 -2
- package/vendor/hindsight-memory/tests/test_config.py +8 -3
- package/vendor/hindsight-memory/tests/test_hooks.py +10 -1
- package/vendor/hindsight-memory/tests/test_retain_context.py +69 -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.
|
|
@@ -395,7 +395,9 @@ import {
|
|
|
395
395
|
pruneMessagesOlderThanDays,
|
|
396
396
|
hasOutboundDeliveredSince,
|
|
397
397
|
hasOutboundWithText,
|
|
398
|
+
recordSystemOutbound, updateSystemOutboundText,
|
|
398
399
|
} from '../history.js'
|
|
400
|
+
import { makeSystemMessageObserver } from './system-message-observer.js'
|
|
399
401
|
import {
|
|
400
402
|
runRegistryReaper,
|
|
401
403
|
resolveRetentionDays as resolveRegistryRetentionDays,
|
|
@@ -658,7 +660,8 @@ import { createStatusPinApi, type PinCapableBot, type RobustApiSeam } from './st
|
|
|
658
660
|
import { collectSweepTargets, type StalePinSweeper, type SweepTarget } from './stale-pin-sweep.js'
|
|
659
661
|
import { createGatewayStalePinSweeper } from './stale-pin-sweep-wiring.js'
|
|
660
662
|
import { reseedSweepLedger } from './stale-pin-sweep-store.js'
|
|
661
|
-
|
|
663
|
+
// Ownership choke point for every state-dir write; zero-syscall no-op off the root path. Rationale: src/util/state-owner.ts.
|
|
664
|
+
import { appendStateFileSync, atomicWriteStateFileSync, mkdirStateSync, reconcileStateDirOwnershipLogged, writeStateFileSync } from '../../src/util/state-owner.js'
|
|
662
665
|
import { startWebhookIngestServer } from './webhook-ingest-server.js'
|
|
663
666
|
import { recordWebhookEvent } from '../../src/web/webhook-gateway-record.js'
|
|
664
667
|
|
|
@@ -2148,6 +2151,8 @@ function runHistoryReaperNow(reason: 'boot' | 'periodic'): void {
|
|
|
2148
2151
|
process.stderr.write(`telegram gateway: history-reaper (${reason}) failed: ${(err as Error).message}\n`)
|
|
2149
2152
|
}
|
|
2150
2153
|
}
|
|
2154
|
+
// State-dir ownership backfill (never throws; rides this EXISTING boot/6h tick). See src/util/state-owner.ts.
|
|
2155
|
+
reconcileStateDirOwnershipLogged(STATE_DIR, reason, { prefix: 'telegram gateway' })
|
|
2151
2156
|
}
|
|
2152
2157
|
// Run once at boot to catch up long-stopped agents. (#2996 P0c: gated.)
|
|
2153
2158
|
if (isGatewayMain) runHistoryReaperNow('boot')
|
|
@@ -2668,7 +2673,7 @@ function noteAgentOutputAt(key: string, ts: number): void {
|
|
|
2668
2673
|
const OBLIGATION_STORE_PATH = join(STATE_DIR, 'obligations.json')
|
|
2669
2674
|
const obligationStoreFs = {
|
|
2670
2675
|
readFileSync: (p: string) => readFileSync(p, 'utf8'),
|
|
2671
|
-
writeFileSync: (p: string, d: string) =>
|
|
2676
|
+
writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
|
|
2672
2677
|
renameSync: (a: string, b: string) => renameSync(a, b),
|
|
2673
2678
|
existsSync: (p: string) => existsSync(p),
|
|
2674
2679
|
fsyncFileSync: fsyncPathSync, fsyncDirSync: fsyncPathSync, unlinkSync,
|
|
@@ -5595,10 +5600,26 @@ const rawRobustApiCall = createRetryApiCall({
|
|
|
5595
5600
|
floodWaitRemainingMs: probeFloodWaitRemainingMs,
|
|
5596
5601
|
})
|
|
5597
5602
|
|
|
5603
|
+
// #4571 — card/system-surface history lane. Every card the gateway posts
|
|
5604
|
+
// (activity card, status pin, approval/boot/issues/worker-feed cards, progress
|
|
5605
|
+
// lines, notices) goes through `robustApiCall`, so observing its RESOLVED
|
|
5606
|
+
// result is the one chokepoint that makes every posted message id resolvable
|
|
5607
|
+
// when the operator quote-replies to it. Never throws; see
|
|
5608
|
+
// system-message-observer.ts for the send-vs-edit and throttling contract.
|
|
5609
|
+
// Gated on the SAME condition as `initHistory` above — a non-main gateway
|
|
5610
|
+
// process never opens the DB, so an observer there would be pure noise.
|
|
5611
|
+
const observeSentMessage = isGatewayMain && HISTORY_ENABLED
|
|
5612
|
+
? makeSystemMessageObserver({ insert: recordSystemOutbound, updateText: updateSystemOutboundText })
|
|
5613
|
+
: undefined
|
|
5614
|
+
|
|
5598
5615
|
const robustApiCall = <T>(
|
|
5599
5616
|
fn: () => Promise<T>,
|
|
5600
5617
|
opts?: Parameters<typeof rawRobustApiCall<T>>[1],
|
|
5601
|
-
): Promise<T> =>
|
|
5618
|
+
): Promise<T> => {
|
|
5619
|
+
const p = sendGate.gate(() => rawRobustApiCall(fn, opts), opts)
|
|
5620
|
+
if (observeSentMessage == null) return p
|
|
5621
|
+
return p.then((res) => { observeSentMessage(res, opts); return res })
|
|
5622
|
+
}
|
|
5602
5623
|
|
|
5603
5624
|
// Fire-and-forget wrapper for outbound surfaces that previously had
|
|
5604
5625
|
// `.catch(() => {})` directly on `bot.api.*` calls. Resolves to undefined
|
|
@@ -8384,7 +8405,7 @@ const statusPinRightsCache = new PinRightsCache()
|
|
|
8384
8405
|
const STATUS_PIN_STORE_PATH = join(STATE_DIR, 'status-pins.json')
|
|
8385
8406
|
const statusPinStoreFs = {
|
|
8386
8407
|
readFileSync: (p: string) => readFileSync(p, 'utf8'),
|
|
8387
|
-
writeFileSync: (p: string, d: string) =>
|
|
8408
|
+
writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
|
|
8388
8409
|
renameSync: (a: string, b: string) => renameSync(a, b),
|
|
8389
8410
|
existsSync: (p: string) => existsSync(p),
|
|
8390
8411
|
}
|
|
@@ -8400,7 +8421,7 @@ const statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING
|
|
|
8400
8421
|
const ACTIVITY_CARD_STORE_PATH = join(STATE_DIR, 'activity-cards-pending.json')
|
|
8401
8422
|
const activityCardStoreFs: ActivityCardStoreFsSeam = {
|
|
8402
8423
|
readFileSync: (p: string) => readFileSync(p, 'utf8'),
|
|
8403
|
-
writeFileSync: (p: string, d: string) =>
|
|
8424
|
+
writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
|
|
8404
8425
|
renameSync: (a: string, b: string) => renameSync(a, b),
|
|
8405
8426
|
existsSync: (p: string) => existsSync(p),
|
|
8406
8427
|
}
|
|
@@ -8419,7 +8440,7 @@ const activityCardPersistEnabled = !STATIC
|
|
|
8419
8440
|
const QUEUED_CARD_STORE_PATH = join(STATE_DIR, 'queued-cards-pending.json')
|
|
8420
8441
|
const queuedCardStoreFs: QueuedCardStoreFsSeam = {
|
|
8421
8442
|
readFileSync: (p: string) => readFileSync(p, 'utf8'),
|
|
8422
|
-
writeFileSync: (p: string, d: string) =>
|
|
8443
|
+
writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
|
|
8423
8444
|
renameSync: (a: string, b: string) => renameSync(a, b),
|
|
8424
8445
|
existsSync: (p: string) => existsSync(p),
|
|
8425
8446
|
}
|
|
@@ -9054,7 +9075,7 @@ let stalePinSweepEligible = false
|
|
|
9054
9075
|
const STALE_PIN_SWEEP_STORE_PATH = join(STATE_DIR, 'stale-pin-sweep.json')
|
|
9055
9076
|
const sweepStoreFs = {
|
|
9056
9077
|
readFileSync: (p: string) => readFileSync(p, 'utf-8'),
|
|
9057
|
-
writeFileSync: (p: string, data: string) =>
|
|
9078
|
+
writeFileSync: (p: string, data: string) => atomicWriteStateFileSync(p, data, 0o600),
|
|
9058
9079
|
existsSync: (p: string) => existsSync(p),
|
|
9059
9080
|
}
|
|
9060
9081
|
const stalePinSweeper: StalePinSweeper = createGatewayStalePinSweeper({
|
|
@@ -9152,7 +9173,7 @@ let workerActivityFeed: ReturnType<typeof createWorkerActivityFeed> | null = nul
|
|
|
9152
9173
|
// ─── IPC server ───────────────────────────────────────────────────────────
|
|
9153
9174
|
const SOCKET_PATH = process.env.SWITCHROOM_GATEWAY_SOCKET ?? join(STATE_DIR, 'gateway.sock')
|
|
9154
9175
|
// Ensure the directory for the socket exists (#2996 P0c: gated — disk write).
|
|
9155
|
-
if (isGatewayMain)
|
|
9176
|
+
if (isGatewayMain) mkdirStateSync(STATE_DIR, { recursive: true, mode: 0o700 })
|
|
9156
9177
|
|
|
9157
9178
|
// PID file + session marker. See pid-file.ts and session-marker.ts for
|
|
9158
9179
|
// the 2026-04-22 incident that motivates these. The PID file lets the
|
|
@@ -9739,9 +9760,9 @@ if (isGatewayMain) inboundSpool = STATIC
|
|
|
9739
9760
|
: createInboundSpool({
|
|
9740
9761
|
path: join(STATE_DIR, 'inbound-spool.jsonl'),
|
|
9741
9762
|
fs: {
|
|
9742
|
-
appendFileSync: (p, d) =>
|
|
9763
|
+
appendFileSync: (p, d) => appendStateFileSync(p, d),
|
|
9743
9764
|
readFileSync: (p) => readFileSync(p, 'utf8'),
|
|
9744
|
-
writeFileSync: (p, d) =>
|
|
9765
|
+
writeFileSync: (p, d) => writeStateFileSync(p, d),
|
|
9745
9766
|
renameSync: (a, b) => renameSync(a, b),
|
|
9746
9767
|
existsSync: (p) => existsSync(p),
|
|
9747
9768
|
statSizeSync: (p) => statSync(p).size,
|
|
@@ -12848,7 +12869,7 @@ async function publishToTelegraph(
|
|
|
12848
12869
|
}
|
|
12849
12870
|
account = created.value
|
|
12850
12871
|
try {
|
|
12851
|
-
|
|
12872
|
+
mkdirStateSync(STATE_DIR, { recursive: true, mode: 0o700 })
|
|
12852
12873
|
writeFileSync(accountPath, JSON.stringify(account, null, 2), { mode: 0o600 })
|
|
12853
12874
|
} catch (err) {
|
|
12854
12875
|
process.stderr.write(`telegram gateway: telegraph cache write failed: ${(err as Error).message}\n`)
|
|
@@ -15048,13 +15069,18 @@ export async function handleInbound(
|
|
|
15048
15069
|
replyToText,
|
|
15049
15070
|
replyToTextEscaped,
|
|
15050
15071
|
replyToRole,
|
|
15072
|
+
replyToKind,
|
|
15051
15073
|
} = resolveReplyToFromBuffer({
|
|
15052
15074
|
replyToMessageId,
|
|
15053
15075
|
replyToText: replyForwardCtx.replyToText,
|
|
15054
15076
|
replyToTextEscaped: replyForwardCtx.replyToTextEscaped,
|
|
15055
15077
|
historyEnabled: HISTORY_ENABLED,
|
|
15056
15078
|
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
15057
|
-
|
|
15079
|
+
// includeSystem (#4571): a quote-reply to the live activity card / a status
|
|
15080
|
+
// pin / an approval card must resolve. Scoped to THIS lookup — the
|
|
15081
|
+
// reaction-trigger's bot-authored predicate keeps the default (cards
|
|
15082
|
+
// invisible), so its behaviour is unchanged.
|
|
15083
|
+
lookup: (messageId) => lookupMessageRoleAndText(chat_id, messageId, { includeSystem: true }),
|
|
15058
15084
|
})
|
|
15059
15085
|
|
|
15060
15086
|
if (HISTORY_ENABLED) {
|
|
@@ -15132,6 +15158,7 @@ export async function handleInbound(
|
|
|
15132
15158
|
replyToMessageId,
|
|
15133
15159
|
replyToTextEscaped,
|
|
15134
15160
|
replyToRole,
|
|
15161
|
+
replyToKind,
|
|
15135
15162
|
forwardOriginMeta,
|
|
15136
15163
|
topicFramingEnabled: TOPIC_FRAMING_ENABLED,
|
|
15137
15164
|
personDirectory: PERSON_DIRECTORY,
|
|
@@ -16061,7 +16088,7 @@ function spawnSwitchroomDetached(
|
|
|
16061
16088
|
const logPath = join(STATE_DIR, 'detached-spawn.log')
|
|
16062
16089
|
let outFd: number | null = null
|
|
16063
16090
|
try {
|
|
16064
|
-
|
|
16091
|
+
mkdirStateSync(STATE_DIR, { recursive: true })
|
|
16065
16092
|
outFd = openSync(logPath, 'a')
|
|
16066
16093
|
writeFileSync(logPath, `\n[${new Date().toISOString()}] spawn ${SWITCHROOM_CLI} ${fullArgs.join(' ')}\n`, { flag: 'a' })
|
|
16067
16094
|
} catch {}
|
|
@@ -17577,9 +17604,9 @@ function buildFolderPickerDeps(): FolderPickerHandlerDeps {
|
|
|
17577
17604
|
// existing on-disk lockouts age out via DEFAULT_FALLBACK_COOLDOWN_MS.
|
|
17578
17605
|
const lockoutOps: LockoutPersistOps = {
|
|
17579
17606
|
readFileSync: (p, enc) => readFileSync(p, enc),
|
|
17580
|
-
writeFileSync: (p, data, opts) =>
|
|
17607
|
+
writeFileSync: (p, data, opts) => writeStateFileSync(p, data, opts),
|
|
17581
17608
|
existsSync: (p) => existsSync(p),
|
|
17582
|
-
mkdirSync: (p, opts) =>
|
|
17609
|
+
mkdirSync: (p, opts) => mkdirStateSync(p, opts),
|
|
17583
17610
|
joinPath: (...parts) => join(...parts),
|
|
17584
17611
|
}
|
|
17585
17612
|
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* system-message-observer.ts — make CARD message ids resolvable (#4571).
|
|
3
|
+
*
|
|
4
|
+
* The problem
|
|
5
|
+
* -----------
|
|
6
|
+
* Only two things ever reached `history.db`: an inbound message, and an
|
|
7
|
+
* outbound that flowed through the `reply` / `stream_reply` family (which call
|
|
8
|
+
* `recordOutbound` explicitly). Everything else the gateway posts — the
|
|
9
|
+
* mid-turn activity card, the pinned status message, approval / boot / issues
|
|
10
|
+
* / worker-feed cards, `progress_update` lines, restart notices — consumed a
|
|
11
|
+
* real Telegram message id and left NO row behind. Measured on a live agent's
|
|
12
|
+
* buffer: 116 rows across a 266-id span above id 20000, i.e. ~56% of the ids
|
|
13
|
+
* in that chat were absent, clustered exactly where the cards were.
|
|
14
|
+
*
|
|
15
|
+
* That is user-visible, not cosmetic. The activity card is the most recent
|
|
16
|
+
* message on the operator's screen for most of a turn, so quote-replying to it
|
|
17
|
+
* is the natural gesture. Telegram then delivers `reply_to_message_id` pointing
|
|
18
|
+
* at a message the agent has no record of, the reply-antecedent resolver
|
|
19
|
+
* (`resolveReplyToFromBuffer`) gets `null` back, and the agent has to say "I
|
|
20
|
+
* can't see the message you replied to".
|
|
21
|
+
*
|
|
22
|
+
* The mechanism
|
|
23
|
+
* -------------
|
|
24
|
+
* Rather than add a `recordSystemOutbound(...)` call to each of the ~110 raw
|
|
25
|
+
* send sites (which is exactly the kind of per-call-site discipline that
|
|
26
|
+
* decays — the `reply` path was the only site anyone remembered), this hooks
|
|
27
|
+
* the ONE chokepoint every gateway outbound already goes through:
|
|
28
|
+
* `gateway.ts`'s `robustApiCall` (chat-lock → send-gate → retry policy). Every
|
|
29
|
+
* card send in the gateway is routed through it, enforced by the
|
|
30
|
+
* `check-bot-api-wrapping` lint guard.
|
|
31
|
+
*
|
|
32
|
+
* The observer reads the Telegram RESPONSE, not the request, which buys three
|
|
33
|
+
* things for free:
|
|
34
|
+
* - the real `message_id` (the only thing a reply can point at),
|
|
35
|
+
* - the chat and forum-topic the message actually landed in,
|
|
36
|
+
* - the RENDERED text, which for a card is otherwise unrecoverable (it is
|
|
37
|
+
* composed from live tool activity and never persisted anywhere).
|
|
38
|
+
*
|
|
39
|
+
* Send vs edit is NOT guessed from the verb (verb tagging is not uniform
|
|
40
|
+
* across call sites and would rot). It falls out of the data: an edit returns
|
|
41
|
+
* the same `message_id` it edited, so the conditional insert no-ops and the
|
|
42
|
+
* call falls through to an in-place text refresh. One row per card, forever,
|
|
43
|
+
* regardless of how many times it is edited.
|
|
44
|
+
*
|
|
45
|
+
* Cost control. The activity card is the highest-volume repeated
|
|
46
|
+
* `editMessageText` in the gateway (it climbs every few seconds for the whole
|
|
47
|
+
* turn). Refreshing its stored text on every edit would be a SQLite write per
|
|
48
|
+
* edit. So a per-message throttle (`editRefreshMs`, default 20s) keeps the hot
|
|
49
|
+
* path entirely in memory: a card edit inside the window costs one Map lookup
|
|
50
|
+
* and no DB work at all. The stored text is therefore a recent snapshot, not
|
|
51
|
+
* a byte-exact mirror of the live card — which is the right trade for a
|
|
52
|
+
* quote-reply antecedent.
|
|
53
|
+
*
|
|
54
|
+
* Nothing here throws. A failure to record a card must never break the send it
|
|
55
|
+
* is observing.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/** The subset of a Telegram `Message` response this observer reads. */
|
|
59
|
+
export interface SentMessageLike {
|
|
60
|
+
message_id?: unknown
|
|
61
|
+
chat?: { id?: unknown } | null
|
|
62
|
+
message_thread_id?: unknown
|
|
63
|
+
text?: unknown
|
|
64
|
+
caption?: unknown
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The subset of `robustApiCall`'s opts the observer reads. */
|
|
68
|
+
export interface ObservedCallOpts {
|
|
69
|
+
chat_id?: string
|
|
70
|
+
threadId?: number
|
|
71
|
+
verb?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface SystemMessageObserverDeps {
|
|
75
|
+
/**
|
|
76
|
+
* `history.recordSystemOutbound`. Must return true iff it inserted a NEW
|
|
77
|
+
* row, and false if any row already existed for (chat_id, message_id).
|
|
78
|
+
*/
|
|
79
|
+
insert: (args: {
|
|
80
|
+
chat_id: string
|
|
81
|
+
thread_id: number | null
|
|
82
|
+
message_id: number
|
|
83
|
+
kind: string | null
|
|
84
|
+
text: string
|
|
85
|
+
}) => boolean
|
|
86
|
+
/**
|
|
87
|
+
* `history.updateSystemOutboundText`. Must return true iff it updated a row,
|
|
88
|
+
* and false when the target row is absent or is NOT a system row (i.e. the
|
|
89
|
+
* id belongs to a real reply or an inbound).
|
|
90
|
+
*/
|
|
91
|
+
updateText: (args: { chat_id: string; message_id: number; text: string }) => boolean
|
|
92
|
+
/** Injectable clock for the edit throttle. Defaults to `Date.now`. */
|
|
93
|
+
now?: () => number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface SystemMessageObserverOptions {
|
|
97
|
+
/**
|
|
98
|
+
* Minimum gap between two stored-text refreshes of the SAME message. Edits
|
|
99
|
+
* inside the window are dropped without touching SQLite.
|
|
100
|
+
*/
|
|
101
|
+
editRefreshMs?: number
|
|
102
|
+
/** Cap on tracked message ids before the oldest half is evicted. */
|
|
103
|
+
maxTracked?: number
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const DEFAULT_EDIT_REFRESH_MS = 20_000
|
|
107
|
+
export const DEFAULT_MAX_TRACKED = 512
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Normalise a `robustApiCall` verb into the stored `kind` discriminator.
|
|
111
|
+
*
|
|
112
|
+
* The verb is the honest, already-present label for what a send IS
|
|
113
|
+
* (`activity-summary.send`, `boot-card`, `worker-feed`, `approval-card`), so
|
|
114
|
+
* the kind is derived rather than invented. The trailing transport suffix is
|
|
115
|
+
* stripped so a card's OPEN and its EDITs classify identically.
|
|
116
|
+
*
|
|
117
|
+
* Pure. Returns null for an absent / blank verb.
|
|
118
|
+
*/
|
|
119
|
+
export function normalizeSendVerb(verb: string | undefined | null): string | null {
|
|
120
|
+
if (typeof verb !== 'string') return null
|
|
121
|
+
const trimmed = verb.trim()
|
|
122
|
+
if (trimmed.length === 0) return null
|
|
123
|
+
const base = trimmed.replace(/\.(send|edit|create|post|update)$/i, '')
|
|
124
|
+
const cleaned = (base.length > 0 ? base : trimmed).slice(0, 64)
|
|
125
|
+
return cleaned.length > 0 ? cleaned : null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Extract the (chat_id, message_id, thread, text) tuple from a Telegram API
|
|
130
|
+
* result, or null when the result is not a sent/edited Message (the retry
|
|
131
|
+
* wrapper also returns `true` for pins / deletes / reactions / callback
|
|
132
|
+
* answers, and `undefined` for a swallowed benign 400).
|
|
133
|
+
*
|
|
134
|
+
* The response's own `chat.id` wins over the caller's `chat_id` opt: it is what
|
|
135
|
+
* Telegram actually delivered to, and several call sites pass no `chat_id` at
|
|
136
|
+
* all. Pure.
|
|
137
|
+
*/
|
|
138
|
+
export function extractSentMessage(
|
|
139
|
+
result: unknown,
|
|
140
|
+
opts?: ObservedCallOpts,
|
|
141
|
+
): { chatId: string; messageId: number; threadId: number | null; text: string } | null {
|
|
142
|
+
if (result == null || typeof result !== 'object') return null
|
|
143
|
+
const msg = result as SentMessageLike
|
|
144
|
+
const id = msg.message_id
|
|
145
|
+
if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0) return null
|
|
146
|
+
const rawChat = msg.chat?.id
|
|
147
|
+
const chatId =
|
|
148
|
+
rawChat != null && (typeof rawChat === 'number' || typeof rawChat === 'string')
|
|
149
|
+
? String(rawChat)
|
|
150
|
+
: opts?.chat_id
|
|
151
|
+
if (chatId == null || chatId.length === 0) return null
|
|
152
|
+
const rawThread = msg.message_thread_id
|
|
153
|
+
const threadId =
|
|
154
|
+
typeof rawThread === 'number' && Number.isInteger(rawThread)
|
|
155
|
+
? rawThread
|
|
156
|
+
: typeof opts?.threadId === 'number'
|
|
157
|
+
? opts.threadId
|
|
158
|
+
: null
|
|
159
|
+
const text =
|
|
160
|
+
typeof msg.text === 'string' ? msg.text : typeof msg.caption === 'string' ? msg.caption : ''
|
|
161
|
+
return { chatId, messageId: id, threadId, text }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Per-id bookkeeping. `foreign` = the id belongs to a non-system row (a real
|
|
165
|
+
* reply or an inbound); never write to it again. */
|
|
166
|
+
type TrackedState = { lane: 'system' | 'foreign'; lastStoredMs: number }
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Build the observer. The returned function is called with the RESOLVED result
|
|
170
|
+
* of every `robustApiCall` and never throws.
|
|
171
|
+
*/
|
|
172
|
+
export function makeSystemMessageObserver(
|
|
173
|
+
deps: SystemMessageObserverDeps,
|
|
174
|
+
options?: SystemMessageObserverOptions,
|
|
175
|
+
): (result: unknown, opts?: ObservedCallOpts) => void {
|
|
176
|
+
const now = deps.now ?? Date.now
|
|
177
|
+
const editRefreshMs = options?.editRefreshMs ?? DEFAULT_EDIT_REFRESH_MS
|
|
178
|
+
const maxTracked = Math.max(1, options?.maxTracked ?? DEFAULT_MAX_TRACKED)
|
|
179
|
+
const tracked = new Map<string, TrackedState>()
|
|
180
|
+
|
|
181
|
+
function remember(key: string, state: TrackedState): void {
|
|
182
|
+
tracked.set(key, state)
|
|
183
|
+
if (tracked.size > maxTracked) {
|
|
184
|
+
// Map iterates in insertion order — drop the oldest half in one pass so
|
|
185
|
+
// eviction is amortised O(1) rather than per-insert.
|
|
186
|
+
const drop = Math.ceil(tracked.size / 2)
|
|
187
|
+
let i = 0
|
|
188
|
+
for (const k of tracked.keys()) {
|
|
189
|
+
if (i++ >= drop) break
|
|
190
|
+
tracked.delete(k)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return function observeSentMessage(result: unknown, opts?: ObservedCallOpts): void {
|
|
196
|
+
try {
|
|
197
|
+
const sent = extractSentMessage(result, opts)
|
|
198
|
+
if (sent == null) return
|
|
199
|
+
const key = `${sent.chatId}:${sent.messageId}`
|
|
200
|
+
const t = now()
|
|
201
|
+
const seen = tracked.get(key)
|
|
202
|
+
|
|
203
|
+
if (seen != null) {
|
|
204
|
+
if (seen.lane === 'foreign') return
|
|
205
|
+
if (t - seen.lastStoredMs < editRefreshMs) return
|
|
206
|
+
if (deps.updateText({ chat_id: sent.chatId, message_id: sent.messageId, text: sent.text })) {
|
|
207
|
+
seen.lastStoredMs = t
|
|
208
|
+
} else {
|
|
209
|
+
// The row is gone (retention prune / delete) or was promoted to a
|
|
210
|
+
// real `assistant` reply by recordOutbound. Either way this id is no
|
|
211
|
+
// longer ours to write.
|
|
212
|
+
seen.lane = 'foreign'
|
|
213
|
+
}
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const inserted = deps.insert({
|
|
218
|
+
chat_id: sent.chatId,
|
|
219
|
+
thread_id: sent.threadId,
|
|
220
|
+
message_id: sent.messageId,
|
|
221
|
+
kind: normalizeSendVerb(opts?.verb),
|
|
222
|
+
text: sent.text,
|
|
223
|
+
})
|
|
224
|
+
if (inserted) {
|
|
225
|
+
remember(key, { lane: 'system', lastStoredMs: t })
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
// A row already exists for this id and we did not create it in this
|
|
229
|
+
// process: either a real reply / inbound (leave it alone), or a card this
|
|
230
|
+
// gateway posted before a restart. One probing update disambiguates —
|
|
231
|
+
// `updateText` only ever matches a `system` row.
|
|
232
|
+
const refreshed = deps.updateText({
|
|
233
|
+
chat_id: sent.chatId,
|
|
234
|
+
message_id: sent.messageId,
|
|
235
|
+
text: sent.text,
|
|
236
|
+
})
|
|
237
|
+
remember(key, { lane: refreshed ? 'system' : 'foreign', lastStoredMs: t })
|
|
238
|
+
} catch {
|
|
239
|
+
/* observing a send must never break the send */
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|