@tekmidian/pai 0.35.2 → 0.36.1
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/{auto-route-DVM3U2ZY.mjs → auto-route-Byf8ENXj.mjs} +4 -4
- package/dist/{auto-route-DVM3U2ZY.mjs.map → auto-route-Byf8ENXj.mjs.map} +1 -1
- package/dist/cli/index.mjs +25 -17
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/probe2.mjs +2 -0
- package/dist/cli/program.d.mts.map +1 -1
- package/dist/cli/program.mjs +16 -267
- package/dist/{clusters-CzGxefB7.mjs → clusters-wZgTCYCB.mjs} +2 -2
- package/dist/{clusters-CzGxefB7.mjs.map → clusters-wZgTCYCB.mjs.map} +1 -1
- package/dist/{config-BSkVcvfq.mjs → config-B64vFg14.mjs} +3 -12
- package/dist/{config-BSkVcvfq.mjs.map → config-B64vFg14.mjs.map} +1 -1
- package/dist/config-C_ErGddD.mjs +3 -0
- package/dist/{checkpoint-block-D3rm4dAJ.mjs → context-handover-cache-PtNvj_8D.mjs} +40 -8
- package/dist/context-handover-cache-PtNvj_8D.mjs.map +1 -0
- package/dist/daemon/index.mjs +19 -19
- package/dist/{daemon-B8L2f5vy.mjs → daemon-BZ93KRBo.mjs} +33 -38
- package/dist/daemon-BZ93KRBo.mjs.map +1 -0
- package/dist/daemon-DRdoA489.mjs +20 -0
- package/dist/daemon-mcp/index.mjs +2 -2
- package/dist/{db-BtuN768f.mjs → db-Ca5qfsMC.mjs} +2 -4
- package/dist/{db-BtuN768f.mjs.map → db-Ca5qfsMC.mjs.map} +1 -1
- package/dist/db-O-cyAPfS.mjs +3 -0
- package/dist/db-XEwJbuGO.mjs +3 -0
- package/dist/{db-CYmBWcjh.mjs → db-a1ixZQjr.mjs} +2 -4
- package/dist/{db-CYmBWcjh.mjs.map → db-a1ixZQjr.mjs.map} +1 -1
- package/dist/{detect-Bf2z-oKB.mjs → detect-CdaA48EI.mjs} +1 -1
- package/dist/{detect-Bf2z-oKB.mjs.map → detect-CdaA48EI.mjs.map} +1 -1
- package/dist/{detector-BU-bsDXs.mjs → detector--Gg5JRN5.mjs} +3 -5
- package/dist/{detector-BU-bsDXs.mjs.map → detector--Gg5JRN5.mjs.map} +1 -1
- package/dist/detector-DGAk1iBR.mjs +5 -0
- package/dist/embeddings-CEBGrzwu.mjs +3 -0
- package/dist/{embeddings-Bn86ssxR.mjs → embeddings-DOLZnT1X.mjs} +2 -12
- package/dist/{embeddings-Bn86ssxR.mjs.map → embeddings-DOLZnT1X.mjs.map} +1 -1
- package/dist/{factory-vPTPoBR7.mjs → factory-Bsp7xOpO.mjs} +9 -12
- package/dist/{factory-vPTPoBR7.mjs.map → factory-Bsp7xOpO.mjs.map} +1 -1
- package/dist/factory-CrokPMk2.mjs +3 -0
- package/dist/{helpers-crDEr6S2.mjs → helpers-IjZkXBhj.mjs} +1 -1
- package/dist/{helpers-crDEr6S2.mjs.map → helpers-IjZkXBhj.mjs.map} +1 -1
- package/dist/hooks/context-compression-hook.mjs +217 -70
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/index.mjs +10 -10
- package/dist/{indexer-backend-Bg7VDpGt.mjs → indexer-backend-nQZuEx6N.mjs} +3 -3
- package/dist/{indexer-backend-Bg7VDpGt.mjs.map → indexer-backend-nQZuEx6N.mjs.map} +1 -1
- package/dist/{ipc-client-aVKVERjJ.mjs → ipc-client-BmypMNYk.mjs} +13 -7
- package/dist/ipc-client-BmypMNYk.mjs.map +1 -0
- package/dist/{kg-entity-r8duqhi9.mjs → kg-entity-DbOMPdF9.mjs} +1 -1
- package/dist/{kg-entity-r8duqhi9.mjs.map → kg-entity-DbOMPdF9.mjs.map} +1 -1
- package/dist/{latent-ideas-BL9m2HF9.mjs → latent-ideas-Bn6A5-5P.mjs} +4 -4
- package/dist/{latent-ideas-BL9m2HF9.mjs.map → latent-ideas-Bn6A5-5P.mjs.map} +1 -1
- package/dist/{link-boost-QFLrJwD6.mjs → link-boost-fYjUnxCN.mjs} +1 -1
- package/dist/{link-boost-QFLrJwD6.mjs.map → link-boost-fYjUnxCN.mjs.map} +1 -1
- package/dist/{main-resolver-CNSqU8wo.mjs → main-resolver-BAbhKpeX.mjs} +12 -14
- package/dist/main-resolver-BAbhKpeX.mjs.map +1 -0
- package/dist/main-resolver-Dxh444GO.mjs +4 -0
- package/dist/{migrate-fLD6rAdO.mjs → migrate-Cjzeefn9.mjs} +2 -2
- package/dist/{migrate-fLD6rAdO.mjs.map → migrate-Cjzeefn9.mjs.map} +1 -1
- package/dist/{neighborhood-BX89_nty.mjs → neighborhood-DpaEM991.mjs} +2 -2
- package/dist/{neighborhood-BX89_nty.mjs.map → neighborhood-DpaEM991.mjs.map} +1 -1
- package/dist/{note-context-d1wT_-GA.mjs → note-context-DrcY4cWm.mjs} +1 -1
- package/dist/{note-context-d1wT_-GA.mjs.map → note-context-DrcY4cWm.mjs.map} +1 -1
- package/dist/{pai-marker-B20KqhA8.mjs → pai-marker-CHtbJMwJ.mjs} +1 -1
- package/dist/{pai-marker-B20KqhA8.mjs.map → pai-marker-CHtbJMwJ.mjs.map} +1 -1
- package/dist/{postgres-CI3FjGq0.mjs → postgres-BVme6qX0.mjs} +2 -2
- package/dist/{postgres-CI3FjGq0.mjs.map → postgres-BVme6qX0.mjs.map} +1 -1
- package/dist/{pick-DatgS3Nk.mjs → program-C-fUghPv.mjs} +1298 -223
- package/dist/program-C-fUghPv.mjs.map +1 -0
- package/dist/query-feedback-BBMBp96K.mjs +3 -0
- package/dist/{query-feedback-D4U56Hz6.mjs → query-feedback-C1T6kS18.mjs} +2 -4
- package/dist/{query-feedback-D4U56Hz6.mjs.map → query-feedback-C1T6kS18.mjs.map} +1 -1
- package/dist/reranker-CwTCNsgA.mjs +3 -0
- package/dist/{reranker-CMNZcfVx.mjs → reranker-xPm04PXx.mjs} +2 -8
- package/dist/{reranker-CMNZcfVx.mjs.map → reranker-xPm04PXx.mjs.map} +1 -1
- package/dist/router-BMkOb62X.mjs +3 -0
- package/dist/{router-i9S19Usg.mjs → router-CsDm7HvK.mjs} +2 -4
- package/dist/{router-i9S19Usg.mjs.map → router-CsDm7HvK.mjs.map} +1 -1
- package/dist/{runtime-paths-B0P1TvUr.mjs → runtime-paths-rni52zHX.mjs} +1 -1
- package/dist/{runtime-paths-B0P1TvUr.mjs.map → runtime-paths-rni52zHX.mjs.map} +1 -1
- package/dist/search-CfPpJAWQ.mjs +4 -0
- package/dist/{search-C32zQ0V0.mjs → search-Rpk1cSBC.mjs} +4 -15
- package/dist/{search-C32zQ0V0.mjs.map → search-Rpk1cSBC.mjs.map} +1 -1
- package/dist/{sources-BDwN0B8i.mjs → sources-D8ZdNfvK.mjs} +2 -2
- package/dist/{sources-BDwN0B8i.mjs.map → sources-D8ZdNfvK.mjs.map} +1 -1
- package/dist/{sqlite-C6FHnMkn.mjs → sqlite-D1IaR8Am.mjs} +3 -3
- package/dist/{sqlite-C6FHnMkn.mjs.map → sqlite-D1IaR8Am.mjs.map} +1 -1
- package/dist/state-WaXhLr6R.mjs +70 -0
- package/dist/{state-DTvy-jRB.mjs.map → state-WaXhLr6R.mjs.map} +1 -1
- package/dist/state-qtmrBWCm.mjs +3 -0
- package/dist/{stop-words-BaMEGVeY.mjs → stop-words-Hfu8u22w.mjs} +1 -1
- package/dist/{stop-words-BaMEGVeY.mjs.map → stop-words-Hfu8u22w.mjs.map} +1 -1
- package/dist/{sync--BoxBBok.mjs → sync-BWbe8JTg.mjs} +3 -3
- package/dist/{sync--BoxBBok.mjs.map → sync-BWbe8JTg.mjs.map} +1 -1
- package/dist/{themes-BObEGMWn.mjs → themes-XPkj_bfP.mjs} +3 -3
- package/dist/{themes-BObEGMWn.mjs.map → themes-XPkj_bfP.mjs.map} +1 -1
- package/dist/tools-DEt6YPfc.mjs +5 -0
- package/dist/{tools-C1lCHerL.mjs → tools-ceiy7ANX.mjs} +28 -65
- package/dist/tools-ceiy7ANX.mjs.map +1 -0
- package/dist/{trace-h23JCcFD.mjs → trace-DfyGmMG_.mjs} +1 -1
- package/dist/{trace-h23JCcFD.mjs.map → trace-DfyGmMG_.mjs.map} +1 -1
- package/dist/{utils-BAxjW3j8.mjs → utils-9Err2RBW.mjs} +2 -22
- package/dist/{utils-BAxjW3j8.mjs.map → utils-9Err2RBW.mjs.map} +1 -1
- package/dist/utils-DhMex3Ox.mjs +3 -0
- package/dist/{vault-indexer-CUF9edbW.mjs → vault-indexer-CFvlPUMB.mjs} +2 -2
- package/dist/{vault-indexer-CUF9edbW.mjs.map → vault-indexer-CFvlPUMB.mjs.map} +1 -1
- package/dist/{work-queue-worker-BcDGAcF3.mjs → work-queue-worker-B8W8_3Rn.mjs} +201 -13
- package/dist/work-queue-worker-B8W8_3Rn.mjs.map +1 -0
- package/dist/work-queue-worker-HN2Ufg-L.mjs +11 -0
- package/dist/{zettelkasten-W-h8G2is.mjs → zettelkasten-CvjmMghT.mjs} +4 -4
- package/dist/{zettelkasten-W-h8G2is.mjs.map → zettelkasten-CvjmMghT.mjs.map} +1 -1
- package/package.json +1 -1
- package/src/hooks/ts/lib/context-fill.test.ts +628 -0
- package/src/hooks/ts/lib/context-fill.ts +668 -0
- package/src/hooks/ts/lib/context-handover-cache.ts +46 -0
- package/src/hooks/ts/lib/transcript-text.test.ts +125 -0
- package/src/hooks/ts/lib/transcript-text.ts +71 -0
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +131 -30
- package/statusline-command.sh +16 -0
- package/dist/checkpoint-block-D3rm4dAJ.mjs.map +0 -1
- package/dist/daemon-B8L2f5vy.mjs.map +0 -1
- package/dist/ipc-client-aVKVERjJ.mjs.map +0 -1
- package/dist/main-resolver-CNSqU8wo.mjs.map +0 -1
- package/dist/pick-DatgS3Nk.mjs.map +0 -1
- package/dist/rolldown-runtime-95iHPtFO.mjs +0 -18
- package/dist/state-DTvy-jRB.mjs +0 -102
- package/dist/tools-C1lCHerL.mjs.map +0 -1
- package/dist/work-queue-worker-BcDGAcF3.mjs.map +0 -1
- /package/dist/{indexer-AEcT8wHf.mjs → indexer-D7MvSQPY.mjs} +0 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-fill.ts — "how full is this session's context window, right now?"
|
|
3
|
+
*
|
|
4
|
+
* No hook payload carries this number directly (verified: PreCompact,
|
|
5
|
+
* UserPromptSubmit and PostToolUse stdin never include `context_window`).
|
|
6
|
+
* Two sources can reconstruct it, in order of preference:
|
|
7
|
+
*
|
|
8
|
+
* 1. STATUSLINE STATE FILE — statusline-command.sh receives the exact
|
|
9
|
+
* figure from Claude Code (`.context_window.used_percentage` /
|
|
10
|
+
* `.context_window.context_window_size`) on every render and persists
|
|
11
|
+
* it to `${TMPDIR}/pai-context-<session_id>.json`. This is authoritative
|
|
12
|
+
* but depends on the status line having rendered recently — a session
|
|
13
|
+
* whose terminal isn't drawing a status line (headless, backgrounded)
|
|
14
|
+
* leaves this file missing or stale.
|
|
15
|
+
*
|
|
16
|
+
* 2. TRANSCRIPT USAGE — every `message.usage` entry in the session's own
|
|
17
|
+
* .jsonl transcript already reports the token accounting for that one
|
|
18
|
+
* API call: `input_tokens + cache_read_input_tokens +
|
|
19
|
+
* cache_creation_input_tokens` on the MOST RECENT such entry *is* the
|
|
20
|
+
* current context fill, because each request resends the full context.
|
|
21
|
+
* This is why it must be the last entry's fields summed, never a sum
|
|
22
|
+
* across entries — summing across entries is a running token-spend
|
|
23
|
+
* total, not a fill reading (see calculateSessionTokens, a different
|
|
24
|
+
* metric answering a different question).
|
|
25
|
+
*
|
|
26
|
+
* When neither source is available, the answer is UNKNOWN, not zero. A zero
|
|
27
|
+
* reads as "plenty of room" and would silently suppress every downstream
|
|
28
|
+
* decision that depends on this number (the compaction handover chief among
|
|
29
|
+
* them) for the entire session.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
33
|
+
import { homedir, tmpdir } from "node:os";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
|
|
36
|
+
/** Fallback window size when nothing on hand reports one. Matches the
|
|
37
|
+
* default statusline-command.sh already falls back to. */
|
|
38
|
+
export const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
39
|
+
|
|
40
|
+
/** How many trailing lines of the transcript to scan for a usage entry.
|
|
41
|
+
* A single turn is rarely more than a handful of JSONL lines (assistant
|
|
42
|
+
* text + tool_use/tool_result pairs), so 40 comfortably covers one turn
|
|
43
|
+
* even a busy one, without reading the whole file on every check. */
|
|
44
|
+
const TRANSCRIPT_TAIL_LINES = 40;
|
|
45
|
+
|
|
46
|
+
/** A statusline reading older than this is treated as not there at all —
|
|
47
|
+
* a stale number is worse than none, because it looks confident. */
|
|
48
|
+
const STATUSLINE_STALE_MS = 5 * 60 * 1000; // 5 minutes
|
|
49
|
+
|
|
50
|
+
export type ContextFillSource = "statusline" | "transcript" | "unknown";
|
|
51
|
+
|
|
52
|
+
export interface ContextFillReading {
|
|
53
|
+
status: "ok" | "unknown";
|
|
54
|
+
/** Tokens currently occupying the context window, or null when unknown. */
|
|
55
|
+
usedTokens: number | null;
|
|
56
|
+
windowSize: number;
|
|
57
|
+
/** usedTokens / windowSize, or null when unknown. Not clamped to [0,1] —
|
|
58
|
+
* callers that need a clamped display value do that themselves so the
|
|
59
|
+
* raw reading (which can legitimately exceed 1 for a moment) is never
|
|
60
|
+
* silently rewritten here. */
|
|
61
|
+
fraction: number | null;
|
|
62
|
+
source: ContextFillSource;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function unknownReading(windowSize: number): ContextFillReading {
|
|
66
|
+
return { status: "unknown", usedTokens: null, windowSize, fraction: null, source: "unknown" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Source 1 — statusline state file
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
export function statuslineStateFilePath(sessionId: string): string {
|
|
74
|
+
return join(tmpdir(), `pai-context-${sessionId}.json`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface StatuslineState {
|
|
78
|
+
used_percentage?: number;
|
|
79
|
+
context_window_size?: number;
|
|
80
|
+
session_id?: string;
|
|
81
|
+
timestamp?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read the state file statusline-command.sh writes on every render.
|
|
86
|
+
* Returns null when the file is missing, unparsable, or older than
|
|
87
|
+
* STATUSLINE_STALE_MS — all three mean "not a source right now".
|
|
88
|
+
*/
|
|
89
|
+
export function readStatuslineFill(sessionId: string, now = Date.now()): ContextFillReading | null {
|
|
90
|
+
if (!sessionId) return null;
|
|
91
|
+
const path = statuslineStateFilePath(sessionId);
|
|
92
|
+
if (!existsSync(path)) return null;
|
|
93
|
+
|
|
94
|
+
let raw: StatuslineState;
|
|
95
|
+
try {
|
|
96
|
+
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (typeof raw.timestamp !== "number" || now - raw.timestamp > STATUSLINE_STALE_MS) {
|
|
102
|
+
return null; // stale — fall through to the transcript source
|
|
103
|
+
}
|
|
104
|
+
if (typeof raw.used_percentage !== "number" || typeof raw.context_window_size !== "number") {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const windowSize = raw.context_window_size;
|
|
109
|
+
const usedTokens = Math.round((raw.used_percentage / 100) * windowSize);
|
|
110
|
+
return {
|
|
111
|
+
status: "ok",
|
|
112
|
+
usedTokens,
|
|
113
|
+
windowSize,
|
|
114
|
+
fraction: raw.used_percentage / 100,
|
|
115
|
+
source: "statusline",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Source 2 — transcript usage (fallback)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
interface UsageEntry {
|
|
124
|
+
input_tokens?: number;
|
|
125
|
+
cache_read_input_tokens?: number;
|
|
126
|
+
cache_creation_input_tokens?: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Sum the three context-carrying fields of ONE usage object — never across
|
|
131
|
+
* usage objects. A single `message.usage` already reports what that one API
|
|
132
|
+
* call sent as context; adding another entry's numbers to it produces a
|
|
133
|
+
* cumulative spend figure, not a fill reading, and can exceed the window
|
|
134
|
+
* size many times over on a long session (the exact defect this helper
|
|
135
|
+
* exists to not repeat — see the PreCompact header fix in the same change).
|
|
136
|
+
*/
|
|
137
|
+
function usageTotal(usage: UsageEntry): number {
|
|
138
|
+
return (
|
|
139
|
+
(usage.input_tokens || 0) +
|
|
140
|
+
(usage.cache_read_input_tokens || 0) +
|
|
141
|
+
(usage.cache_creation_input_tokens || 0)
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Derive context fill from the tail of a .jsonl transcript: the most recent
|
|
147
|
+
* `message.usage` entry, read from the end backwards so a trailing line with
|
|
148
|
+
* no usage field (a plain text turn, a tool_result) doesn't hide one just
|
|
149
|
+
* before it.
|
|
150
|
+
*/
|
|
151
|
+
export function contextFillFromTranscript(
|
|
152
|
+
transcriptPath: string,
|
|
153
|
+
windowSize = DEFAULT_CONTEXT_WINDOW
|
|
154
|
+
): ContextFillReading {
|
|
155
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return unknownReading(windowSize);
|
|
156
|
+
|
|
157
|
+
let raw: string;
|
|
158
|
+
try {
|
|
159
|
+
raw = readFileSync(transcriptPath, "utf-8");
|
|
160
|
+
} catch {
|
|
161
|
+
return unknownReading(windowSize);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const lines = raw.trim().split("\n").filter((l) => l.trim());
|
|
165
|
+
const tail = lines.slice(-TRANSCRIPT_TAIL_LINES);
|
|
166
|
+
|
|
167
|
+
for (let i = tail.length - 1; i >= 0; i--) {
|
|
168
|
+
let entry: { message?: { usage?: UsageEntry } };
|
|
169
|
+
try {
|
|
170
|
+
entry = JSON.parse(tail[i]);
|
|
171
|
+
} catch {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const usage = entry?.message?.usage;
|
|
175
|
+
if (usage && typeof usage === "object") {
|
|
176
|
+
const usedTokens = usageTotal(usage);
|
|
177
|
+
return {
|
|
178
|
+
status: "ok",
|
|
179
|
+
usedTokens,
|
|
180
|
+
windowSize,
|
|
181
|
+
fraction: usedTokens / windowSize,
|
|
182
|
+
source: "transcript",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return unknownReading(windowSize);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Precedence — statusline (fresh) > transcript > unknown
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
export function getContextFill(
|
|
195
|
+
input: { sessionId?: string; transcriptPath?: string; windowSize?: number },
|
|
196
|
+
now = Date.now()
|
|
197
|
+
): ContextFillReading {
|
|
198
|
+
const windowSize = input.windowSize ?? DEFAULT_CONTEXT_WINDOW;
|
|
199
|
+
|
|
200
|
+
if (input.sessionId) {
|
|
201
|
+
const fromStatusline = readStatuslineFill(input.sessionId, now);
|
|
202
|
+
if (fromStatusline) return fromStatusline;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (input.transcriptPath) {
|
|
206
|
+
return contextFillFromTranscript(input.transcriptPath, windowSize);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return unknownReading(windowSize);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Display helper — clamp-and-flag rather than ever print an absurd number
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
export interface FillDisplay {
|
|
217
|
+
/** e.g. "63k" or "unknown". Never a number larger than the window. */
|
|
218
|
+
text: string;
|
|
219
|
+
/** True when the raw reading exceeded the window size and had to be
|
|
220
|
+
* clamped — that reading was a bug, not a fill, and callers may want to
|
|
221
|
+
* log it even though the displayed text is already safe. */
|
|
222
|
+
flagged: boolean;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function formatContextFill(reading: ContextFillReading): FillDisplay {
|
|
226
|
+
if (reading.status !== "ok" || reading.usedTokens === null) {
|
|
227
|
+
return { text: "unknown", flagged: false };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let used = reading.usedTokens;
|
|
231
|
+
let flagged = false;
|
|
232
|
+
if (used > reading.windowSize) {
|
|
233
|
+
flagged = true;
|
|
234
|
+
used = reading.windowSize;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const text = used > 1000 ? `${Math.round(used / 1000)}k` : String(used);
|
|
238
|
+
return { text: flagged ? `${text}+ (clamped — reading exceeded window)` : text, flagged };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Threshold-triggered handover — WHEN to warm up / refresh the model-written
|
|
243
|
+
// summary ahead of a compaction.
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
//
|
|
246
|
+
// THE CONFIGURED VALUE DOES NOT PREDICT THE TRIGGER. CLAUDE_AUTOCOMPACT_
|
|
247
|
+
// PCT_OVERRIDE was 80 throughout, on this machine, across BOTH of the
|
|
248
|
+
// following regimes — the same configured number, two different truths:
|
|
249
|
+
//
|
|
250
|
+
// 2026-08-16 .. 2026-09-10 preTokens ~ 993,096 – 1,002,500 (~100% of a 1M window)
|
|
251
|
+
// 2026-09-12 .. 2026-09-15 preTokens ~ 782,981 – 791,995 (~78-79% of a 1M window)
|
|
252
|
+
//
|
|
253
|
+
// A threshold derived only from the configured override was wrong for the
|
|
254
|
+
// second regime and would be wrong again the next time it moves — the
|
|
255
|
+
// config is not ground truth, it is what Claude Code claims it will honor.
|
|
256
|
+
//
|
|
257
|
+
// GROUND TRUTH IS ON DISK ALREADY: every real compaction writes a
|
|
258
|
+
// `compact_boundary` system event to the transcript with
|
|
259
|
+
// `compactMetadata.preTokens` — the platform's own count of context tokens
|
|
260
|
+
// immediately before IT compacted. `measureCompactionTrigger` scans a
|
|
261
|
+
// project's own transcripts for these and takes the MINIMUM of the most
|
|
262
|
+
// recent three (minimum, not mean: warm-up must fire before the earliest
|
|
263
|
+
// plausible boundary, not the average one). A project that has compacted
|
|
264
|
+
// before learns its own trigger and needs no code change when the regime
|
|
265
|
+
// moves again — the same derivation produced 998,508 pre-09-12 and ~784k
|
|
266
|
+
// after, from one unchanged formula.
|
|
267
|
+
//
|
|
268
|
+
// The configured chain — env override, then a default — is the fallback,
|
|
269
|
+
// used ONLY when a project has no compaction history yet:
|
|
270
|
+
//
|
|
271
|
+
// effectiveTrigger = measured ?? (windowSize * (override ?? 80) / 100)
|
|
272
|
+
//
|
|
273
|
+
// DEFAULT IS 80, NOT 100, WHEN NOTHING IS KNOWN. 100 was considered — it
|
|
274
|
+
// matches the FIRST regime above — and rejected: the costs are asymmetric.
|
|
275
|
+
// Warming up too early wastes one summary; cheap, invisible. Warming up too
|
|
276
|
+
// late produces exactly the degraded successor session this feature exists
|
|
277
|
+
// to prevent. 80 is the conservative default until a project has its own
|
|
278
|
+
// measured history to correct it.
|
|
279
|
+
//
|
|
280
|
+
// Margins below the effective trigger are absolute tokens, sized against the
|
|
281
|
+
// largest single-turn context jump measured: 62,383 tokens (517,952 →
|
|
282
|
+
// 580,335, about 27 seconds). warmup sits 100k below the trigger —
|
|
283
|
+
// comfortably more than that jump, so a session cannot leap clean over
|
|
284
|
+
// warmup straight into a compaction in one turn; refresh at 40k below
|
|
285
|
+
// catches a session that sat above warmup for a while; fireNow at 15k below
|
|
286
|
+
// is "no time left for a clean crossing" and fires immediately rather than
|
|
287
|
+
// waiting for one.
|
|
288
|
+
export const DEFAULT_AUTOCOMPACT_PCT = 80;
|
|
289
|
+
|
|
290
|
+
/** How many of the most recent compact_boundary events to consider, and to
|
|
291
|
+
* take the minimum of. */
|
|
292
|
+
const MEASURED_TRIGGER_SAMPLE_SIZE = 3;
|
|
293
|
+
|
|
294
|
+
export const THRESHOLD_MARGIN_TOKENS = {
|
|
295
|
+
warmup: 100_000,
|
|
296
|
+
refresh: 40_000,
|
|
297
|
+
immediate: 15_000,
|
|
298
|
+
} as const;
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// Measured trigger — scan a project's own transcript history for the
|
|
302
|
+
// platform's own compact_boundary events, ground truth over configuration.
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
//
|
|
305
|
+
// Deliberately re-implements the tiny bit of path encoding it needs (below)
|
|
306
|
+
// rather than importing project-utils/paths.ts's encodePath: that module
|
|
307
|
+
// chain ends in pai-paths.ts, which calls process.exit(1) if PAI_DIR does
|
|
308
|
+
// not resolve to an existing directory. context-fill.ts is specifically the
|
|
309
|
+
// module a hook falls back on when its environment is unreliable — pulling
|
|
310
|
+
// in a dependency that can kill the process on import would defeat that.
|
|
311
|
+
// Claude Code's transcript directory is a fixed, home-relative convention
|
|
312
|
+
// (~/.claude/projects/<encoded-cwd>/), not something PAI_DIR governs, so
|
|
313
|
+
// resolving it directly here is also just the more correct dependency, not
|
|
314
|
+
// only the safer one.
|
|
315
|
+
|
|
316
|
+
/** Real default — overridable per-call so tests never touch the user's
|
|
317
|
+
* actual ~/.claude/projects/ (a live directory this very session writes to). */
|
|
318
|
+
export const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
319
|
+
|
|
320
|
+
function encodeProjectPath(cwd: string): string {
|
|
321
|
+
return cwd.replace(/[/\s.-]/g, "-");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface CompactBoundarySample {
|
|
325
|
+
preTokens: number;
|
|
326
|
+
timestampMs: number;
|
|
327
|
+
/** ISO string, kept alongside timestampMs so callers can print it. */
|
|
328
|
+
timestamp: string;
|
|
329
|
+
/** The event's own uuid when present — the dedup key. Claude Code's
|
|
330
|
+
* `sessions/` archive directory mirrors the live project transcript, so
|
|
331
|
+
* the SAME compact_boundary event can legitimately appear in two files;
|
|
332
|
+
* without deduping by identity, "most recent three" can silently become
|
|
333
|
+
* three copies of one event, which is a stale reading wearing a
|
|
334
|
+
* plausible-looking sample size. */
|
|
335
|
+
uuid?: string;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Every `.jsonl` transcript belonging to a project — top-level (the live
|
|
339
|
+
* file) and `sessions/` (Claude Code's archive, which mirrors it). No file
|
|
340
|
+
* is excluded and no ordering is applied here: ordering by EVENT
|
|
341
|
+
* timestamp, not by file mtime, is the whole point (see
|
|
342
|
+
* readCompactBoundarySamples) — a file's mtime does not reliably track
|
|
343
|
+
* which events inside it are recent, and pre-filtering by mtime is exactly
|
|
344
|
+
* what caused this function to return a stale, pre-regime-change trigger
|
|
345
|
+
* on real data. */
|
|
346
|
+
function listProjectTranscripts(cwd: string, projectsDir: string): string[] {
|
|
347
|
+
const projectDir = join(projectsDir, encodeProjectPath(cwd));
|
|
348
|
+
if (!existsSync(projectDir)) return [];
|
|
349
|
+
|
|
350
|
+
const paths: string[] = [];
|
|
351
|
+
const collect = (dir: string): void => {
|
|
352
|
+
if (!existsSync(dir)) return;
|
|
353
|
+
let entries: string[];
|
|
354
|
+
try {
|
|
355
|
+
entries = readdirSync(dir);
|
|
356
|
+
} catch {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
for (const entry of entries) {
|
|
360
|
+
if (entry.endsWith(".jsonl")) paths.push(join(dir, entry));
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
collect(projectDir);
|
|
364
|
+
collect(join(projectDir, "sessions"));
|
|
365
|
+
return paths;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Every DISTINCT compact_boundary sample found across ALL of a project's
|
|
370
|
+
* transcripts (live + archived), newest first by the event's OWN timestamp
|
|
371
|
+
* — never by which file it came from or that file's mtime. Deduplicated by
|
|
372
|
+
* the event's uuid (falling back to a timestamp+preTokens key for the rare
|
|
373
|
+
* line with no uuid) so an event mirrored into `sessions/` is counted once.
|
|
374
|
+
*/
|
|
375
|
+
function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBoundarySample[] {
|
|
376
|
+
const byKey = new Map<string, CompactBoundarySample>();
|
|
377
|
+
|
|
378
|
+
for (const path of listProjectTranscripts(cwd, projectsDir)) {
|
|
379
|
+
let raw: string;
|
|
380
|
+
try {
|
|
381
|
+
raw = readFileSync(path, "utf-8");
|
|
382
|
+
} catch {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
for (const line of raw.split("\n")) {
|
|
387
|
+
if (!line.trim()) continue;
|
|
388
|
+
let entry: {
|
|
389
|
+
type?: string;
|
|
390
|
+
subtype?: string;
|
|
391
|
+
timestamp?: string;
|
|
392
|
+
uuid?: string;
|
|
393
|
+
compactMetadata?: { preTokens?: number };
|
|
394
|
+
};
|
|
395
|
+
try {
|
|
396
|
+
entry = JSON.parse(line);
|
|
397
|
+
} catch {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (entry.type !== "system" || entry.subtype !== "compact_boundary") continue;
|
|
401
|
+
const preTokens = entry.compactMetadata?.preTokens;
|
|
402
|
+
if (typeof preTokens !== "number" || !Number.isFinite(preTokens)) continue;
|
|
403
|
+
const timestampMs = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
|
|
404
|
+
if (!Number.isFinite(timestampMs)) continue;
|
|
405
|
+
|
|
406
|
+
const key = entry.uuid ?? `${timestampMs}:${preTokens}`;
|
|
407
|
+
if (!byKey.has(key)) {
|
|
408
|
+
byKey.set(key, { preTokens, timestampMs, timestamp: entry.timestamp!, uuid: entry.uuid });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return [...byKey.values()].sort((a, b) => b.timestampMs - a.timestampMs);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The most recent MEASURED_TRIGGER_SAMPLE_SIZE distinct compact_boundary
|
|
418
|
+
* events for a project, newest first — exposed on its own (not just the
|
|
419
|
+
* derived minimum) so the number `measureCompactionTrigger` returns is
|
|
420
|
+
* checkable: print these and the timestamps prove which three events
|
|
421
|
+
* produced it, rather than asking for trust.
|
|
422
|
+
*/
|
|
423
|
+
export function selectedCompactionSamples(
|
|
424
|
+
cwd: string,
|
|
425
|
+
projectsDir: string = CLAUDE_PROJECTS_DIR
|
|
426
|
+
): CompactBoundarySample[] {
|
|
427
|
+
if (!cwd) return [];
|
|
428
|
+
return readCompactBoundarySamples(cwd, projectsDir).slice(0, MEASURED_TRIGGER_SAMPLE_SIZE);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The measured compaction trigger for a project, or null when it has no
|
|
433
|
+
* compaction history yet (a brand-new project, or one whose transcripts
|
|
434
|
+
* this process cannot read). Minimum of the most recent
|
|
435
|
+
* MEASURED_TRIGGER_SAMPLE_SIZE DISTINCT compact_boundary events, ordered by
|
|
436
|
+
* the events' own timestamps across every transcript the project has
|
|
437
|
+
* (live and archived) — see the module comment above for why minimum, not
|
|
438
|
+
* mean, and readCompactBoundarySamples for why "distinct" and "own
|
|
439
|
+
* timestamp" both matter (a file-mtime-ordered, non-deduplicated version of
|
|
440
|
+
* this returned a stale pre-regime-change trigger on real project data).
|
|
441
|
+
*/
|
|
442
|
+
export function measureCompactionTrigger(
|
|
443
|
+
cwd: string,
|
|
444
|
+
projectsDir: string = CLAUDE_PROJECTS_DIR
|
|
445
|
+
): number | null {
|
|
446
|
+
const samples = selectedCompactionSamples(cwd, projectsDir);
|
|
447
|
+
if (samples.length === 0) return null;
|
|
448
|
+
const trigger = Math.min(...samples.map((s) => s.preTokens));
|
|
449
|
+
console.error(
|
|
450
|
+
`[context-fill] measured trigger for ${cwd}: ${trigger} (minimum of ` +
|
|
451
|
+
samples.map((s) => `${s.preTokens}@${s.timestamp}`).join(", ") + ")"
|
|
452
|
+
);
|
|
453
|
+
return trigger;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Read CLAUDE_AUTOCOMPACT_PCT_OVERRIDE from the environment. Absent →
|
|
458
|
+
* DEFAULT_AUTOCOMPACT_PCT. Present but not a finite number in (0, 100] →
|
|
459
|
+
* also DEFAULT_AUTOCOMPACT_PCT, logged, so a typo in the override degrades
|
|
460
|
+
* to the documented default instead of silently producing nonsense
|
|
461
|
+
* thresholds (0, negative, or a fraction so large no session ever reaches
|
|
462
|
+
* it).
|
|
463
|
+
*/
|
|
464
|
+
export function resolveAutocompactPct(env: NodeJS.ProcessEnv = process.env): number {
|
|
465
|
+
const raw = env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE;
|
|
466
|
+
if (raw === undefined || raw === "") return DEFAULT_AUTOCOMPACT_PCT;
|
|
467
|
+
|
|
468
|
+
const parsed = Number(raw);
|
|
469
|
+
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) {
|
|
470
|
+
console.error(
|
|
471
|
+
`[context-fill] CLAUDE_AUTOCOMPACT_PCT_OVERRIDE="${raw}" is not a usable percentage — ` +
|
|
472
|
+
`falling back to the default ${DEFAULT_AUTOCOMPACT_PCT}.`
|
|
473
|
+
);
|
|
474
|
+
return DEFAULT_AUTOCOMPACT_PCT;
|
|
475
|
+
}
|
|
476
|
+
return parsed;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The two thresholds that actually fire a handover job. "immediate" is not a
|
|
481
|
+
* third job — see `isImmediate` below — it only changes how urgently a
|
|
482
|
+
* crossing of these two is treated.
|
|
483
|
+
*/
|
|
484
|
+
export type ThresholdName = "warmup" | "refresh";
|
|
485
|
+
|
|
486
|
+
/** Which basis actually produced effectiveTriggerTokens — reported so the
|
|
487
|
+
* number is checkable rather than trusted. */
|
|
488
|
+
export type TriggerSource = "measured" | "configured" | "measured-clamped";
|
|
489
|
+
|
|
490
|
+
export interface ContextFillThresholds {
|
|
491
|
+
warmupTokens: number;
|
|
492
|
+
refreshTokens: number;
|
|
493
|
+
immediateTokens: number;
|
|
494
|
+
/** The derived compaction trigger these were measured back from —
|
|
495
|
+
* min(measured, configured) when a measured value exists. */
|
|
496
|
+
effectiveTriggerTokens: number;
|
|
497
|
+
/** The raw measured value from this project's own compact_boundary
|
|
498
|
+
* history, before any clamping — null when the project has no history.
|
|
499
|
+
* Kept alongside effectiveTriggerTokens so a clamp is visible rather
|
|
500
|
+
* than silent: a caller can see both what was measured and what was
|
|
501
|
+
* actually used. */
|
|
502
|
+
measuredTriggerTokens: number | null;
|
|
503
|
+
/** The raw configured-chain value (env override or default, as a
|
|
504
|
+
* fraction of the window) — always computed and reported, even when it
|
|
505
|
+
* wasn't what ended up being used. */
|
|
506
|
+
configuredTriggerTokens: number;
|
|
507
|
+
/** The autocompact percentage from the configured chain. */
|
|
508
|
+
autocompactPct: number;
|
|
509
|
+
/** "measured": a measured value existed and was <= configured, so it was
|
|
510
|
+
* used directly — the better estimate, since it reflects reality the
|
|
511
|
+
* configured percentage cannot know.
|
|
512
|
+
* "measured-clamped": a measured value existed but was HIGHER than
|
|
513
|
+
* configured — it reflects a regime that may no longer apply (this
|
|
514
|
+
* project's last compaction predates a since-changed trigger), so the
|
|
515
|
+
* lower, safer configured value was used instead.
|
|
516
|
+
* "configured": no measured value exists yet (no compaction history for
|
|
517
|
+
* this project) — the configured chain is all there is. */
|
|
518
|
+
triggerSource: TriggerSource;
|
|
519
|
+
/** True when these were computed against a window size Claude Code itself
|
|
520
|
+
* reported (the statusline source). False when the window is only an
|
|
521
|
+
* assumed default (the transcript-fallback source never learns the real
|
|
522
|
+
* window size) — callers should log this: a threshold silently computed
|
|
523
|
+
* against the wrong basis is the same class of fault as printing a token
|
|
524
|
+
* count larger than the window. */
|
|
525
|
+
windowConfirmed: boolean;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export interface ContextFillThresholdOpts {
|
|
529
|
+
/** The session's project directory, used to look up its own compaction
|
|
530
|
+
* history for the measured trigger. Omit when unknown — falls back to
|
|
531
|
+
* the configured chain, same as a project with no history yet. */
|
|
532
|
+
cwd?: string;
|
|
533
|
+
/** Override the measured-trigger lookup instead of scanning transcripts —
|
|
534
|
+
* primarily for tests. `null` forces the configured fallback even when
|
|
535
|
+
* `cwd` is given; `undefined` (the default) does the real lookup. */
|
|
536
|
+
measuredTrigger?: number | null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Derive warmup/refresh/immediate thresholds from a fill reading.
|
|
541
|
+
*
|
|
542
|
+
* effectiveTrigger = min(measured, configuredChainValue) when a measured
|
|
543
|
+
* value exists — NOT the measured value outright. A project whose newest
|
|
544
|
+
* compaction predates a regime change measures a stale-HIGH trigger: a real
|
|
545
|
+
* case on this machine measured 998,267 for a project whose ACTUAL current
|
|
546
|
+
* boundary (from a different project's fresher history, cross-checked
|
|
547
|
+
* independently) is ~784,000 — using 998,267 directly would compute a
|
|
548
|
+
* warm-up of 898,267, above the real boundary, so the handover would never
|
|
549
|
+
* fire there. The measurement is honest; it is just old.
|
|
550
|
+
*
|
|
551
|
+
* The minimum is correct in both directions: the measured value is the
|
|
552
|
+
* better estimate when it is LOWER than configured (it reflects reality the
|
|
553
|
+
* configured percentage cannot know — see the module comment above, the
|
|
554
|
+
* 100%-to-78% regime change this project itself lived through); it is
|
|
555
|
+
* unsafe when it is HIGHER (it reflects a regime that no longer applies).
|
|
556
|
+
* Taking the minimum costs nothing in the safe direction — one wasted
|
|
557
|
+
* summary if the project's regime actually did move up — and prevents the
|
|
558
|
+
* unsafe direction, where a stale-high measurement suppresses the handover
|
|
559
|
+
* past the real boundary. That asymmetry is the same one the 80-not-100
|
|
560
|
+
* default was chosen for.
|
|
561
|
+
*
|
|
562
|
+
* Margins below effectiveTrigger are clamped at 0 (and logged) for a window
|
|
563
|
+
* small enough that a margin would otherwise go negative — a pathological
|
|
564
|
+
* input should degrade to "fire immediately", never to a threshold below
|
|
565
|
+
* zero.
|
|
566
|
+
*/
|
|
567
|
+
export function contextFillThresholds(
|
|
568
|
+
reading: ContextFillReading,
|
|
569
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
570
|
+
opts: ContextFillThresholdOpts = {}
|
|
571
|
+
): ContextFillThresholds {
|
|
572
|
+
const windowConfirmed = reading.source === "statusline";
|
|
573
|
+
const autocompactPct = resolveAutocompactPct(env);
|
|
574
|
+
const configuredTriggerTokens = Math.round(reading.windowSize * (autocompactPct / 100));
|
|
575
|
+
|
|
576
|
+
const measuredTriggerTokens = opts.measuredTrigger !== undefined
|
|
577
|
+
? opts.measuredTrigger
|
|
578
|
+
: opts.cwd
|
|
579
|
+
? measureCompactionTrigger(opts.cwd)
|
|
580
|
+
: null;
|
|
581
|
+
|
|
582
|
+
let effectiveTriggerTokens: number;
|
|
583
|
+
let triggerSource: TriggerSource;
|
|
584
|
+
|
|
585
|
+
if (measuredTriggerTokens === null) {
|
|
586
|
+
effectiveTriggerTokens = configuredTriggerTokens;
|
|
587
|
+
triggerSource = "configured";
|
|
588
|
+
console.error(
|
|
589
|
+
`[context-fill] trigger source: CONFIGURED — no compaction history for this project yet. ` +
|
|
590
|
+
`measured=none, configured=${configuredTriggerTokens} (${autocompactPct}% of a ${reading.windowSize}-token ` +
|
|
591
|
+
`window) -> using ${effectiveTriggerTokens}.`
|
|
592
|
+
);
|
|
593
|
+
} else if (measuredTriggerTokens <= configuredTriggerTokens) {
|
|
594
|
+
effectiveTriggerTokens = measuredTriggerTokens;
|
|
595
|
+
triggerSource = "measured";
|
|
596
|
+
console.error(
|
|
597
|
+
`[context-fill] trigger source: MEASURED — measured=${measuredTriggerTokens} ` +
|
|
598
|
+
`(minimum of the most recent ${MEASURED_TRIGGER_SAMPLE_SIZE} compact_boundary events), ` +
|
|
599
|
+
`configured=${configuredTriggerTokens} -> using ${effectiveTriggerTokens} (measured, ≤ configured).`
|
|
600
|
+
);
|
|
601
|
+
} else {
|
|
602
|
+
effectiveTriggerTokens = configuredTriggerTokens;
|
|
603
|
+
triggerSource = "measured-clamped";
|
|
604
|
+
console.error(
|
|
605
|
+
`[context-fill] trigger source: MEASURED-CLAMPED — measured=${measuredTriggerTokens} is HIGHER than ` +
|
|
606
|
+
`configured=${configuredTriggerTokens} (a stale regime this project's history predates) -> ` +
|
|
607
|
+
`using ${effectiveTriggerTokens} (configured, the safer bound).`
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const clamp = (name: string, value: number): number => {
|
|
612
|
+
if (value >= 0) return value;
|
|
613
|
+
console.error(
|
|
614
|
+
`[context-fill] ${name} threshold went negative (${value}) for a ` +
|
|
615
|
+
`${reading.windowSize}-token window — clamping to 0.`
|
|
616
|
+
);
|
|
617
|
+
return 0;
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
warmupTokens: clamp("warmup", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.warmup),
|
|
622
|
+
refreshTokens: clamp("refresh", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.refresh),
|
|
623
|
+
immediateTokens: clamp("immediate", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.immediate),
|
|
624
|
+
effectiveTriggerTokens,
|
|
625
|
+
measuredTriggerTokens,
|
|
626
|
+
configuredTriggerTokens,
|
|
627
|
+
autocompactPct,
|
|
628
|
+
triggerSource,
|
|
629
|
+
windowConfirmed,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Which named thresholds `usedTokens` has newly crossed, given the ones that
|
|
635
|
+
* have already fired this session. Ascending order (warmup before refresh).
|
|
636
|
+
*
|
|
637
|
+
* A single check can return more than one name — e.g. a session first
|
|
638
|
+
* observed at 99% of its window crosses warmup and refresh in the same tick,
|
|
639
|
+
* because there was no earlier clean crossing to catch it at. That is the
|
|
640
|
+
* "fire immediately" case: the caller enqueues one handover and marks every
|
|
641
|
+
* newly-crossed name fired, rather than waiting for a crossing that already
|
|
642
|
+
* happened.
|
|
643
|
+
*/
|
|
644
|
+
export function crossedThresholds(
|
|
645
|
+
usedTokens: number,
|
|
646
|
+
thresholds: ContextFillThresholds,
|
|
647
|
+
alreadyFired: ThresholdName[]
|
|
648
|
+
): ThresholdName[] {
|
|
649
|
+
const fired = new Set(alreadyFired);
|
|
650
|
+
const ordered: Array<[ThresholdName, number]> = [
|
|
651
|
+
["warmup", thresholds.warmupTokens],
|
|
652
|
+
["refresh", thresholds.refreshTokens],
|
|
653
|
+
];
|
|
654
|
+
return ordered.filter(([name, tokens]) => !fired.has(name) && usedTokens >= tokens).map(([name]) => name);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* True once a session is at or above the "no time left for a clean crossing"
|
|
659
|
+
* floor (0.985 of the window). Purely informational for callers — it never
|
|
660
|
+
* gates whether `crossedThresholds` fires (a poll-based check already fires
|
|
661
|
+
* any unfired threshold the moment `usedTokens` reaches it, on whatever tick
|
|
662
|
+
* observes it) — but it distinguishes "this crossed on schedule" from "this
|
|
663
|
+
* was first observed already almost out of room", which is worth a different
|
|
664
|
+
* log line and, for a caller that queues work, a higher priority.
|
|
665
|
+
*/
|
|
666
|
+
export function isImmediate(usedTokens: number, thresholds: ContextFillThresholds): boolean {
|
|
667
|
+
return usedTokens >= thresholds.immediateTokens;
|
|
668
|
+
}
|