switchroom 0.12.17 → 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/cli/switchroom.js
CHANGED
|
@@ -47243,8 +47243,8 @@ var {
|
|
|
47243
47243
|
} = import__.default;
|
|
47244
47244
|
|
|
47245
47245
|
// src/build-info.ts
|
|
47246
|
-
var VERSION = "0.12.
|
|
47247
|
-
var COMMIT_SHA = "
|
|
47246
|
+
var VERSION = "0.12.18";
|
|
47247
|
+
var COMMIT_SHA = "8b848c7b";
|
|
47248
47248
|
|
|
47249
47249
|
// src/cli/agent.ts
|
|
47250
47250
|
init_source();
|
package/package.json
CHANGED
|
@@ -42472,6 +42472,27 @@ function decideProactiveCompact(state3, occupancy, cap) {
|
|
|
42472
42472
|
};
|
|
42473
42473
|
}
|
|
42474
42474
|
|
|
42475
|
+
// gateway/compact-notify.ts
|
|
42476
|
+
function idleCompactNotifyState() {
|
|
42477
|
+
return { phase: "idle", fileAtStart: null };
|
|
42478
|
+
}
|
|
42479
|
+
function nextCompactNotify(state3, ev) {
|
|
42480
|
+
if (ev.fired) {
|
|
42481
|
+
const superseding = state3.phase === "awaiting";
|
|
42482
|
+
return {
|
|
42483
|
+
state: { phase: "awaiting", fileAtStart: ev.activeFile },
|
|
42484
|
+
action: superseding ? "start-superseding" : "start"
|
|
42485
|
+
};
|
|
42486
|
+
}
|
|
42487
|
+
if (state3.phase === "awaiting") {
|
|
42488
|
+
if (ev.rearmed && ev.activeFile != null && ev.activeFile === state3.fileAtStart) {
|
|
42489
|
+
return { state: idleCompactNotifyState(), action: "finish" };
|
|
42490
|
+
}
|
|
42491
|
+
return { state: state3, action: "none" };
|
|
42492
|
+
}
|
|
42493
|
+
return { state: state3, action: "none" };
|
|
42494
|
+
}
|
|
42495
|
+
|
|
42475
42496
|
// gateway/hostd-dispatch.ts
|
|
42476
42497
|
import { existsSync as existsSync20 } from "node:fs";
|
|
42477
42498
|
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
@@ -46903,11 +46924,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
46903
46924
|
}
|
|
46904
46925
|
|
|
46905
46926
|
// ../src/build-info.ts
|
|
46906
|
-
var VERSION = "0.12.
|
|
46907
|
-
var COMMIT_SHA = "
|
|
46908
|
-
var COMMIT_DATE = "2026-05-
|
|
46927
|
+
var VERSION = "0.12.18";
|
|
46928
|
+
var COMMIT_SHA = "8b848c7b";
|
|
46929
|
+
var COMMIT_DATE = "2026-05-19T22:26:42+10:00";
|
|
46909
46930
|
var LATEST_PR = null;
|
|
46910
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
46931
|
+
var COMMITS_AHEAD_OF_TAG = 2;
|
|
46911
46932
|
|
|
46912
46933
|
// gateway/boot-version.ts
|
|
46913
46934
|
function formatRelativeAgo(iso) {
|
|
@@ -47815,6 +47836,9 @@ var pendingRestarts = new Map;
|
|
|
47815
47836
|
var lastSessionActiveFile = null;
|
|
47816
47837
|
var compactState = initialCompactState();
|
|
47817
47838
|
var compactDispatching = false;
|
|
47839
|
+
var COMPACT_CARD_TIMEOUT_MS = 900000;
|
|
47840
|
+
var compactNotifyState = idleCompactNotifyState();
|
|
47841
|
+
var outstandingCompactCard = null;
|
|
47818
47842
|
var activeDraftStreams = new Map;
|
|
47819
47843
|
var activeDraftParseModes = new Map;
|
|
47820
47844
|
var suppressPtyPreview = new Set;
|
|
@@ -47898,8 +47922,24 @@ function maybeProactiveCompact() {
|
|
|
47898
47922
|
return;
|
|
47899
47923
|
const t = turns[0];
|
|
47900
47924
|
const occupancy = t.input + t.cacheRead + t.cacheCreate;
|
|
47925
|
+
const wasArmed = compactState.armed;
|
|
47901
47926
|
const decision = decideProactiveCompact(compactState, occupancy, cap);
|
|
47902
47927
|
compactState = decision.state;
|
|
47928
|
+
const rearmed = !wasArmed && decision.state.armed;
|
|
47929
|
+
const nt = nextCompactNotify(compactNotifyState, {
|
|
47930
|
+
fired: decision.fire,
|
|
47931
|
+
rearmed,
|
|
47932
|
+
activeFile: file
|
|
47933
|
+
});
|
|
47934
|
+
compactNotifyState = nt.state;
|
|
47935
|
+
if (nt.action === "finish") {
|
|
47936
|
+
resolveCompactCard("finished", occupancy);
|
|
47937
|
+
} else if (nt.action === "start" || nt.action === "start-superseding") {
|
|
47938
|
+
if (nt.action === "start-superseding") {
|
|
47939
|
+
resolveCompactCard("superseded", occupancy);
|
|
47940
|
+
}
|
|
47941
|
+
postCompactCard(occupancy, cap);
|
|
47942
|
+
}
|
|
47903
47943
|
if (!decision.fire)
|
|
47904
47944
|
return;
|
|
47905
47945
|
compactDispatching = true;
|
|
@@ -47912,6 +47952,66 @@ function maybeProactiveCompact() {
|
|
|
47912
47952
|
compactDispatching = false;
|
|
47913
47953
|
});
|
|
47914
47954
|
}
|
|
47955
|
+
async function postCompactCard(occ, cap) {
|
|
47956
|
+
try {
|
|
47957
|
+
const chatId = loadAccess().allowFrom[0];
|
|
47958
|
+
if (!chatId)
|
|
47959
|
+
return;
|
|
47960
|
+
const threadId = chatThreadMap.get(chatId);
|
|
47961
|
+
const text = `\uD83D\uDDDC\uFE0F <b>Context compaction</b>
|
|
47962
|
+
` + `Working context hit ~${occ.toLocaleString()} tokens (cap ${cap.toLocaleString()}) \u2014 running <code>/compact</code>. ` + `Older detail moves to Hindsight; I'll confirm here once the context has shrunk (may take a turn or two).`;
|
|
47963
|
+
const sent = await swallowingApiCall(() => bot.api.sendMessage(chatId, text, {
|
|
47964
|
+
parse_mode: "HTML",
|
|
47965
|
+
...threadId != null ? { message_thread_id: threadId } : {}
|
|
47966
|
+
}), { chat_id: chatId, verb: "proactiveCompact.start" });
|
|
47967
|
+
const messageId = sent?.message_id;
|
|
47968
|
+
if (typeof messageId !== "number")
|
|
47969
|
+
return;
|
|
47970
|
+
const timer2 = setTimeout(() => {
|
|
47971
|
+
resolveCompactCard("timeout", null);
|
|
47972
|
+
}, COMPACT_CARD_TIMEOUT_MS);
|
|
47973
|
+
timer2.unref?.();
|
|
47974
|
+
outstandingCompactCard = {
|
|
47975
|
+
chatId,
|
|
47976
|
+
threadId,
|
|
47977
|
+
messageId,
|
|
47978
|
+
occAtStart: occ,
|
|
47979
|
+
capAtStart: cap,
|
|
47980
|
+
timer: timer2
|
|
47981
|
+
};
|
|
47982
|
+
} catch (err) {
|
|
47983
|
+
process.stderr.write(`telegram gateway: proactive-compact start card failed: ${err instanceof Error ? err.message : String(err)}
|
|
47984
|
+
`);
|
|
47985
|
+
}
|
|
47986
|
+
}
|
|
47987
|
+
async function resolveCompactCard(kind, occNow) {
|
|
47988
|
+
const card = outstandingCompactCard;
|
|
47989
|
+
if (!card)
|
|
47990
|
+
return;
|
|
47991
|
+
outstandingCompactCard = null;
|
|
47992
|
+
clearTimeout(card.timer);
|
|
47993
|
+
if (kind === "timeout")
|
|
47994
|
+
compactNotifyState = idleCompactNotifyState();
|
|
47995
|
+
let text;
|
|
47996
|
+
if (kind === "finished") {
|
|
47997
|
+
text = `\u2705 <b>Context compacted</b>
|
|
47998
|
+
` + `Working context reduced` + (occNow != null ? ` (~${card.occAtStart.toLocaleString()} \u2192 ` + `~${occNow.toLocaleString()} tokens)` : "") + `. Hindsight retains the detail.`;
|
|
47999
|
+
} else if (kind === "superseded") {
|
|
48000
|
+
text = `\u21A9\uFE0F <b>Context compaction superseded</b>
|
|
48001
|
+
` + `A newer compaction started before this one confirmed.`;
|
|
48002
|
+
} else {
|
|
48003
|
+
text = `\u26A0\uFE0F <b>Compaction issued</b>
|
|
48004
|
+
` + `<code>/compact</code> was requested but the context isn't confirmed reduced yet. Native compaction and Hindsight still apply.`;
|
|
48005
|
+
}
|
|
48006
|
+
try {
|
|
48007
|
+
await swallowingApiCall(() => bot.api.editMessageText(card.chatId, card.messageId, text, {
|
|
48008
|
+
parse_mode: "HTML"
|
|
48009
|
+
}), { chat_id: card.chatId, verb: `proactiveCompact.${kind}` });
|
|
48010
|
+
} catch (err) {
|
|
48011
|
+
process.stderr.write(`telegram gateway: proactive-compact ${kind} card edit failed: ${err instanceof Error ? err.message : String(err)}
|
|
48012
|
+
`);
|
|
48013
|
+
}
|
|
48014
|
+
}
|
|
47915
48015
|
function endStatusReaction(chatId, threadId, outcome) {
|
|
47916
48016
|
const key = statusKey(chatId, threadId);
|
|
47917
48017
|
const ctrl = activeStatusReactions.get(key);
|
|
@@ -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
|
+
}
|
|
@@ -230,6 +230,7 @@ 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
231
|
import { readTurnUsages } from '../../src/agents/perf.js'
|
|
232
232
|
import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
|
|
233
|
+
import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
|
|
233
234
|
import {
|
|
234
235
|
tryHostdDispatch,
|
|
235
236
|
hostdRequestId,
|
|
@@ -1086,6 +1087,25 @@ let lastSessionActiveFile: string | null = null
|
|
|
1086
1087
|
// turn and we must not double-dispatch before the first send settles.
|
|
1087
1088
|
let compactState: CompactState = initialCompactState()
|
|
1088
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
|
|
1089
1109
|
const activeDraftStreams = new Map<string, DraftStreamHandle>()
|
|
1090
1110
|
const activeDraftParseModes = new Map<string, 'HTML' | 'MarkdownV2' | undefined>()
|
|
1091
1111
|
const suppressPtyPreview = new Set<string>()
|
|
@@ -1321,8 +1341,35 @@ function maybeProactiveCompact(): void {
|
|
|
1321
1341
|
const t = turns[0];
|
|
1322
1342
|
const occupancy = t.input + t.cacheRead + t.cacheCreate;
|
|
1323
1343
|
|
|
1344
|
+
const wasArmed = compactState.armed;
|
|
1324
1345
|
const decision = decideProactiveCompact(compactState, occupancy, cap);
|
|
1325
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
|
+
|
|
1326
1373
|
if (!decision.fire) return;
|
|
1327
1374
|
|
|
1328
1375
|
// Set the re-entrancy guard synchronously BEFORE the await so a
|
|
@@ -1345,6 +1392,109 @@ function maybeProactiveCompact(): void {
|
|
|
1345
1392
|
});
|
|
1346
1393
|
}
|
|
1347
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
|
+
|
|
1348
1498
|
function endStatusReaction(chatId: string, threadId: number | undefined, outcome: 'done' | 'error'): void {
|
|
1349
1499
|
const key = statusKey(chatId, threadId)
|
|
1350
1500
|
const ctrl = activeStatusReactions.get(key)
|
|
@@ -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
|
+
})
|