@tekmidian/pai 0.35.1 → 0.36.0

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 (134) hide show
  1. package/dist/.metadata_never_index +0 -0
  2. package/dist/{auto-route-DVM3U2ZY.mjs → auto-route-Byf8ENXj.mjs} +4 -4
  3. package/dist/{auto-route-DVM3U2ZY.mjs.map → auto-route-Byf8ENXj.mjs.map} +1 -1
  4. package/dist/{checkpoint-block-D3rm4dAJ.mjs → checkpoint-block-DKYxCkBL.mjs} +7 -6
  5. package/dist/checkpoint-block-DKYxCkBL.mjs.map +1 -0
  6. package/dist/cli/index.mjs +25 -17
  7. package/dist/cli/index.mjs.map +1 -1
  8. package/dist/cli/probe2.mjs +2 -0
  9. package/dist/cli/program.d.mts.map +1 -1
  10. package/dist/cli/program.mjs +16 -267
  11. package/dist/{clusters-CzGxefB7.mjs → clusters-wZgTCYCB.mjs} +2 -2
  12. package/dist/{clusters-CzGxefB7.mjs.map → clusters-wZgTCYCB.mjs.map} +1 -1
  13. package/dist/{config-BSkVcvfq.mjs → config-B64vFg14.mjs} +3 -12
  14. package/dist/{config-BSkVcvfq.mjs.map → config-B64vFg14.mjs.map} +1 -1
  15. package/dist/config-C_ErGddD.mjs +3 -0
  16. package/dist/daemon/index.mjs +19 -19
  17. package/dist/{daemon-Hnu6-HDD.mjs → daemon--N2JFnUs.mjs} +39 -39
  18. package/dist/daemon--N2JFnUs.mjs.map +1 -0
  19. package/dist/daemon-DJEFqV84.mjs +20 -0
  20. package/dist/daemon-mcp/index.mjs +2 -2
  21. package/dist/{db-BtuN768f.mjs → db-Ca5qfsMC.mjs} +2 -4
  22. package/dist/{db-BtuN768f.mjs.map → db-Ca5qfsMC.mjs.map} +1 -1
  23. package/dist/db-O-cyAPfS.mjs +3 -0
  24. package/dist/db-XEwJbuGO.mjs +3 -0
  25. package/dist/{db-CYmBWcjh.mjs → db-a1ixZQjr.mjs} +2 -4
  26. package/dist/{db-CYmBWcjh.mjs.map → db-a1ixZQjr.mjs.map} +1 -1
  27. package/dist/{detect-Bf2z-oKB.mjs → detect-CdaA48EI.mjs} +1 -1
  28. package/dist/{detect-Bf2z-oKB.mjs.map → detect-CdaA48EI.mjs.map} +1 -1
  29. package/dist/{detector-BU-bsDXs.mjs → detector--Gg5JRN5.mjs} +3 -5
  30. package/dist/{detector-BU-bsDXs.mjs.map → detector--Gg5JRN5.mjs.map} +1 -1
  31. package/dist/detector-DGAk1iBR.mjs +5 -0
  32. package/dist/embeddings-CEBGrzwu.mjs +3 -0
  33. package/dist/{embeddings-Bn86ssxR.mjs → embeddings-DOLZnT1X.mjs} +2 -12
  34. package/dist/{embeddings-Bn86ssxR.mjs.map → embeddings-DOLZnT1X.mjs.map} +1 -1
  35. package/dist/{factory-BGH0COXb.mjs → factory-Bsp7xOpO.mjs} +9 -12
  36. package/dist/{factory-BGH0COXb.mjs.map → factory-Bsp7xOpO.mjs.map} +1 -1
  37. package/dist/factory-CrokPMk2.mjs +3 -0
  38. package/dist/{helpers-crDEr6S2.mjs → helpers-IjZkXBhj.mjs} +1 -1
  39. package/dist/{helpers-crDEr6S2.mjs.map → helpers-IjZkXBhj.mjs.map} +1 -1
  40. package/dist/hooks/context-compression-hook.mjs +209 -70
  41. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  42. package/dist/hooks/whisper-reinject.mjs +59 -0
  43. package/dist/hooks/whisper-reinject.mjs.map +7 -0
  44. package/dist/hooks/whisper-rules.mjs +24 -9
  45. package/dist/hooks/whisper-rules.mjs.map +2 -2
  46. package/dist/index.mjs +10 -10
  47. package/dist/{indexer-backend-Bg7VDpGt.mjs → indexer-backend-nQZuEx6N.mjs} +3 -3
  48. package/dist/{indexer-backend-Bg7VDpGt.mjs.map → indexer-backend-nQZuEx6N.mjs.map} +1 -1
  49. package/dist/{ipc-client-aVKVERjJ.mjs → ipc-client-BmypMNYk.mjs} +13 -7
  50. package/dist/ipc-client-BmypMNYk.mjs.map +1 -0
  51. package/dist/{kg-entity-r8duqhi9.mjs → kg-entity-DbOMPdF9.mjs} +1 -1
  52. package/dist/{kg-entity-r8duqhi9.mjs.map → kg-entity-DbOMPdF9.mjs.map} +1 -1
  53. package/dist/{latent-ideas-BL9m2HF9.mjs → latent-ideas-Bn6A5-5P.mjs} +4 -4
  54. package/dist/{latent-ideas-BL9m2HF9.mjs.map → latent-ideas-Bn6A5-5P.mjs.map} +1 -1
  55. package/dist/{link-boost-QFLrJwD6.mjs → link-boost-fYjUnxCN.mjs} +1 -1
  56. package/dist/{link-boost-QFLrJwD6.mjs.map → link-boost-fYjUnxCN.mjs.map} +1 -1
  57. package/dist/{main-resolver-CNSqU8wo.mjs → main-resolver-BAbhKpeX.mjs} +12 -14
  58. package/dist/main-resolver-BAbhKpeX.mjs.map +1 -0
  59. package/dist/main-resolver-Dxh444GO.mjs +4 -0
  60. package/dist/{migrate-fLD6rAdO.mjs → migrate-Cjzeefn9.mjs} +2 -2
  61. package/dist/{migrate-fLD6rAdO.mjs.map → migrate-Cjzeefn9.mjs.map} +1 -1
  62. package/dist/{neighborhood-BX89_nty.mjs → neighborhood-DpaEM991.mjs} +2 -2
  63. package/dist/{neighborhood-BX89_nty.mjs.map → neighborhood-DpaEM991.mjs.map} +1 -1
  64. package/dist/{note-context-d1wT_-GA.mjs → note-context-DrcY4cWm.mjs} +1 -1
  65. package/dist/{note-context-d1wT_-GA.mjs.map → note-context-DrcY4cWm.mjs.map} +1 -1
  66. package/dist/{pai-marker-B20KqhA8.mjs → pai-marker-CHtbJMwJ.mjs} +1 -1
  67. package/dist/{pai-marker-B20KqhA8.mjs.map → pai-marker-CHtbJMwJ.mjs.map} +1 -1
  68. package/dist/{postgres-BALUE11K.mjs → postgres-BVme6qX0.mjs} +7 -4
  69. package/dist/postgres-BVme6qX0.mjs.map +1 -0
  70. package/dist/{pick-aWhenqjE.mjs → program-BnMNFb4O.mjs} +1171 -223
  71. package/dist/program-BnMNFb4O.mjs.map +1 -0
  72. package/dist/query-feedback-BBMBp96K.mjs +3 -0
  73. package/dist/{query-feedback-D4U56Hz6.mjs → query-feedback-C1T6kS18.mjs} +2 -4
  74. package/dist/{query-feedback-D4U56Hz6.mjs.map → query-feedback-C1T6kS18.mjs.map} +1 -1
  75. package/dist/reranker-CwTCNsgA.mjs +3 -0
  76. package/dist/{reranker-CMNZcfVx.mjs → reranker-xPm04PXx.mjs} +2 -8
  77. package/dist/{reranker-CMNZcfVx.mjs.map → reranker-xPm04PXx.mjs.map} +1 -1
  78. package/dist/router-BMkOb62X.mjs +3 -0
  79. package/dist/{router-i9S19Usg.mjs → router-CsDm7HvK.mjs} +2 -4
  80. package/dist/{router-i9S19Usg.mjs.map → router-CsDm7HvK.mjs.map} +1 -1
  81. package/dist/{runtime-paths-B0P1TvUr.mjs → runtime-paths-rni52zHX.mjs} +1 -1
  82. package/dist/{runtime-paths-B0P1TvUr.mjs.map → runtime-paths-rni52zHX.mjs.map} +1 -1
  83. package/dist/search-CfPpJAWQ.mjs +4 -0
  84. package/dist/{search-C32zQ0V0.mjs → search-Rpk1cSBC.mjs} +4 -15
  85. package/dist/{search-C32zQ0V0.mjs.map → search-Rpk1cSBC.mjs.map} +1 -1
  86. package/dist/{sources-BDwN0B8i.mjs → sources-D8ZdNfvK.mjs} +2 -2
  87. package/dist/{sources-BDwN0B8i.mjs.map → sources-D8ZdNfvK.mjs.map} +1 -1
  88. package/dist/{sqlite-C6FHnMkn.mjs → sqlite-D1IaR8Am.mjs} +3 -3
  89. package/dist/{sqlite-C6FHnMkn.mjs.map → sqlite-D1IaR8Am.mjs.map} +1 -1
  90. package/dist/state-WaXhLr6R.mjs +70 -0
  91. package/dist/{state-DTvy-jRB.mjs.map → state-WaXhLr6R.mjs.map} +1 -1
  92. package/dist/state-qtmrBWCm.mjs +3 -0
  93. package/dist/{stop-words-BaMEGVeY.mjs → stop-words-Hfu8u22w.mjs} +1 -1
  94. package/dist/{stop-words-BaMEGVeY.mjs.map → stop-words-Hfu8u22w.mjs.map} +1 -1
  95. package/dist/{sync--BoxBBok.mjs → sync-BWbe8JTg.mjs} +3 -3
  96. package/dist/{sync--BoxBBok.mjs.map → sync-BWbe8JTg.mjs.map} +1 -1
  97. package/dist/{themes-BObEGMWn.mjs → themes-XPkj_bfP.mjs} +3 -3
  98. package/dist/{themes-BObEGMWn.mjs.map → themes-XPkj_bfP.mjs.map} +1 -1
  99. package/dist/tools-DEt6YPfc.mjs +5 -0
  100. package/dist/{tools-C1lCHerL.mjs → tools-ceiy7ANX.mjs} +28 -65
  101. package/dist/tools-ceiy7ANX.mjs.map +1 -0
  102. package/dist/{trace-h23JCcFD.mjs → trace-DfyGmMG_.mjs} +1 -1
  103. package/dist/{trace-h23JCcFD.mjs.map → trace-DfyGmMG_.mjs.map} +1 -1
  104. package/dist/{utils-BAxjW3j8.mjs → utils-9Err2RBW.mjs} +2 -22
  105. package/dist/{utils-BAxjW3j8.mjs.map → utils-9Err2RBW.mjs.map} +1 -1
  106. package/dist/utils-DhMex3Ox.mjs +3 -0
  107. package/dist/{vault-indexer-CUF9edbW.mjs → vault-indexer-CFvlPUMB.mjs} +2 -2
  108. package/dist/{vault-indexer-CUF9edbW.mjs.map → vault-indexer-CFvlPUMB.mjs.map} +1 -1
  109. package/dist/{work-queue-worker-BcDGAcF3.mjs → work-queue-worker-228XjABm.mjs} +234 -14
  110. package/dist/work-queue-worker-228XjABm.mjs.map +1 -0
  111. package/dist/work-queue-worker-LRA9Fj9z.mjs +11 -0
  112. package/dist/{zettelkasten-W-h8G2is.mjs → zettelkasten-CvjmMghT.mjs} +4 -4
  113. package/dist/{zettelkasten-W-h8G2is.mjs.map → zettelkasten-CvjmMghT.mjs.map} +1 -1
  114. package/package.json +1 -1
  115. package/src/hooks/ts/lib/context-fill.test.ts +515 -0
  116. package/src/hooks/ts/lib/context-fill.ts +585 -0
  117. package/src/hooks/ts/lib/context-handover-cache.ts +46 -0
  118. package/src/hooks/ts/lib/transcript-text.test.ts +125 -0
  119. package/src/hooks/ts/lib/transcript-text.ts +71 -0
  120. package/src/hooks/ts/post-tool-use/whisper-reinject.ts +88 -0
  121. package/src/hooks/ts/pre-compact/context-compression-hook.ts +112 -30
  122. package/src/hooks/ts/user-prompt/whisper-rules.ts +62 -8
  123. package/statusline-command.sh +16 -0
  124. package/dist/checkpoint-block-D3rm4dAJ.mjs.map +0 -1
  125. package/dist/daemon-Hnu6-HDD.mjs.map +0 -1
  126. package/dist/ipc-client-aVKVERjJ.mjs.map +0 -1
  127. package/dist/main-resolver-CNSqU8wo.mjs.map +0 -1
  128. package/dist/pick-aWhenqjE.mjs.map +0 -1
  129. package/dist/postgres-BALUE11K.mjs.map +0 -1
  130. package/dist/rolldown-runtime-95iHPtFO.mjs +0 -18
  131. package/dist/state-DTvy-jRB.mjs +0 -102
  132. package/dist/tools-C1lCHerL.mjs.map +0 -1
  133. package/dist/work-queue-worker-BcDGAcF3.mjs.map +0 -1
  134. /package/dist/{indexer-AEcT8wHf.mjs → indexer-D7MvSQPY.mjs} +0 -0
