switchroom 0.12.16 → 0.12.18
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/agent-scheduler/index.js +82 -81
- package/dist/auth-broker/index.js +82 -81
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +359 -358
- package/dist/host-control/main.js +101 -100
- package/dist/vault/approvals/kernel-server.js +84 -83
- package/dist/vault/broker/server.js +85 -84
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +7 -0
- package/telegram-plugin/dist/bridge/bridge.js +115 -113
- package/telegram-plugin/dist/gateway/gateway.js +561 -287
- package/telegram-plugin/dist/server.js +163 -161
- package/telegram-plugin/gateway/compact-notify.ts +94 -0
- package/telegram-plugin/gateway/gateway.ts +261 -4
- package/telegram-plugin/gateway/ipc-protocol.ts +9 -0
- package/telegram-plugin/gateway/proactive-compact.ts +84 -0
- package/telegram-plugin/tests/compact-notify.test.ts +138 -0
- package/telegram-plugin/tests/proactive-compact.test.ts +101 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure transition selector for the proactive-compaction user
|
|
3
|
+
* notification (a single Telegram card edited START → FINISH). Kept
|
|
4
|
+
* side-effect-free so the lifecycle — the part prone to double-post /
|
|
5
|
+
* stale-edit / spurious-finish — is unit-testable in isolation. The
|
|
6
|
+
* impure shell (send/editMessageText, the wall-clock timeout) lives in
|
|
7
|
+
* gateway.ts.
|
|
8
|
+
*
|
|
9
|
+
* The "finished" signal is the proactive-compaction state machine's
|
|
10
|
+
* re-arm edge (`armed: false → true`, which the decider only produces
|
|
11
|
+
* when post-`/compact` occupancy drops below 0.6×cap — i.e. context
|
|
12
|
+
* verifiably shrank). This module does NOT own the wall-clock timeout
|
|
13
|
+
* (an idle agent produces no evaluations, so a dangling card must be
|
|
14
|
+
* resolved by a real timer in the shell, independent of this loop).
|
|
15
|
+
*
|
|
16
|
+
* Session reset/rotation (`session.max_idle` / `max_turns`) is
|
|
17
|
+
* deliberately silent and is not represented here.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface CompactNotifyState {
|
|
21
|
+
phase: "idle" | "awaiting";
|
|
22
|
+
/**
|
|
23
|
+
* The active session file when START was posted. FINISH is only
|
|
24
|
+
* accepted if the re-arm edge is observed against this SAME file —
|
|
25
|
+
* a sub-agent transcript briefly leading session-tail's currentFile
|
|
26
|
+
* can read a small occupancy and spuriously satisfy the re-arm
|
|
27
|
+
* condition; gating on the start-file rejects that false positive.
|
|
28
|
+
*/
|
|
29
|
+
fileAtStart: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* - `none` — no card action this evaluation.
|
|
34
|
+
* - `start` — post a fresh START card.
|
|
35
|
+
* - `start-superseding` — a prior card is still outstanding; mark it
|
|
36
|
+
* superseded, then post a fresh START card.
|
|
37
|
+
* - `finish` — edit the outstanding card to FINISHED.
|
|
38
|
+
*/
|
|
39
|
+
export type CompactNotifyAction =
|
|
40
|
+
| "none"
|
|
41
|
+
| "start"
|
|
42
|
+
| "start-superseding"
|
|
43
|
+
| "finish";
|
|
44
|
+
|
|
45
|
+
export interface CompactNotifyEvent {
|
|
46
|
+
/** The proactive-compaction decider fired `/compact` this eval. */
|
|
47
|
+
fired: boolean;
|
|
48
|
+
/** Decider re-arm edge this eval (wasArmed=false → state.armed=true). */
|
|
49
|
+
rearmed: boolean;
|
|
50
|
+
/** session-tail's active file used for the occupancy read this eval. */
|
|
51
|
+
activeFile: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function idleCompactNotifyState(): CompactNotifyState {
|
|
55
|
+
return { phase: "idle", fileAtStart: null };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Select the card action for this idle evaluation and the next state.
|
|
60
|
+
* Pure: same inputs → same outputs, no I/O.
|
|
61
|
+
*
|
|
62
|
+
* Precedence:
|
|
63
|
+
* 1. A fire always (re)starts a card. If one was still outstanding,
|
|
64
|
+
* supersede it first (single-outstanding-card invariant).
|
|
65
|
+
* 2. While awaiting, a re-arm edge ON THE SAME start-file → FINISH.
|
|
66
|
+
* 3. Otherwise hold (the shell's wall-clock timeout resolves a card
|
|
67
|
+
* that never gets a re-arm — e.g. a compaction that didn't shrink
|
|
68
|
+
* context, or an agent that went idle right after START).
|
|
69
|
+
*/
|
|
70
|
+
export function nextCompactNotify(
|
|
71
|
+
state: CompactNotifyState,
|
|
72
|
+
ev: CompactNotifyEvent,
|
|
73
|
+
): { state: CompactNotifyState; action: CompactNotifyAction } {
|
|
74
|
+
if (ev.fired) {
|
|
75
|
+
const superseding = state.phase === "awaiting";
|
|
76
|
+
return {
|
|
77
|
+
state: { phase: "awaiting", fileAtStart: ev.activeFile },
|
|
78
|
+
action: superseding ? "start-superseding" : "start",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (state.phase === "awaiting") {
|
|
83
|
+
if (
|
|
84
|
+
ev.rearmed &&
|
|
85
|
+
ev.activeFile != null &&
|
|
86
|
+
ev.activeFile === state.fileAtStart
|
|
87
|
+
) {
|
|
88
|
+
return { state: idleCompactNotifyState(), action: "finish" };
|
|
89
|
+
}
|
|
90
|
+
return { state, action: "none" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { state, action: "none" };
|
|
94
|
+
}
|
|
@@ -228,6 +228,9 @@ import { handleInjectCommand } from './inject-handler.js'
|
|
|
228
228
|
import { type BannerState } from '../slot-banner.js'
|
|
229
229
|
import { refreshBanner } from '../slot-banner-driver.js'
|
|
230
230
|
import { loadConfig as loadSwitchroomConfig } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
|
|
231
|
+
import { readTurnUsages } from '../../src/agents/perf.js'
|
|
232
|
+
import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
|
|
233
|
+
import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
|
|
231
234
|
import {
|
|
232
235
|
tryHostdDispatch,
|
|
233
236
|
hostdRequestId,
|
|
@@ -1062,6 +1065,47 @@ const chatAvailableReactions = new Map<string, Set<string> | null>()
|
|
|
1062
1065
|
const chatProbesInFlight = new Set<string>()
|
|
1063
1066
|
const activeTurnStartedAt = new Map<string, number>()
|
|
1064
1067
|
const pendingRestarts = new Map<string, number>() // agentName -> timestamp when restart was requested
|
|
1068
|
+
|
|
1069
|
+
// ─── Proactive context compaction (session.max_context_tokens) ──────────
|
|
1070
|
+
//
|
|
1071
|
+
// Opt-in: when the resolved agent config sets session.max_context_tokens,
|
|
1072
|
+
// we fire `/compact` once the live context-window occupancy of the latest
|
|
1073
|
+
// assistant turn reaches that many tokens. Evaluated ONLY at the
|
|
1074
|
+
// model-idle gate inside purgeReactionTracking (activeTurnStartedAt.size
|
|
1075
|
+
// === 0) — never mid-turn — mirroring the pendingRestarts drain. The
|
|
1076
|
+
// `/compact` verb is allowlisted in src/agents/inject.ts and runs via the
|
|
1077
|
+
// tmux send-keys path (the only path that actually executes the slash
|
|
1078
|
+
// command; inject_inbound would deliver it as literal text).
|
|
1079
|
+
//
|
|
1080
|
+
// `lastSessionActiveFile` is the session-tail's tracked currentFile,
|
|
1081
|
+
// forwarded by the bridge on every session_event — we read occupancy from
|
|
1082
|
+
// exactly that file (never an independent findActiveSessionFile re-scan).
|
|
1083
|
+
let lastSessionActiveFile: string | null = null
|
|
1084
|
+
// Anti-spam state machine lives in ./proactive-compact (pure, unit
|
|
1085
|
+
// tested). `compactDispatching` is a synchronous re-entrancy guard for
|
|
1086
|
+
// the async tmux send — purgeReactionTracking can run several times per
|
|
1087
|
+
// turn and we must not double-dispatch before the first send settles.
|
|
1088
|
+
let compactState: CompactState = initialCompactState()
|
|
1089
|
+
let compactDispatching = false
|
|
1090
|
+
|
|
1091
|
+
// User-facing proactive-compaction notification (single Telegram card
|
|
1092
|
+
// edited START → FINISH; transition selection is pure in
|
|
1093
|
+
// ./compact-notify). Session reset/rotation stays silent — not here.
|
|
1094
|
+
// The wall-clock timer is owned HERE (not the pure helper): an idle
|
|
1095
|
+
// agent produces no idle evaluations, so a card that never gets a
|
|
1096
|
+
// re-arm edge must be resolved by a real timer, independent of the
|
|
1097
|
+
// eval loop, or it would dangle forever.
|
|
1098
|
+
const COMPACT_CARD_TIMEOUT_MS = 15 * 60 * 1000
|
|
1099
|
+
interface OutstandingCompactCard {
|
|
1100
|
+
chatId: string
|
|
1101
|
+
threadId: number | undefined
|
|
1102
|
+
messageId: number
|
|
1103
|
+
occAtStart: number
|
|
1104
|
+
capAtStart: number
|
|
1105
|
+
timer: ReturnType<typeof setTimeout>
|
|
1106
|
+
}
|
|
1107
|
+
let compactNotifyState: CompactNotifyState = idleCompactNotifyState()
|
|
1108
|
+
let outstandingCompactCard: OutstandingCompactCard | null = null
|
|
1065
1109
|
const activeDraftStreams = new Map<string, DraftStreamHandle>()
|
|
1066
1110
|
const activeDraftParseModes = new Map<string, 'HTML' | 'MarkdownV2' | undefined>()
|
|
1067
1111
|
const suppressPtyPreview = new Set<string>()
|
|
@@ -1233,14 +1277,224 @@ function purgeReactionTracking(key: string): void {
|
|
|
1233
1277
|
// survives us getting killed by our own restart. Fire-and-forget;
|
|
1234
1278
|
// response to the client was already sent when the restart was
|
|
1235
1279
|
// scheduled, so nobody is waiting on this.
|
|
1236
|
-
if (activeTurnStartedAt.size === 0
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1280
|
+
if (activeTurnStartedAt.size === 0) {
|
|
1281
|
+
if (pendingRestarts.size > 0) {
|
|
1282
|
+
for (const [agentName, _timestamp] of pendingRestarts.entries()) {
|
|
1283
|
+
triggerSelfRestart(agentName, 'turn-complete-pending-restart');
|
|
1284
|
+
pendingRestarts.delete(agentName);
|
|
1285
|
+
}
|
|
1286
|
+
} else {
|
|
1287
|
+
// Strictly lower priority than a pending restart: if we just
|
|
1288
|
+
// kicked a restart the process is going away and compacting is
|
|
1289
|
+
// moot, so only evaluate when no restart drained this pass.
|
|
1290
|
+
maybeProactiveCompact();
|
|
1240
1291
|
}
|
|
1241
1292
|
}
|
|
1242
1293
|
}
|
|
1243
1294
|
|
|
1295
|
+
/**
|
|
1296
|
+
* Model-idle proactive-compaction check. Called ONLY from the
|
|
1297
|
+
* activeTurnStartedAt.size === 0 gate above (never mid-turn). Opt-in via
|
|
1298
|
+
* the resolved agent config's session.max_context_tokens; a no-op when
|
|
1299
|
+
* unset, so a fresh `switchroom setup` is unchanged.
|
|
1300
|
+
*
|
|
1301
|
+
* Occupancy = the latest usage-bearing assistant turn's
|
|
1302
|
+
* input + cache_read + cache_creation tokens (the prefix the model
|
|
1303
|
+
* actually re-read this turn ≈ current window fill). readTurnUsages(_,1)
|
|
1304
|
+
* returns exactly that single turn and skips tool-only / usage-less
|
|
1305
|
+
* lines, so we never under-count off a sub-line.
|
|
1306
|
+
*
|
|
1307
|
+
* Note (accepted, benign): there is a check-to-send race — a new inbound
|
|
1308
|
+
* could set activeTurnStartedAt between this idle check and the async
|
|
1309
|
+
* tmux send. A `/compact` that lands as a new turn starts is queued in
|
|
1310
|
+
* claude's prompt buffer and runs at the next idle prompt (see the
|
|
1311
|
+
* FUTURE-GAP note in src/agents/inject.ts); it is not a mid-generation
|
|
1312
|
+
* injection. We do not claim size===0 is atomic.
|
|
1313
|
+
*/
|
|
1314
|
+
function maybeProactiveCompact(): void {
|
|
1315
|
+
if (compactDispatching) return;
|
|
1316
|
+
|
|
1317
|
+
const agentName = process.env.SWITCHROOM_AGENT_NAME;
|
|
1318
|
+
if (!agentName) return;
|
|
1319
|
+
|
|
1320
|
+
let cap: number | undefined;
|
|
1321
|
+
try {
|
|
1322
|
+
const cfg = loadSwitchroomConfig();
|
|
1323
|
+
// Resolve through the cascade so a fleet-wide
|
|
1324
|
+
// `defaults.session.max_context_tokens` applies even when the agent
|
|
1325
|
+
// has no explicit per-agent session block (rawAgent → {}).
|
|
1326
|
+
const rawAgent = cfg.agents?.[agentName] ?? {};
|
|
1327
|
+
const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent);
|
|
1328
|
+
cap = resolved.session?.max_context_tokens;
|
|
1329
|
+
} catch {
|
|
1330
|
+
// Best-effort — config may be unreadable in odd boot states; a
|
|
1331
|
+
// failed read just means "no proactive compaction this pass".
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
if (cap == null || cap <= 0) return; // opt-in: unset → native compaction only
|
|
1335
|
+
|
|
1336
|
+
const file = lastSessionActiveFile;
|
|
1337
|
+
if (!file) return;
|
|
1338
|
+
|
|
1339
|
+
const turns = readTurnUsages(file, 1);
|
|
1340
|
+
if (turns.length === 0) return;
|
|
1341
|
+
const t = turns[0];
|
|
1342
|
+
const occupancy = t.input + t.cacheRead + t.cacheCreate;
|
|
1343
|
+
|
|
1344
|
+
const wasArmed = compactState.armed;
|
|
1345
|
+
const decision = decideProactiveCompact(compactState, occupancy, cap);
|
|
1346
|
+
compactState = decision.state;
|
|
1347
|
+
// Re-arm edge: the decider only flips disarmed→armed when post-
|
|
1348
|
+
// /compact occupancy fell below 0.6×cap — i.e. context verifiably
|
|
1349
|
+
// shrank. That edge is the honest "compaction finished" signal.
|
|
1350
|
+
const rearmed = !wasArmed && decision.state.armed;
|
|
1351
|
+
|
|
1352
|
+
// User-facing notification transitions (pure). Resolved synchronously
|
|
1353
|
+
// here — before any await and before the !fire return — so a
|
|
1354
|
+
// re-entrant purge pass can't double-post (compactState was already
|
|
1355
|
+
// persisted/disarmed above). All card I/O is reached only past the
|
|
1356
|
+
// `cap == null` opt-in return earlier in this function, so a fleet
|
|
1357
|
+
// with the feature off never posts anything (Defaults preserved).
|
|
1358
|
+
const nt = nextCompactNotify(compactNotifyState, {
|
|
1359
|
+
fired: decision.fire,
|
|
1360
|
+
rearmed,
|
|
1361
|
+
activeFile: file,
|
|
1362
|
+
});
|
|
1363
|
+
compactNotifyState = nt.state;
|
|
1364
|
+
if (nt.action === 'finish') {
|
|
1365
|
+
void resolveCompactCard('finished', occupancy);
|
|
1366
|
+
} else if (nt.action === 'start' || nt.action === 'start-superseding') {
|
|
1367
|
+
if (nt.action === 'start-superseding') {
|
|
1368
|
+
void resolveCompactCard('superseded', occupancy);
|
|
1369
|
+
}
|
|
1370
|
+
void postCompactCard(occupancy, cap);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (!decision.fire) return;
|
|
1374
|
+
|
|
1375
|
+
// Set the re-entrancy guard synchronously BEFORE the await so a
|
|
1376
|
+
// re-entrant purge pass can't double-dispatch (the decider already
|
|
1377
|
+
// disarmed + armed the cooldown in decision.state).
|
|
1378
|
+
compactDispatching = true;
|
|
1379
|
+
process.stderr.write(
|
|
1380
|
+
`telegram gateway: proactive /compact for ${agentName} ` +
|
|
1381
|
+
`(occupancy=${occupancy} >= cap=${cap})\n`,
|
|
1382
|
+
);
|
|
1383
|
+
void injectSlashCommandImpl(agentName, '/compact')
|
|
1384
|
+
.catch((err: unknown) => {
|
|
1385
|
+
process.stderr.write(
|
|
1386
|
+
`telegram gateway: proactive /compact inject failed for ` +
|
|
1387
|
+
`${agentName}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
1388
|
+
);
|
|
1389
|
+
})
|
|
1390
|
+
.finally(() => {
|
|
1391
|
+
compactDispatching = false;
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Post the START card for a proactive compaction. Best-effort: a failed
|
|
1397
|
+
* send just means no card (the compaction itself still happens). The
|
|
1398
|
+
* outstanding record + a wall-clock timeout are armed so the card can
|
|
1399
|
+
* never dangle if the re-arm edge never arrives (failed/idle case).
|
|
1400
|
+
*/
|
|
1401
|
+
async function postCompactCard(occ: number, cap: number): Promise<void> {
|
|
1402
|
+
try {
|
|
1403
|
+
const chatId = loadAccess().allowFrom[0];
|
|
1404
|
+
if (!chatId) return;
|
|
1405
|
+
const threadId = chatThreadMap.get(chatId);
|
|
1406
|
+
const text =
|
|
1407
|
+
`🗜️ <b>Context compaction</b>\n` +
|
|
1408
|
+
`Working context hit ~${occ.toLocaleString()} tokens ` +
|
|
1409
|
+
`(cap ${cap.toLocaleString()}) — running <code>/compact</code>. ` +
|
|
1410
|
+
`Older detail moves to Hindsight; I'll confirm here once the ` +
|
|
1411
|
+
`context has shrunk (may take a turn or two).`;
|
|
1412
|
+
const sent = await swallowingApiCall(
|
|
1413
|
+
() =>
|
|
1414
|
+
bot.api.sendMessage(chatId, text, {
|
|
1415
|
+
parse_mode: 'HTML',
|
|
1416
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
1417
|
+
}),
|
|
1418
|
+
{ chat_id: chatId, verb: 'proactiveCompact.start' },
|
|
1419
|
+
);
|
|
1420
|
+
const messageId = (sent as { message_id?: number } | undefined)
|
|
1421
|
+
?.message_id;
|
|
1422
|
+
if (typeof messageId !== 'number') return;
|
|
1423
|
+
const timer = setTimeout(() => {
|
|
1424
|
+
void resolveCompactCard('timeout', null);
|
|
1425
|
+
}, COMPACT_CARD_TIMEOUT_MS);
|
|
1426
|
+
timer.unref?.();
|
|
1427
|
+
outstandingCompactCard = {
|
|
1428
|
+
chatId,
|
|
1429
|
+
threadId,
|
|
1430
|
+
messageId,
|
|
1431
|
+
occAtStart: occ,
|
|
1432
|
+
capAtStart: cap,
|
|
1433
|
+
timer,
|
|
1434
|
+
};
|
|
1435
|
+
} catch (err) {
|
|
1436
|
+
process.stderr.write(
|
|
1437
|
+
`telegram gateway: proactive-compact start card failed: ` +
|
|
1438
|
+
`${err instanceof Error ? err.message : String(err)}\n`,
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* Resolve the outstanding START card to a terminal state by editing it
|
|
1445
|
+
* in place. `finished` is driven by the decider re-arm edge (context
|
|
1446
|
+
* verifiably shrank), `superseded` when a newer compaction starts first,
|
|
1447
|
+
* `timeout` by the wall-clock timer (re-arm never arrived). The
|
|
1448
|
+
* outstanding record + timer are cleared synchronously BEFORE the await
|
|
1449
|
+
* so a stale message_id can never be edited twice and the timer can't
|
|
1450
|
+
* double-fire. On `timeout` the pure notify state is also reset so a
|
|
1451
|
+
* future compaction starts a clean lifecycle.
|
|
1452
|
+
*/
|
|
1453
|
+
async function resolveCompactCard(
|
|
1454
|
+
kind: 'finished' | 'superseded' | 'timeout',
|
|
1455
|
+
occNow: number | null,
|
|
1456
|
+
): Promise<void> {
|
|
1457
|
+
const card = outstandingCompactCard;
|
|
1458
|
+
if (!card) return;
|
|
1459
|
+
outstandingCompactCard = null;
|
|
1460
|
+
clearTimeout(card.timer);
|
|
1461
|
+
if (kind === 'timeout') compactNotifyState = idleCompactNotifyState();
|
|
1462
|
+
let text: string;
|
|
1463
|
+
if (kind === 'finished') {
|
|
1464
|
+
text =
|
|
1465
|
+
`✅ <b>Context compacted</b>\n` +
|
|
1466
|
+
`Working context reduced` +
|
|
1467
|
+
(occNow != null
|
|
1468
|
+
? ` (~${card.occAtStart.toLocaleString()} → ` +
|
|
1469
|
+
`~${occNow.toLocaleString()} tokens)`
|
|
1470
|
+
: '') +
|
|
1471
|
+
`. Hindsight retains the detail.`;
|
|
1472
|
+
} else if (kind === 'superseded') {
|
|
1473
|
+
text =
|
|
1474
|
+
`↩️ <b>Context compaction superseded</b>\n` +
|
|
1475
|
+
`A newer compaction started before this one confirmed.`;
|
|
1476
|
+
} else {
|
|
1477
|
+
text =
|
|
1478
|
+
`⚠️ <b>Compaction issued</b>\n` +
|
|
1479
|
+
`<code>/compact</code> was requested but the context isn't ` +
|
|
1480
|
+
`confirmed reduced yet. Native compaction and Hindsight still apply.`;
|
|
1481
|
+
}
|
|
1482
|
+
try {
|
|
1483
|
+
await swallowingApiCall(
|
|
1484
|
+
() =>
|
|
1485
|
+
bot.api.editMessageText(card.chatId, card.messageId, text, {
|
|
1486
|
+
parse_mode: 'HTML',
|
|
1487
|
+
}),
|
|
1488
|
+
{ chat_id: card.chatId, verb: `proactiveCompact.${kind}` },
|
|
1489
|
+
);
|
|
1490
|
+
} catch (err) {
|
|
1491
|
+
process.stderr.write(
|
|
1492
|
+
`telegram gateway: proactive-compact ${kind} card edit failed: ` +
|
|
1493
|
+
`${err instanceof Error ? err.message : String(err)}\n`,
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1244
1498
|
function endStatusReaction(chatId: string, threadId: number | undefined, outcome: 'done' | 'error'): void {
|
|
1245
1499
|
const key = statusKey(chatId, threadId)
|
|
1246
1500
|
const ctrl = activeStatusReactions.get(key)
|
|
@@ -2997,6 +3251,9 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
2997
3251
|
},
|
|
2998
3252
|
|
|
2999
3253
|
onSessionEvent(_client: IpcClient, msg: SessionEventForward) {
|
|
3254
|
+
// Track the session-tail's attached file for the proactive-
|
|
3255
|
+
// compaction occupancy read (see maybeProactiveCompact).
|
|
3256
|
+
if (msg.activeFile) lastSessionActiveFile = msg.activeFile
|
|
3000
3257
|
const ev = msg.event as unknown as SessionEvent
|
|
3001
3258
|
// Pass the envelope's chatId so non-enqueue events can route to the
|
|
3002
3259
|
// correct card even when the driver's currentChatId is stale.
|
|
@@ -121,6 +121,15 @@ export interface SessionEventForward {
|
|
|
121
121
|
event: Record<string, unknown>;
|
|
122
122
|
chatId: string;
|
|
123
123
|
threadId?: number;
|
|
124
|
+
/**
|
|
125
|
+
* The session-tail's currently-attached JSONL path (its tracked
|
|
126
|
+
* `currentFile`, not an independent re-scan). Forwarded so the
|
|
127
|
+
* gateway's proactive-compaction check reads occupancy from the
|
|
128
|
+
* exact file the tailer is on — avoids the sub-agent-mtime /
|
|
129
|
+
* stale-rotation wrong-file hazard. Absent until the tailer has
|
|
130
|
+
* attached a file.
|
|
131
|
+
*/
|
|
132
|
+
activeFile?: string;
|
|
124
133
|
}
|
|
125
134
|
|
|
126
135
|
export interface PermissionRequestForward {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decision core for proactive context compaction
|
|
3
|
+
* (`session.max_context_tokens`). Kept side-effect-free so the
|
|
4
|
+
* anti-spam state machine — the part most prone to livelock /
|
|
5
|
+
* double-fire — is unit-testable in isolation. The impure shell
|
|
6
|
+
* (config load, session-file read, tmux `/compact` inject) lives in
|
|
7
|
+
* gateway.ts and calls `decideProactiveCompact` at the model-idle gate.
|
|
8
|
+
*
|
|
9
|
+
* Occupancy fed in by the caller = the latest usage-bearing assistant
|
|
10
|
+
* turn's `input + cache_read + cache_creation` tokens (the prefix the
|
|
11
|
+
* model re-read this turn ≈ live context-window fill). Not cumulative.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Hysteresis lower band: re-arm only once occupancy < fraction × cap. */
|
|
15
|
+
export const COMPACT_REARM_FRACTION = 0.6;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Turn-count re-fire floor: after a fire, skip this many idle
|
|
19
|
+
* evaluations regardless of occupancy. Guards against a slow
|
|
20
|
+
* post-`/compact` JSONL rotation still reading the pre-compact turn and
|
|
21
|
+
* triggering an immediate second compaction.
|
|
22
|
+
*/
|
|
23
|
+
export const COMPACT_COOLDOWN_TURNS = 3;
|
|
24
|
+
|
|
25
|
+
export interface CompactState {
|
|
26
|
+
/** False after a fire; re-armed only below the hysteresis band. */
|
|
27
|
+
armed: boolean;
|
|
28
|
+
/** Idle evaluations remaining before re-fire is even considered. */
|
|
29
|
+
cooldownTurns: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CompactDecision {
|
|
33
|
+
fire: boolean;
|
|
34
|
+
/** Next state — caller must persist this verbatim. */
|
|
35
|
+
state: CompactState;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function initialCompactState(): CompactState {
|
|
39
|
+
return { armed: true, cooldownTurns: 0 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decide whether to fire `/compact` this idle evaluation, given the
|
|
44
|
+
* current state, the measured occupancy, and the configured cap.
|
|
45
|
+
*
|
|
46
|
+
* Precedence (each returns early):
|
|
47
|
+
* 1. Cooldown floor — burn one turn, never fire.
|
|
48
|
+
* 2. Disarmed — re-arm iff occupancy < REARM_FRACTION × cap; never
|
|
49
|
+
* fire on the arming pass (so we can't arm and fire together).
|
|
50
|
+
* 3. Below cap — hold.
|
|
51
|
+
* 4. Armed and at/above cap — fire, disarm, start the cooldown.
|
|
52
|
+
*
|
|
53
|
+
* Livelock safety: once disarmed, step 2 is the ONLY path back to
|
|
54
|
+
* armed, and it requires occupancy to actually drop below the lower
|
|
55
|
+
* band. If a compaction fails to shrink context, the cap stays
|
|
56
|
+
* exceeded, occupancy never drops below 0.6×cap, and we stay disarmed
|
|
57
|
+
* — i.e. we degrade to "don't fire" rather than firing every turn.
|
|
58
|
+
*/
|
|
59
|
+
export function decideProactiveCompact(
|
|
60
|
+
state: CompactState,
|
|
61
|
+
occupancy: number,
|
|
62
|
+
cap: number,
|
|
63
|
+
): CompactDecision {
|
|
64
|
+
if (state.cooldownTurns > 0) {
|
|
65
|
+
return {
|
|
66
|
+
fire: false,
|
|
67
|
+
state: { armed: state.armed, cooldownTurns: state.cooldownTurns - 1 },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!state.armed) {
|
|
72
|
+
const armed = occupancy < cap * COMPACT_REARM_FRACTION;
|
|
73
|
+
return { fire: false, state: { armed, cooldownTurns: 0 } };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (occupancy < cap) {
|
|
77
|
+
return { fire: false, state };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
fire: true,
|
|
82
|
+
state: { armed: false, cooldownTurns: COMPACT_COOLDOWN_TURNS },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
nextCompactNotify,
|
|
4
|
+
idleCompactNotifyState,
|
|
5
|
+
type CompactNotifyState,
|
|
6
|
+
} from '../gateway/compact-notify.js'
|
|
7
|
+
|
|
8
|
+
const FILE_A = '/state/.../sess-a.jsonl'
|
|
9
|
+
const FILE_B = '/state/.../sess-b.jsonl'
|
|
10
|
+
|
|
11
|
+
describe('nextCompactNotify', () => {
|
|
12
|
+
it('idle + fired → start, awaiting with fileAtStart', () => {
|
|
13
|
+
const r = nextCompactNotify(idleCompactNotifyState(), {
|
|
14
|
+
fired: true,
|
|
15
|
+
rearmed: false,
|
|
16
|
+
activeFile: FILE_A,
|
|
17
|
+
})
|
|
18
|
+
expect(r.action).toBe('start')
|
|
19
|
+
expect(r.state).toEqual({ phase: 'awaiting', fileAtStart: FILE_A })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('idle + nothing → none', () => {
|
|
23
|
+
const r = nextCompactNotify(idleCompactNotifyState(), {
|
|
24
|
+
fired: false,
|
|
25
|
+
rearmed: false,
|
|
26
|
+
activeFile: FILE_A,
|
|
27
|
+
})
|
|
28
|
+
expect(r.action).toBe('none')
|
|
29
|
+
expect(r.state.phase).toBe('idle')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('idle + spurious rearm (no card outstanding) → none', () => {
|
|
33
|
+
const r = nextCompactNotify(idleCompactNotifyState(), {
|
|
34
|
+
fired: false,
|
|
35
|
+
rearmed: true,
|
|
36
|
+
activeFile: FILE_A,
|
|
37
|
+
})
|
|
38
|
+
expect(r.action).toBe('none')
|
|
39
|
+
expect(r.state.phase).toBe('idle')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('awaiting + rearm on SAME file → finish, back to idle', () => {
|
|
43
|
+
const awaiting: CompactNotifyState = { phase: 'awaiting', fileAtStart: FILE_A }
|
|
44
|
+
const r = nextCompactNotify(awaiting, {
|
|
45
|
+
fired: false,
|
|
46
|
+
rearmed: true,
|
|
47
|
+
activeFile: FILE_A,
|
|
48
|
+
})
|
|
49
|
+
expect(r.action).toBe('finish')
|
|
50
|
+
expect(r.state).toEqual(idleCompactNotifyState())
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('awaiting + rearm on DIFFERENT file → none (sub-agent false-positive guard)', () => {
|
|
54
|
+
const awaiting: CompactNotifyState = { phase: 'awaiting', fileAtStart: FILE_A }
|
|
55
|
+
const r = nextCompactNotify(awaiting, {
|
|
56
|
+
fired: false,
|
|
57
|
+
rearmed: true,
|
|
58
|
+
activeFile: FILE_B,
|
|
59
|
+
})
|
|
60
|
+
expect(r.action).toBe('none')
|
|
61
|
+
expect(r.state).toBe(awaiting) // unchanged, still awaiting
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('awaiting + rearm but activeFile null → none', () => {
|
|
65
|
+
const awaiting: CompactNotifyState = { phase: 'awaiting', fileAtStart: FILE_A }
|
|
66
|
+
const r = nextCompactNotify(awaiting, {
|
|
67
|
+
fired: false,
|
|
68
|
+
rearmed: true,
|
|
69
|
+
activeFile: null,
|
|
70
|
+
})
|
|
71
|
+
expect(r.action).toBe('none')
|
|
72
|
+
expect(r.state.phase).toBe('awaiting')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('awaiting + no rearm → none, stays awaiting (shell timeout owns dangling)', () => {
|
|
76
|
+
const awaiting: CompactNotifyState = { phase: 'awaiting', fileAtStart: FILE_A }
|
|
77
|
+
const r = nextCompactNotify(awaiting, {
|
|
78
|
+
fired: false,
|
|
79
|
+
rearmed: false,
|
|
80
|
+
activeFile: FILE_A,
|
|
81
|
+
})
|
|
82
|
+
expect(r.action).toBe('none')
|
|
83
|
+
expect(r.state.phase).toBe('awaiting')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('awaiting + fired again → start-superseding, awaiting with NEW fileAtStart', () => {
|
|
87
|
+
const awaiting: CompactNotifyState = { phase: 'awaiting', fileAtStart: FILE_A }
|
|
88
|
+
const r = nextCompactNotify(awaiting, {
|
|
89
|
+
fired: true,
|
|
90
|
+
rearmed: false,
|
|
91
|
+
activeFile: FILE_B,
|
|
92
|
+
})
|
|
93
|
+
expect(r.action).toBe('start-superseding')
|
|
94
|
+
expect(r.state).toEqual({ phase: 'awaiting', fileAtStart: FILE_B })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('full healthy cycle: fire → hold → rearm(same file) → idle', () => {
|
|
98
|
+
let s = idleCompactNotifyState()
|
|
99
|
+
const fires: string[] = []
|
|
100
|
+
const seq = [
|
|
101
|
+
{ fired: true, rearmed: false, activeFile: FILE_A }, // START
|
|
102
|
+
{ fired: false, rearmed: false, activeFile: FILE_A }, // cooldown/hold
|
|
103
|
+
{ fired: false, rearmed: false, activeFile: FILE_A }, // hold
|
|
104
|
+
{ fired: false, rearmed: true, activeFile: FILE_A }, // FINISH
|
|
105
|
+
{ fired: false, rearmed: false, activeFile: FILE_A }, // idle, nothing
|
|
106
|
+
]
|
|
107
|
+
for (const ev of seq) {
|
|
108
|
+
const r = nextCompactNotify(s, ev)
|
|
109
|
+
s = r.state
|
|
110
|
+
fires.push(r.action)
|
|
111
|
+
}
|
|
112
|
+
expect(fires).toEqual(['start', 'none', 'none', 'finish', 'none'])
|
|
113
|
+
expect(s).toEqual(idleCompactNotifyState())
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('rearm that arrives only after a file flip never finishes the wrong card', () => {
|
|
117
|
+
// START on FILE_A; a sub-agent (FILE_B) leads and "rearms" → ignored;
|
|
118
|
+
// later the real drop is observed back on FILE_A → finish.
|
|
119
|
+
let s = nextCompactNotify(idleCompactNotifyState(), {
|
|
120
|
+
fired: true,
|
|
121
|
+
rearmed: false,
|
|
122
|
+
activeFile: FILE_A,
|
|
123
|
+
}).state
|
|
124
|
+
const mid = nextCompactNotify(s, {
|
|
125
|
+
fired: false,
|
|
126
|
+
rearmed: true,
|
|
127
|
+
activeFile: FILE_B,
|
|
128
|
+
})
|
|
129
|
+
expect(mid.action).toBe('none')
|
|
130
|
+
s = mid.state
|
|
131
|
+
const fin = nextCompactNotify(s, {
|
|
132
|
+
fired: false,
|
|
133
|
+
rearmed: true,
|
|
134
|
+
activeFile: FILE_A,
|
|
135
|
+
})
|
|
136
|
+
expect(fin.action).toBe('finish')
|
|
137
|
+
})
|
|
138
|
+
})
|