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.
- package/dist/cli/switchroom.js +4 -2
- package/package.json +2 -1
- package/telegram-plugin/dist/gateway/gateway.js +113 -7
- package/telegram-plugin/gateway/gateway.ts +52 -9
- package/telegram-plugin/gateway/prefix-warmup.ts +123 -0
- package/telegram-plugin/stderr-timestamps.ts +106 -0
- package/telegram-plugin/tests/prefix-warmup.test.ts +175 -0
- package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
- package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
- package/vendor/hindsight-memory/CHANGELOG.md +32 -0
- package/vendor/hindsight-memory/LICENSE +21 -0
- package/vendor/hindsight-memory/README.md +329 -0
- package/vendor/hindsight-memory/hooks/hooks.json +49 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
- package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
- package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
- package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
- package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
- package/vendor/hindsight-memory/scripts/recall.py +873 -0
- package/vendor/hindsight-memory/scripts/retain.py +286 -0
- package/vendor/hindsight-memory/scripts/session_end.py +122 -0
- package/vendor/hindsight-memory/scripts/session_start.py +76 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
- package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
- package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
- package/vendor/hindsight-memory/settings.json +37 -0
- package/vendor/hindsight-memory/skills/setup.md +24 -0
- package/vendor/hindsight-memory/tests/conftest.py +94 -0
- package/vendor/hindsight-memory/tests/test_bank.py +142 -0
- package/vendor/hindsight-memory/tests/test_client.py +232 -0
- package/vendor/hindsight-memory/tests/test_config.py +128 -0
- package/vendor/hindsight-memory/tests/test_content.py +471 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
- package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
- package/vendor/hindsight-memory/tests/test_pending.py +152 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
- package/vendor/hindsight-memory/tests/test_state.py +125 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -47247,8 +47247,8 @@ var {
|
|
|
47247
47247
|
} = import__.default;
|
|
47248
47248
|
|
|
47249
47249
|
// src/build-info.ts
|
|
47250
|
-
var VERSION = "0.12.
|
|
47251
|
-
var COMMIT_SHA = "
|
|
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.
|
|
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.
|
|
47476
|
-
var COMMIT_SHA = "
|
|
47477
|
-
var COMMIT_DATE = "2026-05-
|
|
47478
|
-
var LATEST_PR =
|
|
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
|
-
|
|
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
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1279
|
-
// from the
|
|
1280
|
-
//
|
|
1281
|
-
//
|
|
1282
|
-
|
|
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
|
-
|
|
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
|
+
}
|