switchroom 0.12.27 → 0.12.29

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.
Files changed (48) hide show
  1. package/dist/cli/switchroom.js +4 -2
  2. package/package.json +2 -1
  3. package/telegram-plugin/dist/gateway/gateway.js +113 -7
  4. package/telegram-plugin/gateway/gateway.ts +52 -9
  5. package/telegram-plugin/gateway/prefix-warmup.ts +123 -0
  6. package/telegram-plugin/stderr-timestamps.ts +106 -0
  7. package/telegram-plugin/tests/prefix-warmup.test.ts +175 -0
  8. package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
  9. package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
  10. package/vendor/hindsight-memory/CHANGELOG.md +32 -0
  11. package/vendor/hindsight-memory/LICENSE +21 -0
  12. package/vendor/hindsight-memory/README.md +329 -0
  13. package/vendor/hindsight-memory/hooks/hooks.json +49 -0
  14. package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
  15. package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
  16. package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
  17. package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
  18. package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
  19. package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
  20. package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
  21. package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
  22. package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
  23. package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
  24. package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
  25. package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
  26. package/vendor/hindsight-memory/scripts/recall.py +873 -0
  27. package/vendor/hindsight-memory/scripts/retain.py +286 -0
  28. package/vendor/hindsight-memory/scripts/session_end.py +122 -0
  29. package/vendor/hindsight-memory/scripts/session_start.py +76 -0
  30. package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
  31. package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
  35. package/vendor/hindsight-memory/settings.json +37 -0
  36. package/vendor/hindsight-memory/skills/setup.md +24 -0
  37. package/vendor/hindsight-memory/tests/conftest.py +94 -0
  38. package/vendor/hindsight-memory/tests/test_bank.py +142 -0
  39. package/vendor/hindsight-memory/tests/test_client.py +232 -0
  40. package/vendor/hindsight-memory/tests/test_config.py +128 -0
  41. package/vendor/hindsight-memory/tests/test_content.py +471 -0
  42. package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
  43. package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
  44. package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
  45. package/vendor/hindsight-memory/tests/test_pending.py +152 -0
  46. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
  47. package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
  48. package/vendor/hindsight-memory/tests/test_state.py +125 -0
@@ -47247,8 +47247,8 @@ var {
47247
47247
  } = import__.default;
47248
47248
 
47249
47249
  // src/build-info.ts
47250
- var VERSION = "0.12.27";
47251
- var COMMIT_SHA = "64fb245d";
47250
+ var VERSION = "0.12.29";
47251
+ var COMMIT_SHA = "f7c92422";
47252
47252
 
47253
47253
  // src/cli/agent.ts
47254
47254
  init_source();