@@ -0,0 +1,585 @@
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, statSync } 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
+ /** How many of a project's most-recently-modified transcripts to scan for
295
+ * compact_boundary events. compact_boundary events cluster in whichever
296
+ * files were touched most recently — a full-history scan would cost a lot
297
+ * for a long-lived project and buy nothing this doesn't already get from
298
+ * the last handful of files. */
299
+ const MEASURED_TRIGGER_MAX_FILES = 8;
300
+
301
+ export const THRESHOLD_MARGIN_TOKENS = {
302
+ warmup: 100_000,
303
+ refresh: 40_000,
304
+ immediate: 15_000,
305
+ } as const;
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Measured trigger — scan a project's own transcript history for the
309
+ // platform's own compact_boundary events, ground truth over configuration.
310
+ // ---------------------------------------------------------------------------
311
+ //
312
+ // Deliberately re-implements the tiny bit of path encoding it needs (below)
313
+ // rather than importing project-utils/paths.ts's encodePath: that module
314
+ // chain ends in pai-paths.ts, which calls process.exit(1) if PAI_DIR does
315
+ // not resolve to an existing directory. context-fill.ts is specifically the
316
+ // module a hook falls back on when its environment is unreliable — pulling
317
+ // in a dependency that can kill the process on import would defeat that.
318
+ // Claude Code's transcript directory is a fixed, home-relative convention
319
+ // (~/.claude/projects/<encoded-cwd>/), not something PAI_DIR governs, so
320
+ // resolving it directly here is also just the more correct dependency, not
321
+ // only the safer one.
322
+
323
+ /** Real default — overridable per-call so tests never touch the user's
324
+ * actual ~/.claude/projects/ (a live directory this very session writes to). */
325
+ export const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
326
+
327
+ function encodeProjectPath(cwd: string): string {
328
+ return cwd.replace(/[/\s.-]/g, "-");
329
+ }
330
+
331
+ interface CompactBoundarySample {
332
+ preTokens: number;
333
+ timestampMs: number;
334
+ }
335
+
336
+ function listProjectTranscripts(cwd: string, projectsDir: string): string[] {
337
+ const projectDir = join(projectsDir, encodeProjectPath(cwd));
338
+ if (!existsSync(projectDir)) return [];
339
+
340
+ const candidates: Array<{ path: string; mtimeMs: number }> = [];
341
+ const collect = (dir: string): void => {
342
+ if (!existsSync(dir)) return;
343
+ let entries: string[];
344
+ try {
345
+ entries = readdirSync(dir);
346
+ } catch {
347
+ return;
348
+ }
349
+ for (const entry of entries) {
350
+ if (!entry.endsWith(".jsonl")) continue;
351
+ const full = join(dir, entry);
352
+ try {
353
+ candidates.push({ path: full, mtimeMs: statSync(full).mtimeMs });
354
+ } catch {
355
+ // Unreadable — skip.
356
+ }
357
+ }
358
+ };
359
+ collect(projectDir);
360
+ collect(join(projectDir, "sessions"));
361
+
362
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
363
+ return candidates.slice(0, MEASURED_TRIGGER_MAX_FILES).map((c) => c.path);
364
+ }
365
+
366
+ /**
367
+ * Every compact_boundary sample found in a project's most-recently-modified
368
+ * transcripts, newest first.
369
+ */
370
+ function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBoundarySample[] {
371
+ const samples: CompactBoundarySample[] = [];
372
+
373
+ for (const path of listProjectTranscripts(cwd, projectsDir)) {
374
+ let raw: string;
375
+ try {
376
+ raw = readFileSync(path, "utf-8");
377
+ } catch {
378
+ continue;
379
+ }
380
+
381
+ for (const line of raw.split("\n")) {
382
+ if (!line.trim()) continue;
383
+ let entry: {
384
+ type?: string;
385
+ subtype?: string;
386
+ timestamp?: string;
387
+ compactMetadata?: { preTokens?: number };
388
+ };
389
+ try {
390
+ entry = JSON.parse(line);
391
+ } catch {
392
+ continue;
393
+ }
394
+ if (entry.type !== "system" || entry.subtype !== "compact_boundary") continue;
395
+ const preTokens = entry.compactMetadata?.preTokens;
396
+ if (typeof preTokens !== "number" || !Number.isFinite(preTokens)) continue;
397
+ const timestampMs = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
398
+ if (!Number.isFinite(timestampMs)) continue;
399
+ samples.push({ preTokens, timestampMs });
400
+ }
401
+ }
402
+
403
+ samples.sort((a, b) => b.timestampMs - a.timestampMs);
404
+ return samples;
405
+ }
406
+
407
+ /**
408
+ * The measured compaction trigger for a project, or null when it has no
409
+ * compaction history yet (a brand-new project, or one whose transcripts
410
+ * this process cannot read). Minimum of the most recent
411
+ * MEASURED_TRIGGER_SAMPLE_SIZE compact_boundary events — see the module
412
+ * comment above for why minimum, not mean.
413
+ */
414
+ export function measureCompactionTrigger(
415
+ cwd: string,
416
+ projectsDir: string = CLAUDE_PROJECTS_DIR
417
+ ): number | null {
418
+ if (!cwd) return null;
419
+ const samples = readCompactBoundarySamples(cwd, projectsDir).slice(0, MEASURED_TRIGGER_SAMPLE_SIZE);
420
+ if (samples.length === 0) return null;
421
+ return Math.min(...samples.map((s) => s.preTokens));
422
+ }
423
+
424
+ /**
425
+ * Read CLAUDE_AUTOCOMPACT_PCT_OVERRIDE from the environment. Absent →
426
+ * DEFAULT_AUTOCOMPACT_PCT. Present but not a finite number in (0, 100] →
427
+ * also DEFAULT_AUTOCOMPACT_PCT, logged, so a typo in the override degrades
428
+ * to the documented default instead of silently producing nonsense
429
+ * thresholds (0, negative, or a fraction so large no session ever reaches
430
+ * it).
431
+ */
432
+ export function resolveAutocompactPct(env: NodeJS.ProcessEnv = process.env): number {
433
+ const raw = env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE;
434
+ if (raw === undefined || raw === "") return DEFAULT_AUTOCOMPACT_PCT;
435
+
436
+ const parsed = Number(raw);
437
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) {
438
+ console.error(
439
+ `[context-fill] CLAUDE_AUTOCOMPACT_PCT_OVERRIDE="${raw}" is not a usable percentage — ` +
440
+ `falling back to the default ${DEFAULT_AUTOCOMPACT_PCT}.`
441
+ );
442
+ return DEFAULT_AUTOCOMPACT_PCT;
443
+ }
444
+ return parsed;
445
+ }
446
+
447
+ /**
448
+ * The two thresholds that actually fire a handover job. "immediate" is not a
449
+ * third job — see `isImmediate` below — it only changes how urgently a
450
+ * crossing of these two is treated.
451
+ */
452
+ export type ThresholdName = "warmup" | "refresh";
453
+
454
+ /** Which basis actually produced effectiveTriggerTokens — reported so the
455
+ * number is checkable rather than trusted. */
456
+ export type TriggerSource = "measured" | "configured";
457
+
458
+ export interface ContextFillThresholds {
459
+ warmupTokens: number;
460
+ refreshTokens: number;
461
+ immediateTokens: number;
462
+ /** The derived compaction trigger these were measured back from. */
463
+ effectiveTriggerTokens: number;
464
+ /** The autocompact percentage from the configured chain — computed and
465
+ * reported even when `triggerSource` is "measured" (in which case it
466
+ * describes what the fallback WOULD have used, not what was used). */
467
+ autocompactPct: number;
468
+ /** "measured" when this project has compact_boundary history and that was
469
+ * used; "configured" when it fell back to the env-override/default chain. */
470
+ triggerSource: TriggerSource;
471
+ /** True when these were computed against a window size Claude Code itself
472
+ * reported (the statusline source). False when the window is only an
473
+ * assumed default (the transcript-fallback source never learns the real
474
+ * window size) — callers should log this: a threshold silently computed
475
+ * against the wrong basis is the same class of fault as printing a token
476
+ * count larger than the window. */
477
+ windowConfirmed: boolean;
478
+ }
479
+
480
+ export interface ContextFillThresholdOpts {
481
+ /** The session's project directory, used to look up its own compaction
482
+ * history for the measured trigger. Omit when unknown — falls back to
483
+ * the configured chain, same as a project with no history yet. */
484
+ cwd?: string;
485
+ /** Override the measured-trigger lookup instead of scanning transcripts —
486
+ * primarily for tests. `null` forces the configured fallback even when
487
+ * `cwd` is given; `undefined` (the default) does the real lookup. */
488
+ measuredTrigger?: number | null;
489
+ }
490
+
491
+ /**
492
+ * Derive warmup/refresh/immediate thresholds from a fill reading, preferring
493
+ * this project's own measured compaction history over the configured
494
+ * override chain — see the module comment above for why. Margins are
495
+ * clamped at 0 (and logged) for a window small enough that a margin would
496
+ * otherwise go negative — a pathological input should degrade to "fire
497
+ * immediately", never to a threshold below zero.
498
+ */
499
+ export function contextFillThresholds(
500
+ reading: ContextFillReading,
501
+ env: NodeJS.ProcessEnv = process.env,
502
+ opts: ContextFillThresholdOpts = {}
503
+ ): ContextFillThresholds {
504
+ const windowConfirmed = reading.source === "statusline";
505
+ const autocompactPct = resolveAutocompactPct(env);
506
+ const configuredTriggerTokens = Math.round(reading.windowSize * (autocompactPct / 100));
507
+
508
+ const measured = opts.measuredTrigger !== undefined
509
+ ? opts.measuredTrigger
510
+ : opts.cwd
511
+ ? measureCompactionTrigger(opts.cwd)
512
+ : null;
513
+
514
+ const triggerSource: TriggerSource = measured !== null ? "measured" : "configured";
515
+ const effectiveTriggerTokens = measured !== null ? measured : configuredTriggerTokens;
516
+
517
+ if (triggerSource === "measured") {
518
+ console.error(
519
+ `[context-fill] trigger source: MEASURED — ${effectiveTriggerTokens} tokens ` +
520
+ `(minimum of the most recent ${MEASURED_TRIGGER_SAMPLE_SIZE} compact_boundary events for ` +
521
+ `this project; the configured chain would have given ${configuredTriggerTokens}).`
522
+ );
523
+ } else {
524
+ console.error(
525
+ `[context-fill] trigger source: CONFIGURED — no compaction history for this project yet, ` +
526
+ `using ${effectiveTriggerTokens} tokens (${autocompactPct}% of a ${reading.windowSize}-token window).`
527
+ );
528
+ }
529
+
530
+ const clamp = (name: string, value: number): number => {
531
+ if (value >= 0) return value;
532
+ console.error(
533
+ `[context-fill] ${name} threshold went negative (${value}) for a ` +
534
+ `${reading.windowSize}-token window — clamping to 0.`
535
+ );
536
+ return 0;
537
+ };
538
+
539
+ return {
540
+ warmupTokens: clamp("warmup", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.warmup),
541
+ refreshTokens: clamp("refresh", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.refresh),
542
+ immediateTokens: clamp("immediate", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.immediate),
543
+ effectiveTriggerTokens,
544
+ autocompactPct,
545
+ triggerSource,
546
+ windowConfirmed,
547
+ };
548
+ }
549
+
550
+ /**
551
+ * Which named thresholds `usedTokens` has newly crossed, given the ones that
552
+ * have already fired this session. Ascending order (warmup before refresh).
553
+ *
554
+ * A single check can return more than one name — e.g. a session first
555
+ * observed at 99% of its window crosses warmup and refresh in the same tick,
556
+ * because there was no earlier clean crossing to catch it at. That is the
557
+ * "fire immediately" case: the caller enqueues one handover and marks every
558
+ * newly-crossed name fired, rather than waiting for a crossing that already
559
+ * happened.
560
+ */
561
+ export function crossedThresholds(
562
+ usedTokens: number,
563
+ thresholds: ContextFillThresholds,
564
+ alreadyFired: ThresholdName[]
565
+ ): ThresholdName[] {
566
+ const fired = new Set(alreadyFired);
567
+ const ordered: Array<[ThresholdName, number]> = [
568
+ ["warmup", thresholds.warmupTokens],
569
+ ["refresh", thresholds.refreshTokens],
570
+ ];
571
+ return ordered.filter(([name, tokens]) => !fired.has(name) && usedTokens >= tokens).map(([name]) => name);
572
+ }
573
+
574
+ /**
575
+ * True once a session is at or above the "no time left for a clean crossing"
576
+ * floor (0.985 of the window). Purely informational for callers — it never
577
+ * gates whether `crossedThresholds` fires (a poll-based check already fires
578
+ * any unfired threshold the moment `usedTokens` reaches it, on whatever tick
579
+ * observes it) — but it distinguishes "this crossed on schedule" from "this
580
+ * was first observed already almost out of room", which is worth a different
581
+ * log line and, for a caller that queues work, a higher priority.
582
+ */
583
+ export function isImmediate(usedTokens: number, thresholds: ContextFillThresholds): boolean {
584
+ return usedTokens >= thresholds.immediateTokens;
585
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * context-handover-cache.ts — the well-known file where the threshold-
3
+ * triggered pre-compaction handover (see ../../../daemon/context-handover-
4
+ * worker.ts) is written, and where context-compression-hook.ts looks for it
5
+ * at compaction time.
6
+ *
7
+ * Deliberately dependency-light (fs/os/path only). The daemon worker that
8
+ * WRITES this file needs the heavier machinery in session-summary-worker.ts
9
+ * (spawning Claude, git log, etc.), but the PreCompact hook that READS it
10
+ * runs as a short-lived process on every compaction and must not drag in
11
+ * daemon state, database pools, or anything else that module graph carries —
12
+ * this file is the shared seam so neither side has to.
13
+ */
14
+
15
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ export type HandoverThreshold = "warmup" | "refresh";
20
+
21
+ export interface ContextHandoverCache {
22
+ sessionId: string;
23
+ cwd: string;
24
+ threshold: HandoverThreshold;
25
+ generatedAt: string; // ISO timestamp
26
+ model: string;
27
+ summary: string;
28
+ }
29
+
30
+ export function contextHandoverCachePath(sessionId: string): string {
31
+ return join(tmpdir(), `pai-context-handover-${sessionId}.json`);
32
+ }
33
+
34
+ export function readContextHandoverCache(sessionId: string): ContextHandoverCache | null {
35
+ const path = contextHandoverCachePath(sessionId);
36
+ if (!existsSync(path)) return null;
37
+ try {
38
+ return JSON.parse(readFileSync(path, "utf-8")) as ContextHandoverCache;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ export function writeContextHandoverCache(cache: ContextHandoverCache): void {
45
+ writeFileSync(contextHandoverCachePath(cache.sessionId), JSON.stringify(cache, null, 2), "utf-8");
46
+ }