@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.
Files changed (126) hide show
  1. package/dist/{auto-route-DVM3U2ZY.mjs → auto-route-Byf8ENXj.mjs} +4 -4
  2. package/dist/{auto-route-DVM3U2ZY.mjs.map → auto-route-Byf8ENXj.mjs.map} +1 -1
  3. package/dist/cli/index.mjs +25 -17
  4. package/dist/cli/index.mjs.map +1 -1
  5. package/dist/cli/probe2.mjs +2 -0
  6. package/dist/cli/program.d.mts.map +1 -1
  7. package/dist/cli/program.mjs +16 -267
  8. package/dist/{clusters-CzGxefB7.mjs → clusters-wZgTCYCB.mjs} +2 -2
  9. package/dist/{clusters-CzGxefB7.mjs.map → clusters-wZgTCYCB.mjs.map} +1 -1
  10. package/dist/{config-BSkVcvfq.mjs → config-B64vFg14.mjs} +3 -12
  11. package/dist/{config-BSkVcvfq.mjs.map → config-B64vFg14.mjs.map} +1 -1
  12. package/dist/config-C_ErGddD.mjs +3 -0
  13. package/dist/{checkpoint-block-D3rm4dAJ.mjs → context-handover-cache-PtNvj_8D.mjs} +40 -8
  14. package/dist/context-handover-cache-PtNvj_8D.mjs.map +1 -0
  15. package/dist/daemon/index.mjs +19 -19
  16. package/dist/{daemon-B8L2f5vy.mjs → daemon-BZ93KRBo.mjs} +33 -38
  17. package/dist/daemon-BZ93KRBo.mjs.map +1 -0
  18. package/dist/daemon-DRdoA489.mjs +20 -0
  19. package/dist/daemon-mcp/index.mjs +2 -2
  20. package/dist/{db-BtuN768f.mjs → db-Ca5qfsMC.mjs} +2 -4
  21. package/dist/{db-BtuN768f.mjs.map → db-Ca5qfsMC.mjs.map} +1 -1
  22. package/dist/db-O-cyAPfS.mjs +3 -0
  23. package/dist/db-XEwJbuGO.mjs +3 -0
  24. package/dist/{db-CYmBWcjh.mjs → db-a1ixZQjr.mjs} +2 -4
  25. package/dist/{db-CYmBWcjh.mjs.map → db-a1ixZQjr.mjs.map} +1 -1
  26. package/dist/{detect-Bf2z-oKB.mjs → detect-CdaA48EI.mjs} +1 -1
  27. package/dist/{detect-Bf2z-oKB.mjs.map → detect-CdaA48EI.mjs.map} +1 -1
  28. package/dist/{detector-BU-bsDXs.mjs → detector--Gg5JRN5.mjs} +3 -5
  29. package/dist/{detector-BU-bsDXs.mjs.map → detector--Gg5JRN5.mjs.map} +1 -1
  30. package/dist/detector-DGAk1iBR.mjs +5 -0
  31. package/dist/embeddings-CEBGrzwu.mjs +3 -0
  32. package/dist/{embeddings-Bn86ssxR.mjs → embeddings-DOLZnT1X.mjs} +2 -12
  33. package/dist/{embeddings-Bn86ssxR.mjs.map → embeddings-DOLZnT1X.mjs.map} +1 -1
  34. package/dist/{factory-vPTPoBR7.mjs → factory-Bsp7xOpO.mjs} +9 -12
  35. package/dist/{factory-vPTPoBR7.mjs.map → factory-Bsp7xOpO.mjs.map} +1 -1
  36. package/dist/factory-CrokPMk2.mjs +3 -0
  37. package/dist/{helpers-crDEr6S2.mjs → helpers-IjZkXBhj.mjs} +1 -1
  38. package/dist/{helpers-crDEr6S2.mjs.map → helpers-IjZkXBhj.mjs.map} +1 -1
  39. package/dist/hooks/context-compression-hook.mjs +217 -70
  40. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  41. package/dist/index.mjs +10 -10
  42. package/dist/{indexer-backend-Bg7VDpGt.mjs → indexer-backend-nQZuEx6N.mjs} +3 -3
  43. package/dist/{indexer-backend-Bg7VDpGt.mjs.map → indexer-backend-nQZuEx6N.mjs.map} +1 -1
  44. package/dist/{ipc-client-aVKVERjJ.mjs → ipc-client-BmypMNYk.mjs} +13 -7
  45. package/dist/ipc-client-BmypMNYk.mjs.map +1 -0
  46. package/dist/{kg-entity-r8duqhi9.mjs → kg-entity-DbOMPdF9.mjs} +1 -1
  47. package/dist/{kg-entity-r8duqhi9.mjs.map → kg-entity-DbOMPdF9.mjs.map} +1 -1
  48. package/dist/{latent-ideas-BL9m2HF9.mjs → latent-ideas-Bn6A5-5P.mjs} +4 -4
  49. package/dist/{latent-ideas-BL9m2HF9.mjs.map → latent-ideas-Bn6A5-5P.mjs.map} +1 -1
  50. package/dist/{link-boost-QFLrJwD6.mjs → link-boost-fYjUnxCN.mjs} +1 -1
  51. package/dist/{link-boost-QFLrJwD6.mjs.map → link-boost-fYjUnxCN.mjs.map} +1 -1
  52. package/dist/{main-resolver-CNSqU8wo.mjs → main-resolver-BAbhKpeX.mjs} +12 -14
  53. package/dist/main-resolver-BAbhKpeX.mjs.map +1 -0
  54. package/dist/main-resolver-Dxh444GO.mjs +4 -0
  55. package/dist/{migrate-fLD6rAdO.mjs → migrate-Cjzeefn9.mjs} +2 -2
  56. package/dist/{migrate-fLD6rAdO.mjs.map → migrate-Cjzeefn9.mjs.map} +1 -1
  57. package/dist/{neighborhood-BX89_nty.mjs → neighborhood-DpaEM991.mjs} +2 -2
  58. package/dist/{neighborhood-BX89_nty.mjs.map → neighborhood-DpaEM991.mjs.map} +1 -1
  59. package/dist/{note-context-d1wT_-GA.mjs → note-context-DrcY4cWm.mjs} +1 -1
  60. package/dist/{note-context-d1wT_-GA.mjs.map → note-context-DrcY4cWm.mjs.map} +1 -1
  61. package/dist/{pai-marker-B20KqhA8.mjs → pai-marker-CHtbJMwJ.mjs} +1 -1
  62. package/dist/{pai-marker-B20KqhA8.mjs.map → pai-marker-CHtbJMwJ.mjs.map} +1 -1
  63. package/dist/{postgres-CI3FjGq0.mjs → postgres-BVme6qX0.mjs} +2 -2
  64. package/dist/{postgres-CI3FjGq0.mjs.map → postgres-BVme6qX0.mjs.map} +1 -1
  65. package/dist/{pick-DatgS3Nk.mjs → program-C-fUghPv.mjs} +1298 -223
  66. package/dist/program-C-fUghPv.mjs.map +1 -0
  67. package/dist/query-feedback-BBMBp96K.mjs +3 -0
  68. package/dist/{query-feedback-D4U56Hz6.mjs → query-feedback-C1T6kS18.mjs} +2 -4
  69. package/dist/{query-feedback-D4U56Hz6.mjs.map → query-feedback-C1T6kS18.mjs.map} +1 -1
  70. package/dist/reranker-CwTCNsgA.mjs +3 -0
  71. package/dist/{reranker-CMNZcfVx.mjs → reranker-xPm04PXx.mjs} +2 -8
  72. package/dist/{reranker-CMNZcfVx.mjs.map → reranker-xPm04PXx.mjs.map} +1 -1
  73. package/dist/router-BMkOb62X.mjs +3 -0
  74. package/dist/{router-i9S19Usg.mjs → router-CsDm7HvK.mjs} +2 -4
  75. package/dist/{router-i9S19Usg.mjs.map → router-CsDm7HvK.mjs.map} +1 -1
  76. package/dist/{runtime-paths-B0P1TvUr.mjs → runtime-paths-rni52zHX.mjs} +1 -1
  77. package/dist/{runtime-paths-B0P1TvUr.mjs.map → runtime-paths-rni52zHX.mjs.map} +1 -1
  78. package/dist/search-CfPpJAWQ.mjs +4 -0
  79. package/dist/{search-C32zQ0V0.mjs → search-Rpk1cSBC.mjs} +4 -15
  80. package/dist/{search-C32zQ0V0.mjs.map → search-Rpk1cSBC.mjs.map} +1 -1
  81. package/dist/{sources-BDwN0B8i.mjs → sources-D8ZdNfvK.mjs} +2 -2
  82. package/dist/{sources-BDwN0B8i.mjs.map → sources-D8ZdNfvK.mjs.map} +1 -1
  83. package/dist/{sqlite-C6FHnMkn.mjs → sqlite-D1IaR8Am.mjs} +3 -3
  84. package/dist/{sqlite-C6FHnMkn.mjs.map → sqlite-D1IaR8Am.mjs.map} +1 -1
  85. package/dist/state-WaXhLr6R.mjs +70 -0
  86. package/dist/{state-DTvy-jRB.mjs.map → state-WaXhLr6R.mjs.map} +1 -1
  87. package/dist/state-qtmrBWCm.mjs +3 -0
  88. package/dist/{stop-words-BaMEGVeY.mjs → stop-words-Hfu8u22w.mjs} +1 -1
  89. package/dist/{stop-words-BaMEGVeY.mjs.map → stop-words-Hfu8u22w.mjs.map} +1 -1
  90. package/dist/{sync--BoxBBok.mjs → sync-BWbe8JTg.mjs} +3 -3
  91. package/dist/{sync--BoxBBok.mjs.map → sync-BWbe8JTg.mjs.map} +1 -1
  92. package/dist/{themes-BObEGMWn.mjs → themes-XPkj_bfP.mjs} +3 -3
  93. package/dist/{themes-BObEGMWn.mjs.map → themes-XPkj_bfP.mjs.map} +1 -1
  94. package/dist/tools-DEt6YPfc.mjs +5 -0
  95. package/dist/{tools-C1lCHerL.mjs → tools-ceiy7ANX.mjs} +28 -65
  96. package/dist/tools-ceiy7ANX.mjs.map +1 -0
  97. package/dist/{trace-h23JCcFD.mjs → trace-DfyGmMG_.mjs} +1 -1
  98. package/dist/{trace-h23JCcFD.mjs.map → trace-DfyGmMG_.mjs.map} +1 -1
  99. package/dist/{utils-BAxjW3j8.mjs → utils-9Err2RBW.mjs} +2 -22
  100. package/dist/{utils-BAxjW3j8.mjs.map → utils-9Err2RBW.mjs.map} +1 -1
  101. package/dist/utils-DhMex3Ox.mjs +3 -0
  102. package/dist/{vault-indexer-CUF9edbW.mjs → vault-indexer-CFvlPUMB.mjs} +2 -2
  103. package/dist/{vault-indexer-CUF9edbW.mjs.map → vault-indexer-CFvlPUMB.mjs.map} +1 -1
  104. package/dist/{work-queue-worker-BcDGAcF3.mjs → work-queue-worker-B8W8_3Rn.mjs} +201 -13
  105. package/dist/work-queue-worker-B8W8_3Rn.mjs.map +1 -0
  106. package/dist/work-queue-worker-HN2Ufg-L.mjs +11 -0
  107. package/dist/{zettelkasten-W-h8G2is.mjs → zettelkasten-CvjmMghT.mjs} +4 -4
  108. package/dist/{zettelkasten-W-h8G2is.mjs.map → zettelkasten-CvjmMghT.mjs.map} +1 -1
  109. package/package.json +1 -1
  110. package/src/hooks/ts/lib/context-fill.test.ts +628 -0
  111. package/src/hooks/ts/lib/context-fill.ts +668 -0
  112. package/src/hooks/ts/lib/context-handover-cache.ts +46 -0
  113. package/src/hooks/ts/lib/transcript-text.test.ts +125 -0
  114. package/src/hooks/ts/lib/transcript-text.ts +71 -0
  115. package/src/hooks/ts/pre-compact/context-compression-hook.ts +131 -30
  116. package/statusline-command.sh +16 -0
  117. package/dist/checkpoint-block-D3rm4dAJ.mjs.map +0 -1
  118. package/dist/daemon-B8L2f5vy.mjs.map +0 -1
  119. package/dist/ipc-client-aVKVERjJ.mjs.map +0 -1
  120. package/dist/main-resolver-CNSqU8wo.mjs.map +0 -1
  121. package/dist/pick-DatgS3Nk.mjs.map +0 -1
  122. package/dist/rolldown-runtime-95iHPtFO.mjs +0 -18
  123. package/dist/state-DTvy-jRB.mjs +0 -102
  124. package/dist/tools-C1lCHerL.mjs.map +0 -1
  125. package/dist/work-queue-worker-BcDGAcF3.mjs.map +0 -1
  126. /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
+ }