@@ -48576,6 +48576,8 @@ function installHindsightPlugin(agentName, agentDir, switchroomConfig) {
48576
48576
  return null;
48577
48577
  const sourcePath = resolveHindsightVendorPath();
48578
48578
  if (!existsSync11(sourcePath)) {
48579
+ process.stderr.write(`installHindsightPlugin: vendor source missing at ${sourcePath} ` + `\u2014 hindsight plugin NOT installed for ${agentName}. ` + `Likely a packaging regression: check the npm tarball's files array.
48580
+ `);
48579
48581
  return null;
48580
48582
  }
48581
48583
  const destPath = join8(agentDir, ".claude", "plugins", "hindsight-memory");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.12.27",
3
+ "version": "0.12.29",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  "profiles",
14
14
  "skills",
15
15
  "telegram-plugin",
16
+ "vendor",
16
17
  "bin",
17
18
  "README.md",
18
19
  "LICENSE"
@@ -29800,6 +29800,49 @@ function installPluginLogger(env = process.env) {
29800
29800
  return activeHandle;
29801
29801
  }
29802
29802
 
29803
+ // stderr-timestamps.ts
29804
+ var installed = false;
29805
+ var originalWrite = null;
29806
+ var partialBuffer = "";
29807
+ function isoTimestamp() {
29808
+ return new Date().toISOString();
29809
+ }
29810
+ function installStderrTimestamps(env = process.env) {
29811
+ if (env.SWITCHROOM_LOG_TIMESTAMPS === "0")
29812
+ return false;
29813
+ if (installed)
29814
+ return true;
29815
+ const origin = process.stderr.write.bind(process.stderr);
29816
+ originalWrite = origin;
29817
+ const wrapped = function write(chunk, encodingOrCb, cb) {
29818
+ const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
29819
+ const stamped = stampLines(text);
29820
+ return origin(stamped, encodingOrCb, cb);
29821
+ };
29822
+ process.stderr.write = wrapped;
29823
+ installed = true;
29824
+ return true;
29825
+ }
29826
+ function stampLines(text, now = isoTimestamp) {
29827
+ if (text === "")
29828
+ return "";
29829
+ let out = "";
29830
+ let i = 0;
29831
+ while (i < text.length) {
29832
+ const nl = text.indexOf(`
29833
+ `, i);
29834
+ if (nl === -1) {
29835
+ partialBuffer += text.slice(i);
29836
+ break;
29837
+ }
29838
+ const line = partialBuffer + text.slice(i, nl + 1);
29839
+ partialBuffer = "";
29840
+ out += `[${now()}] ${line}`;
29841
+ i = nl + 1;
29842
+ }
29843
+ return out;
29844
+ }
29845
+
29803
29846
  // dm-command-gate.ts
29804
29847
  function decideDmCommandGate(input) {
29805
29848
  if (input.chatType !== "private")
@@ -44160,6 +44203,53 @@ function dispatchOne(effect, ctx) {
44160
44203
  }
44161
44204
  }
44162
44205
 
44206
+ // gateway/prefix-warmup.ts
44207
+ var WARMUP_COOLDOWN_MS = 5 * 60000;
44208
+ var lastWarmupAtPerAgent = new Map;
44209
+ var WARMUP_TEXT = `__WARMUP_PING__
44210
+
44211
+ This is a system prefix-cache warmup (not from a user). ` + "Respond with exactly `NO_REPLY` and nothing else. " + "The gateway will suppress the response \u2014 no message will be sent to anyone.";
44212
+ function maybeFireWarmup(ctx) {
44213
+ if (process.env.SWITCHROOM_PREFIX_WARMUP !== "1")
44214
+ return false;
44215
+ const log = ctx.log ?? ((line) => process.stderr.write(line));
44216
+ const now = (ctx.now ?? Date.now)();
44217
+ const lastAt = lastWarmupAtPerAgent.get(ctx.selfAgent) ?? 0;
44218
+ if (now - lastAt < WARMUP_COOLDOWN_MS) {
44219
+ log(`telegram gateway: prefix-warmup skipped agent=${ctx.selfAgent} ` + `reason=cooldown last=${Math.round((now - lastAt) / 1000)}s ago
44220
+ `);
44221
+ return false;
44222
+ }
44223
+ const target = ctx.resolveBootTarget();
44224
+ if (!target) {
44225
+ log(`telegram gateway: prefix-warmup skipped agent=${ctx.selfAgent} ` + `reason=no-boot-chat-target
44226
+ `);
44227
+ return false;
44228
+ }
44229
+ const msg = {
44230
+ type: "inbound",
44231
+ chatId: target.chatId,
44232
+ ...target.threadId !== undefined ? { threadId: target.threadId } : {},
44233
+ messageId: 0,
44234
+ user: "switchroom-warmup",
44235
+ userId: 0,
44236
+ ts: Math.floor(now / 1000),
44237
+ text: WARMUP_TEXT,
44238
+ meta: { source: "warmup" }
44239
+ };
44240
+ try {
44241
+ ctx.client.send(msg);
44242
+ lastWarmupAtPerAgent.set(ctx.selfAgent, now);
44243
+ log(`telegram gateway: prefix-warmup fired agent=${ctx.selfAgent} ` + `chat=${target.chatId} thread=${target.threadId ?? "-"}
44244
+ `);
44245
+ return true;
44246
+ } catch (err) {
44247
+ log(`telegram gateway: prefix-warmup send threw agent=${ctx.selfAgent}: ` + `${err.message}
44248
+ `);
44249
+ return false;
44250
+ }
44251
+ }
44252
+
44163
44253
  // gateway/vault-grant-inbound-builders.ts
44164
44254
  function buildVaultGrantApprovedInbound(opts) {
44165
44255
  const ts = opts.nowMs ?? Date.now();
@@ -47472,10 +47562,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
47472
47562
  }
47473
47563
 
47474
47564
  // ../src/build-info.ts
47475
- var VERSION = "0.12.27";
47476
- var COMMIT_SHA = "64fb245d";
47477
- var COMMIT_DATE = "2026-05-20T07:46:30Z";
47478
- var LATEST_PR = 1588;
47565
+ var VERSION = "0.12.29";
47566
+ var COMMIT_SHA = "f7c92422";
47567
+ var COMMIT_DATE = "2026-05-20T15:44:41Z";
47568
+ var LATEST_PR = 1595;
47479
47569
  var COMMITS_AHEAD_OF_TAG = 0;
47480
47570
 
47481
47571
  // gateway/boot-version.ts
@@ -47963,6 +48053,7 @@ function resolveCallingSubagent(opts) {
47963
48053
 
47964
48054
  // gateway/gateway.ts
47965
48055
  var REPLY_TO_TEXT_MAX = 200;
48056
+ installStderrTimestamps();
47966
48057
  installPluginLogger();
47967
48058
  installGlobalErrorHandlers();
47968
48059
  process.on("beforeExit", () => {
@@ -48424,8 +48515,9 @@ function statusKey(chatId, threadId) {
48424
48515
  function streamKey3(chatId, threadId) {
48425
48516
  return chatKey(chatId, threadId);
48426
48517
  }
48427
- function purgeReactionTracking(key) {
48428
- shadowEmit({ kind: "turnEnd", key, at: Date.now(), outboundEmitted: true });
48518
+ function purgeReactionTracking(key, endingTurn) {
48519
+ const outboundEmitted = endingTurn != null ? endingTurn.replyCalled === true : currentTurn?.replyCalled === true;
48520
+ shadowEmit({ kind: "turnEnd", key, at: Date.now(), outboundEmitted });
48429
48521
  const msgInfo = activeReactionMsgIds.get(key);
48430
48522
  activeStatusReactions.delete(key);
48431
48523
  activeReactionMsgIds.delete(key);
@@ -48458,7 +48550,7 @@ function endCurrentTurnAtomic(turn) {
48458
48550
  if (currentTurn !== turn)
48459
48551
  return;
48460
48552
  currentTurn = null;
48461
- purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId));
48553
+ purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId), turn);
48462
48554
  }
48463
48555
  function maybeProactiveCompact() {
48464
48556
  if (compactDispatching)
@@ -49383,6 +49475,20 @@ var ipcServer = createIpcServer({
49383
49475
  }
49384
49476
  }
49385
49477
  }
49478
+ if (client3.agentName != null) {
49479
+ maybeFireWarmup({
49480
+ selfAgent: client3.agentName,
49481
+ client: client3,
49482
+ resolveBootTarget: () => {
49483
+ const marker = readRestartMarker();
49484
+ const ageMs = marker ? Date.now() - marker.ts : undefined;
49485
+ const target = resolveBootChatId(marker, ageMs);
49486
+ if (!target)
49487
+ return null;
49488
+ return { chatId: target.chatId, threadId: target.threadId };
49489
+ }
49490
+ });
49491
+ }
49386
49492
  const dedupeDecision = shouldSkipDuplicateBootCard({ activeBootCard, bootCardPending }, "bridge-reconnect");
49387
49493
  if (dedupeDecision.skip) {
49388
49494
  process.stderr.write(`telegram gateway: bridge-reconnect: skipping boot card (${dedupeDecision.reason})
@@ -23,6 +23,7 @@ import { homedir } from 'os'
23
23
  import { join, extname, sep, basename } from 'path'
24
24
 
25
25
  import { installPluginLogger } from '../plugin-logger.js'
26
+ import { installStderrTimestamps } from '../stderr-timestamps.js'
26
27
  import { decideDmCommandGate } from '../dm-command-gate.js'
27
28
  import { redactAuthCodeMessage } from '../auth-code-redact.js'
28
29
  import {
@@ -262,6 +263,7 @@ import { chatKey, chatKeyWithSuffix } from './chat-key.js'
262
263
  import { shadowEmit } from './inbound-delivery-machine-shadow.js'
263
264
  import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
264
265
  import { dispatchEffects, isDispatchEnabled } from './inbound-delivery-machine-dispatch.js'
266
+ import { maybeFireWarmup } from './prefix-warmup.js'
265
267
  import {
266
268
  buildVaultGrantApprovedInbound,
267
269
  buildVaultGrantDeniedInbound,
@@ -380,6 +382,10 @@ import { formatIdleFooter } from '../idle-footer.js'
380
382
  import { resolveCallingSubagent } from './resolve-calling-subagent.js'
381
383
 
382
384
  // ─── Stderr logging ───────────────────────────────────────────────────────
385
+ // Install the line-stamper FIRST so it wraps closest to the original
386
+ // stderr.write. plugin-logger's file mirror then sees the timestamped text.
387
+ // Kill switch: SWITCHROOM_LOG_TIMESTAMPS=0 disables.
388
+ installStderrTimestamps()
383
389
  installPluginLogger()
384
390
 
385
391
  // ─── Telemetry ────────────────────────────────────────────────────────────
@@ -1272,14 +1278,24 @@ function streamKey(chatId: string, threadId?: number | null): string {
1272
1278
  return chatKey(chatId, threadId)
1273
1279
  }
1274
1280
 
1275
- function purgeReactionTracking(key: string): void {
1276
- // Phase 2b shadow: turn end. The key was registered via setTurnStarted
1277
- // when the inbound arrived; purge is the canonical turn-end signal.
1278
- // outboundEmitted is approximated `true` here — refined in PR 3 to read
1279
- // from the per-turn `replyCalled` flag on `currentTurn`. Conservative
1280
- // shadow approximation is safe (only affects machine's lastOutboundAt
1281
- // tracking; can't drive incorrect behavior in shadow mode).
1282
- shadowEmit({ kind: 'turnEnd', key: key as _ChatKey, at: Date.now(), outboundEmitted: true })
1281
+ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
1282
+ // Phase 2b: turn end. The key was registered via setTurnStarted when
1283
+ // the inbound arrived; purge is the canonical turn-end signal.
1284
+ //
1285
+ // outboundEmitted: read from the explicit `endingTurn` parameter when
1286
+ // provided (canonical path via endCurrentTurnAtomic module-scope
1287
+ // currentTurn is already null by the time we get here), falling back
1288
+ // to `currentTurn?.replyCalled` for the legacy callsites that haven't
1289
+ // been threaded yet (sibling-key purges, restart-init cleanup).
1290
+ // Without this explicit-turn handoff the shadow trace would report
1291
+ // outboundEmitted=false on every replied turn (the dominant happy
1292
+ // path), producing strictly worse data than the blind `true` it
1293
+ // replaced. Invariant #5's `lastOutboundAt` correctness depends on
1294
+ // this signal being accurate.
1295
+ const outboundEmitted = endingTurn != null
1296
+ ? endingTurn.replyCalled === true
1297
+ : currentTurn?.replyCalled === true
1298
+ shadowEmit({ kind: 'turnEnd', key: key as _ChatKey, at: Date.now(), outboundEmitted })
1283
1299
  const msgInfo = activeReactionMsgIds.get(key)
1284
1300
  activeStatusReactions.delete(key)
1285
1301
  activeReactionMsgIds.delete(key)
@@ -1365,7 +1381,12 @@ function purgeReactionTracking(key: string): void {
1365
1381
  function endCurrentTurnAtomic(turn: CurrentTurn): void {
1366
1382
  if (currentTurn !== turn) return
1367
1383
  currentTurn = null
1368
- purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId))
1384
+ // Pass `turn` so purgeReactionTracking sees the authoritative
1385
+ // replyCalled flag even though we just nulled module-scope
1386
+ // currentTurn. Without this, the shadow trace's outboundEmitted
1387
+ // would be false on every replied turn (the dominant happy path),
1388
+ // producing strictly worse data than the blind `true` it replaced.
1389
+ purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId), turn)
1369
1390
  }
1370
1391
 
1371
1392
  /**
@@ -3262,6 +3283,28 @@ const ipcServer: IpcServer = createIpcServer({
3262
3283
  }
3263
3284
  }
3264
3285
 
3286
+ // Prefix-cache warmup (cold-start TTFO RFC, opt-in via
3287
+ // SWITCHROOM_PREFIX_WARMUP=1). Fires a synthetic inbound to claude
3288
+ // BEFORE the user's next real message so Anthropic's prefix cache
3289
+ // is warm on the user-perceived first turn. Gated, debounced
3290
+ // (5-min cooldown per agent), and skipped if no boot chat resolves.
3291
+ // Claude responds NO_REPLY per inline instruction; existing
3292
+ // silent-marker suppression at gateway.ts:5906 swallows the
3293
+ // outbound. See docs/rfcs/cold-start-ttfo.md Option A.
3294
+ if (client.agentName != null) {
3295
+ maybeFireWarmup({
3296
+ selfAgent: client.agentName,
3297
+ client,
3298
+ resolveBootTarget: () => {
3299
+ const marker = readRestartMarker()
3300
+ const ageMs = marker ? Date.now() - marker.ts : undefined
3301
+ const target = resolveBootChatId(marker, ageMs)
3302
+ if (!target) return null
3303
+ return { chatId: target.chatId, threadId: target.threadId }
3304
+ },
3305
+ })
3306
+ }
3307
+
3265
3308
  // If the agent reconnected after a /restart (or any restart), post a boot
3266
3309
  // card. The restart-marker carries the ack chat; if absent we fall back to
3267
3310
  // resolveBootChatId so crash-recovery reconnects also get a card.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Prefix-cache warmup turn — opt-in cold-start TTFO optimization.
3
+ *
4
+ * Per cold-start TTFO RFC (docs/rfcs/cold-start-ttfo.md, PR #1589),
5
+ * Option A. On every bridge-up after a restart, synthesize a synthetic
6
+ * inbound (`__WARMUP_PING__`, meta.source="warmup") and deliver it to
7
+ * the just-registered bridge. Claude processes the message — paying
8
+ * the full cold-cache cost on the synthetic turn — and responds
9
+ * `NO_REPLY` per the in-prompt instruction. The existing NO_REPLY
10
+ * suppression at `gateway.ts:5949` swallows the outbound.
11
+ *
12
+ * By the time the user's REAL next message arrives, Anthropic's prefix
13
+ * cache is warm and the user-perceived TTFO drops 4-8s on average.
14
+ *
15
+ * Phase 1 (this file): minimum-viable warmup. AGENT.md is NOT modified
16
+ * — the warmup TEXT carries the NO_REPLY instruction inline. Agent
17
+ * compliance is best-effort; non-compliant agents will emit a real
18
+ * reply to the primary chat (acceptable UX cost gated behind opt-in
19
+ * env var). Cooldown prevents the gymbro-style bridge-churn case from
20
+ * burning OAuth quota on every flap.
21
+ *
22
+ * Kill switch: `SWITCHROOM_PREFIX_WARMUP=1` opt-in (default OFF).
23
+ *
24
+ * Future PR (Phase 2): suppress 👀 reaction + progress card for
25
+ * meta.source="warmup" inbound; tag for Hindsight exclusion.
26
+ */
27
+
28
+ import type { IpcClient } from './ipc-server.js'
29
+ import type { InboundMessage } from './ipc-protocol.js'
30
+
31
+ // Per cold-start RFC open-question #4: cooldown anchored on bridge-up
32
+ // time; conservative 5-minute window catches gymbro-style 6-reconnects-
33
+ // per-UAT-cycle without dropping legitimate every-restart warmups.
34
+ const WARMUP_COOLDOWN_MS = 5 * 60_000
35
+
36
+ const lastWarmupAtPerAgent = new Map<string, number>()
37
+
38
+ export const WARMUP_TEXT =
39
+ '__WARMUP_PING__\n\nThis is a system prefix-cache warmup (not from a user). ' +
40
+ 'Respond with exactly `NO_REPLY` and nothing else. ' +
41
+ 'The gateway will suppress the response — no message will be sent to anyone.'
42
+
43
+ export interface WarmupCtx {
44
+ readonly selfAgent: string
45
+ readonly client: IpcClient
46
+ readonly resolveBootTarget: () =>
47
+ | { chatId: string; threadId?: number | undefined }
48
+ | null
49
+ readonly log?: (line: string) => void
50
+ readonly now?: () => number
51
+ }
52
+
53
+ /**
54
+ * Fire a prefix-cache warmup if conditions are met. Idempotent within
55
+ * the cooldown window. Returns true when a warmup was actually sent.
56
+ *
57
+ * Conditions:
58
+ * 1. `SWITCHROOM_PREFIX_WARMUP=1` env var set (opt-in).
59
+ * 2. Cooldown elapsed for this agent (default 5 min).
60
+ * 3. A boot chat target resolves (no point warming without a chat).
61
+ *
62
+ * The warmup is delivered to `client.send()` directly — it bypasses
63
+ * the gateway's `handleInbound`, which gates on a real Telegram
64
+ * Context object. The bridge forwards to claude exactly as it would a
65
+ * Telegram message.
66
+ */
67
+ export function maybeFireWarmup(ctx: WarmupCtx): boolean {
68
+ if (process.env.SWITCHROOM_PREFIX_WARMUP !== '1') return false
69
+
70
+ const log = ctx.log ?? ((line: string) => process.stderr.write(line))
71
+ const now = (ctx.now ?? Date.now)()
72
+
73
+ const lastAt = lastWarmupAtPerAgent.get(ctx.selfAgent) ?? 0
74
+ if (now - lastAt < WARMUP_COOLDOWN_MS) {
75
+ log(
76
+ `telegram gateway: prefix-warmup skipped agent=${ctx.selfAgent} ` +
77
+ `reason=cooldown last=${Math.round((now - lastAt) / 1000)}s ago\n`,
78
+ )
79
+ return false
80
+ }
81
+
82
+ const target = ctx.resolveBootTarget()
83
+ if (!target) {
84
+ log(
85
+ `telegram gateway: prefix-warmup skipped agent=${ctx.selfAgent} ` +
86
+ `reason=no-boot-chat-target\n`,
87
+ )
88
+ return false
89
+ }
90
+
91
+ const msg: InboundMessage = {
92
+ type: 'inbound',
93
+ chatId: target.chatId,
94
+ ...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
95
+ messageId: 0, // synthetic — never matches a real Telegram message
96
+ user: 'switchroom-warmup',
97
+ userId: 0,
98
+ ts: Math.floor(now / 1000),
99
+ text: WARMUP_TEXT,
100
+ meta: { source: 'warmup' },
101
+ }
102
+
103
+ try {
104
+ ctx.client.send(msg)
105
+ lastWarmupAtPerAgent.set(ctx.selfAgent, now)
106
+ log(
107
+ `telegram gateway: prefix-warmup fired agent=${ctx.selfAgent} ` +
108
+ `chat=${target.chatId} thread=${target.threadId ?? '-'}\n`,
109
+ )
110
+ return true
111
+ } catch (err) {
112
+ log(
113
+ `telegram gateway: prefix-warmup send threw agent=${ctx.selfAgent}: ` +
114
+ `${(err as Error).message}\n`,
115
+ )
116
+ return false
117
+ }
118
+ }
119
+
120
+ /** Test hook: reset the cooldown state. */
121
+ export function __resetForTests(): void {
122
+ lastWarmupAtPerAgent.clear()
123
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Per-line timestamp wrapper for `process.stderr.write`.
3
+ *
4
+ * The gateway's stderr is captured to `/var/log/switchroom/gateway-supervisor.log`
5
+ * by `start.sh`'s `_switchroom_supervise` redirect. The capture has NO
6
+ * line-level timestamps, which makes it impossible to measure the gap between
7
+ * events (e.g., `bridge registered` → first `dispatch stage=bridge_recover` →
8
+ * first `tg-post method=sendMessage`). Without those gaps the cold-start TTFO
9
+ * RFC's optimization claims (PR #1589) are unverifiable.
10
+ *
11
+ * This module installs a one-time wrapper on `process.stderr.write` that
12
+ * prepends an ISO-8601 timestamp (`[YYYY-MM-DDTHH:MM:SS.mmmZ]`) at the start
13
+ * of each logical line. Line-buffered: partial writes that don't end in `\n`
14
+ * are buffered until they do. Newlines mid-chunk split the chunk into
15
+ * multiple timestamped lines.
16
+ *
17
+ * Layered separately from `plugin-logger.ts`'s file mirror so each can be
18
+ * toggled independently. Order at install time: this wrapper runs FIRST
19
+ * (closest to the original write), then plugin-logger's file mirror sees
20
+ * the timestamped text.
21
+ *
22
+ * Kill switch: `SWITCHROOM_LOG_TIMESTAMPS=0` disables. Default ON.
23
+ */
24
+
25
+ let installed = false
26
+ let originalWrite: typeof process.stderr.write | null = null
27
+ let partialBuffer = ''
28
+
29
+ function isoTimestamp(): string {
30
+ return new Date().toISOString()
31
+ }
32
+
33
+ /**
34
+ * Wrap `process.stderr.write` to prepend an ISO timestamp at each line
35
+ * boundary. Idempotent — second call is a no-op.
36
+ *
37
+ * Returns true when the wrapper was installed (or was already), false when
38
+ * the kill-switch env var disabled it.
39
+ */
40
+ export function installStderrTimestamps(env: NodeJS.ProcessEnv = process.env): boolean {
41
+ if (env.SWITCHROOM_LOG_TIMESTAMPS === '0') return false
42
+ if (installed) return true
43
+
44
+ const origin = process.stderr.write.bind(process.stderr)
45
+ originalWrite = origin as typeof process.stderr.write
46
+
47
+ const wrapped = function write(
48
+ chunk: string | Uint8Array,
49
+ encodingOrCb?: BufferEncoding | ((err?: Error) => void),
50
+ cb?: (err?: Error) => void,
51
+ ): boolean {
52
+ const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
53
+ const stamped = stampLines(text)
54
+ return (origin as (c: unknown, e?: unknown, cb?: unknown) => boolean)(
55
+ stamped,
56
+ encodingOrCb,
57
+ cb,
58
+ )
59
+ } as typeof process.stderr.write
60
+
61
+ process.stderr.write = wrapped
62
+ installed = true
63
+ return true
64
+ }
65
+
66
+ /**
67
+ * Internal: split `text` into lines, prepend `[ISO] ` to each complete
68
+ * line, leave any trailing partial line buffered for the next call.
69
+ *
70
+ * Exported for tests only.
71
+ */
72
+ export function stampLines(text: string, now: () => string = isoTimestamp): string {
73
+ if (text === '') return ''
74
+
75
+ let out = ''
76
+ let i = 0
77
+ while (i < text.length) {
78
+ const nl = text.indexOf('\n', i)
79
+ if (nl === -1) {
80
+ // No more newlines in this chunk — buffer the rest.
81
+ partialBuffer += text.slice(i)
82
+ break
83
+ }
84
+ // We have a complete line: anything in partialBuffer + slice up to \n.
85
+ const line = partialBuffer + text.slice(i, nl + 1)
86
+ partialBuffer = ''
87
+ out += `[${now()}] ${line}`
88
+ i = nl + 1
89
+ }
90
+ return out
91
+ }
92
+
93
+ /** Test hook: reset module state. */
94
+ export function __resetForTests(): void {
95
+ if (installed && originalWrite) {
96
+ process.stderr.write = originalWrite
97
+ }
98
+ installed = false
99
+ originalWrite = null
100
+ partialBuffer = ''
101
+ }
102
+
103
+ /** Test hook: read the current partial buffer (debug-only). */
104
+ export function __getPartialBufferForTests(): string {
105
+ return partialBuffer
106
+ }