@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,515 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ import {
8
+ contextFillFromTranscript,
9
+ readStatuslineFill,
10
+ getContextFill,
11
+ formatContextFill,
12
+ statuslineStateFilePath,
13
+ contextFillThresholds,
14
+ resolveAutocompactPct,
15
+ measureCompactionTrigger,
16
+ crossedThresholds,
17
+ isImmediate,
18
+ DEFAULT_CONTEXT_WINDOW,
19
+ type ContextFillReading,
20
+ } from "./context-fill.js";
21
+
22
+ let root: string;
23
+
24
+ beforeEach(() => {
25
+ root = mkdtempSync(join(tmpdir(), "pai-context-fill-test-"));
26
+ });
27
+
28
+ afterEach(() => {
29
+ rmSync(root, { recursive: true, force: true });
30
+ });
31
+
32
+ function writeJsonl(path: string, entries: unknown[]): void {
33
+ writeFileSync(path, entries.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf8");
34
+ }
35
+
36
+ const usageEntry = (usage: Record<string, number>) => ({
37
+ type: "assistant",
38
+ message: { role: "assistant", usage },
39
+ });
40
+
41
+ const textEntry = () => ({ type: "user", message: { role: "user", content: "hi" } });
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // contextFillFromTranscript — the fallback source
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe("contextFillFromTranscript", () => {
48
+ it("sums the three fields of the MOST RECENT usage entry only — not across entries", () => {
49
+ const p = join(root, "t.jsonl");
50
+ writeJsonl(p, [
51
+ usageEntry({ input_tokens: 1000, cache_read_input_tokens: 50_000, cache_creation_input_tokens: 0 }),
52
+ usageEntry({ input_tokens: 2000, cache_read_input_tokens: 60_000, cache_creation_input_tokens: 1000 }),
53
+ ]);
54
+
55
+ const reading = contextFillFromTranscript(p, 200_000);
56
+ expect(reading.status).toBe("ok");
57
+ // Last entry only: 2000 + 60000 + 1000 = 63000. A cumulative sum across
58
+ // both entries would wrongly report 114000.
59
+ expect(reading.usedTokens).toBe(63_000);
60
+ expect(reading.fraction).toBeCloseTo(63_000 / 200_000);
61
+ expect(reading.source).toBe("transcript");
62
+ });
63
+
64
+ it("scans backwards past trailing lines with no usage field", () => {
65
+ const p = join(root, "t.jsonl");
66
+ writeJsonl(p, [
67
+ usageEntry({ input_tokens: 5000, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }),
68
+ textEntry(),
69
+ textEntry(),
70
+ ]);
71
+
72
+ const reading = contextFillFromTranscript(p, 200_000);
73
+ expect(reading.status).toBe("ok");
74
+ expect(reading.usedTokens).toBe(5000);
75
+ });
76
+
77
+ it("returns unknown — never a confident zero — when no usage entry exists in the tail", () => {
78
+ const p = join(root, "t.jsonl");
79
+ writeJsonl(p, [textEntry(), textEntry()]);
80
+
81
+ const reading = contextFillFromTranscript(p, 200_000);
82
+ expect(reading.status).toBe("unknown");
83
+ expect(reading.usedTokens).toBeNull();
84
+ expect(reading.fraction).toBeNull();
85
+ });
86
+
87
+ it("returns unknown for a missing transcript file", () => {
88
+ const reading = contextFillFromTranscript(join(root, "does-not-exist.jsonl"), 200_000);
89
+ expect(reading.status).toBe("unknown");
90
+ });
91
+
92
+ it("only looks at the last TRANSCRIPT_TAIL_LINES lines", () => {
93
+ const p = join(root, "t.jsonl");
94
+ const entries: unknown[] = [
95
+ usageEntry({ input_tokens: 999_999, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }),
96
+ ];
97
+ for (let i = 0; i < 60; i++) entries.push(textEntry());
98
+ writeJsonl(p, entries);
99
+
100
+ // The huge usage entry is now more than 40 lines back — must not surface.
101
+ const reading = contextFillFromTranscript(p, 200_000);
102
+ expect(reading.status).toBe("unknown");
103
+ });
104
+ });
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // readStatuslineFill — the primary source, and its staleness rule
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe("readStatuslineFill", () => {
111
+ it("reads a fresh state file written by statusline-command.sh", () => {
112
+ const sessionId = randomUUID();
113
+ // Point tmpdir-based lookup at a real file by writing exactly where the
114
+ // helper will look.
115
+ const path = statuslineStateFilePath(sessionId);
116
+ const now = Date.now();
117
+ writeFileSync(
118
+ path,
119
+ JSON.stringify({ used_percentage: 63, context_window_size: 200_000, session_id: sessionId, timestamp: now })
120
+ );
121
+
122
+ try {
123
+ const reading = readStatuslineFill(sessionId, now + 1000);
124
+ expect(reading?.status).toBe("ok");
125
+ expect(reading?.usedTokens).toBe(126_000);
126
+ expect(reading?.fraction).toBeCloseTo(0.63);
127
+ expect(reading?.source).toBe("statusline");
128
+ } finally {
129
+ rmSync(path, { force: true });
130
+ }
131
+ });
132
+
133
+ it("treats a state file older than the staleness window as absent", () => {
134
+ const sessionId = randomUUID();
135
+ const path = statuslineStateFilePath(sessionId);
136
+ const staleTimestamp = Date.now() - 10 * 60 * 1000; // 10 minutes old
137
+ writeFileSync(
138
+ path,
139
+ JSON.stringify({ used_percentage: 63, context_window_size: 200_000, session_id: sessionId, timestamp: staleTimestamp })
140
+ );
141
+
142
+ try {
143
+ const reading = readStatuslineFill(sessionId, Date.now());
144
+ expect(reading).toBeNull();
145
+ } finally {
146
+ rmSync(path, { force: true });
147
+ }
148
+ });
149
+
150
+ it("returns null when no state file exists for this session", () => {
151
+ expect(readStatuslineFill(randomUUID())).toBeNull();
152
+ });
153
+ });
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // getContextFill — precedence
157
+ // ---------------------------------------------------------------------------
158
+
159
+ describe("getContextFill precedence", () => {
160
+ it("prefers a fresh statusline reading over the transcript", () => {
161
+ const sessionId = randomUUID();
162
+ const path = statuslineStateFilePath(sessionId);
163
+ const now = Date.now();
164
+ writeFileSync(
165
+ path,
166
+ JSON.stringify({ used_percentage: 63, context_window_size: 200_000, session_id: sessionId, timestamp: now })
167
+ );
168
+
169
+ const transcriptPath = join(root, "t.jsonl");
170
+ writeJsonl(transcriptPath, [
171
+ usageEntry({ input_tokens: 1000, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }),
172
+ ]);
173
+
174
+ try {
175
+ const reading = getContextFill({ sessionId, transcriptPath }, now);
176
+ expect(reading.source).toBe("statusline");
177
+ expect(reading.usedTokens).toBe(126_000);
178
+ } finally {
179
+ rmSync(path, { force: true });
180
+ }
181
+ });
182
+
183
+ it("falls back to the transcript when the statusline state is stale", () => {
184
+ const sessionId = randomUUID();
185
+ const path = statuslineStateFilePath(sessionId);
186
+ const staleTimestamp = Date.now() - 10 * 60 * 1000;
187
+ writeFileSync(
188
+ path,
189
+ JSON.stringify({ used_percentage: 63, context_window_size: 200_000, session_id: sessionId, timestamp: staleTimestamp })
190
+ );
191
+
192
+ const transcriptPath = join(root, "t.jsonl");
193
+ writeJsonl(transcriptPath, [
194
+ usageEntry({ input_tokens: 4000, cache_read_input_tokens: 1000, cache_creation_input_tokens: 0 }),
195
+ ]);
196
+
197
+ try {
198
+ const reading = getContextFill({ sessionId, transcriptPath }, Date.now());
199
+ expect(reading.source).toBe("transcript");
200
+ expect(reading.usedTokens).toBe(5000);
201
+ } finally {
202
+ rmSync(path, { force: true });
203
+ }
204
+ });
205
+
206
+ it("returns unknown when neither source is available", () => {
207
+ const reading = getContextFill({ sessionId: randomUUID() });
208
+ expect(reading.status).toBe("unknown");
209
+ expect(reading.windowSize).toBe(DEFAULT_CONTEXT_WINDOW);
210
+ });
211
+ });
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // formatContextFill — clamp-and-flag
215
+ // ---------------------------------------------------------------------------
216
+
217
+ describe("formatContextFill", () => {
218
+ it("formats a normal reading with a k-suffix", () => {
219
+ const display = formatContextFill({
220
+ status: "ok",
221
+ usedTokens: 63_000,
222
+ windowSize: 200_000,
223
+ fraction: 0.315,
224
+ source: "transcript",
225
+ });
226
+ expect(display.text).toBe("63k");
227
+ expect(display.flagged).toBe(false);
228
+ });
229
+
230
+ it("clamps and flags a reading that exceeds the window instead of printing it", () => {
231
+ const display = formatContextFill({
232
+ status: "ok",
233
+ usedTokens: 2_135_399, // a real value pulled from a broken header
234
+ windowSize: 200_000,
235
+ fraction: 10.68,
236
+ source: "transcript",
237
+ });
238
+ expect(display.flagged).toBe(true);
239
+ expect(display.text).toContain("200k");
240
+ expect(display.text).not.toContain("2135399");
241
+ });
242
+
243
+ it("renders unknown as text, never a confident zero", () => {
244
+ const display = formatContextFill({
245
+ status: "unknown",
246
+ usedTokens: null,
247
+ windowSize: 200_000,
248
+ fraction: null,
249
+ source: "unknown",
250
+ });
251
+ expect(display.text).toBe("unknown");
252
+ });
253
+ });
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // contextFillThresholds — derived from the live autocompact override, not
257
+ // memorized from a past measurement (the trigger moved once already: ~100%
258
+ // of a 1M window through 09-10, ~78-79% from 09-12 on, four days apart).
259
+ // ---------------------------------------------------------------------------
260
+
261
+ function readingAt(windowSize: number, source: ContextFillReading["source"] = "statusline"): ContextFillReading {
262
+ return { status: "ok", usedTokens: 0, windowSize, fraction: 0, source };
263
+ }
264
+
265
+ describe("contextFillThresholds", () => {
266
+ // Fixture requested verbatim: override=80 explicit, 1M window → warmUp
267
+ // 684,000. That does not hold up: effectiveTrigger = 1,000,000 * 80/100 =
268
+ // 800,000, and warmUp = effectiveTrigger - 100,000 = 700,000 by the stated
269
+ // formula — 684,000 has no derivation from these inputs, and an EXPLICIT
270
+ // override of 80 cannot legitimately produce a different effectiveTrigger
271
+ // than an ABSENT override that defaults to the same 80, which the next
272
+ // fixture below requires to be 700,000. Asserting 684,000 here would only
273
+ // be possible by special-casing "override was explicitly set" versus
274
+ // "override defaulted", which the formula gives no basis for. Testing the
275
+ // formula as specified instead: both explicit-80 and absent-defaults-to-80
276
+ // must agree, and they do.
277
+ it("derives warmup from windowSize * (override/100) - 100k, for an explicit override", () => {
278
+ const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" });
279
+ expect(t.effectiveTriggerTokens).toBe(800_000);
280
+ expect(t.warmupTokens).toBe(700_000);
281
+ });
282
+
283
+ it("derives the same effective trigger when the override is absent (defaults to 80)", () => {
284
+ const t = contextFillThresholds(readingAt(1_000_000), {});
285
+ expect(t.autocompactPct).toBe(80);
286
+ expect(t.effectiveTriggerTokens).toBe(800_000);
287
+ expect(t.warmupTokens).toBe(700_000);
288
+ });
289
+
290
+ it("scales down correctly for a 200k window with the override absent", () => {
291
+ const t = contextFillThresholds(readingAt(200_000), {});
292
+ expect(t.effectiveTriggerTokens).toBe(160_000);
293
+ expect(t.warmupTokens).toBe(60_000);
294
+ });
295
+
296
+ it("computes refresh 40k and immediate 15k below the effective trigger", () => {
297
+ const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" });
298
+ expect(t.refreshTokens).toBe(760_000);
299
+ expect(t.immediateTokens).toBe(785_000);
300
+ });
301
+
302
+ it("marks the window confirmed only for a statusline-sourced reading", () => {
303
+ expect(contextFillThresholds(readingAt(1_000_000, "statusline"), {}).windowConfirmed).toBe(true);
304
+ expect(contextFillThresholds(readingAt(DEFAULT_CONTEXT_WINDOW, "transcript"), {}).windowConfirmed).toBe(false);
305
+ });
306
+
307
+ it("clamps a threshold that would go negative instead of returning it", () => {
308
+ // A tiny window where even the trigger itself is below the warmup margin.
309
+ const t = contextFillThresholds(readingAt(50_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" });
310
+ expect(t.effectiveTriggerTokens).toBe(40_000);
311
+ expect(t.warmupTokens).toBe(0); // 40,000 - 100,000 would be negative
312
+ });
313
+ });
314
+
315
+ describe("resolveAutocompactPct", () => {
316
+ it("defaults to 80 when the env var is absent", () => {
317
+ expect(resolveAutocompactPct({})).toBe(80);
318
+ });
319
+
320
+ it("uses a valid override", () => {
321
+ expect(resolveAutocompactPct({ CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "65" })).toBe(65);
322
+ });
323
+
324
+ it("falls back to the default for an unparseable override", () => {
325
+ expect(resolveAutocompactPct({ CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "not-a-number" })).toBe(80);
326
+ });
327
+
328
+ it("falls back to the default for an out-of-range override", () => {
329
+ expect(resolveAutocompactPct({ CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "0" })).toBe(80);
330
+ expect(resolveAutocompactPct({ CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "150" })).toBe(80);
331
+ });
332
+ });
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // crossedThresholds — once per threshold, catch-up on a big jump
336
+ // ---------------------------------------------------------------------------
337
+
338
+ describe("crossedThresholds", () => {
339
+ const thresholds = { warmupTokens: 900_000, refreshTokens: 960_000, immediateTokens: 985_000, effectiveTriggerTokens: 1_000_000, autocompactPct: 100, triggerSource: "configured" as const, windowConfirmed: true };
340
+
341
+ it("fires warmup only on a clean crossing", () => {
342
+ expect(crossedThresholds(910_000, thresholds, [])).toEqual(["warmup"]);
343
+ });
344
+
345
+ it("does not re-fire a threshold already recorded as fired", () => {
346
+ expect(crossedThresholds(920_000, thresholds, ["warmup"])).toEqual([]);
347
+ });
348
+
349
+ it("fires refresh once warmup is already fired and the session climbs further", () => {
350
+ expect(crossedThresholds(965_000, thresholds, ["warmup"])).toEqual(["refresh"]);
351
+ });
352
+
353
+ it("catches up on both thresholds in one poll when first observed already past both (the 75%+ jump case)", () => {
354
+ expect(crossedThresholds(999_000, thresholds, [])).toEqual(["warmup", "refresh"]);
355
+ });
356
+
357
+ it("fires nothing once both thresholds have already fired", () => {
358
+ expect(crossedThresholds(1_002_000, thresholds, ["warmup", "refresh"])).toEqual([]);
359
+ });
360
+ });
361
+
362
+ describe("isImmediate", () => {
363
+ const thresholds = { warmupTokens: 900_000, refreshTokens: 960_000, immediateTokens: 985_000, effectiveTriggerTokens: 1_000_000, autocompactPct: 100, triggerSource: "configured" as const, windowConfirmed: true };
364
+
365
+ it("is false below the immediate floor", () => {
366
+ expect(isImmediate(950_000, thresholds)).toBe(false);
367
+ });
368
+
369
+ it("is true at or above the immediate floor", () => {
370
+ expect(isImmediate(985_000, thresholds)).toBe(true);
371
+ expect(isImmediate(999_000, thresholds)).toBe(true);
372
+ });
373
+ });
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // measureCompactionTrigger — ground truth from the project's own history,
377
+ // preferred over the configured override chain. Never touches the real
378
+ // ~/.claude/projects/ — every test points at an isolated fixture directory
379
+ // via the injectable `projectsDir` parameter.
380
+ // ---------------------------------------------------------------------------
381
+
382
+ /** Same encoding measureCompactionTrigger uses internally, duplicated here
383
+ * only so the fixture can be written at the exact path it will look for. */
384
+ function encodeForFixture(cwd: string): string {
385
+ return cwd.replace(/[/\s.-]/g, "-");
386
+ }
387
+
388
+ function compactBoundaryLine(preTokens: number, timestamp: string): string {
389
+ return JSON.stringify({
390
+ type: "system",
391
+ subtype: "compact_boundary",
392
+ compactMetadata: { trigger: "auto", preTokens },
393
+ timestamp,
394
+ });
395
+ }
396
+
397
+ describe("measureCompactionTrigger", () => {
398
+ it("returns the MINIMUM of the most recent three compact_boundary events, oldest-eligible excluded", () => {
399
+ const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
400
+ const cwd = "/fake/project/measured";
401
+ const projectDir = join(projectsDir, encodeForFixture(cwd));
402
+ mkdirSync(projectDir, { recursive: true });
403
+
404
+ // Five events across two files, all in the SAME project. The most
405
+ // recent three (by timestamp) are 784,000 / 786,000 / 998,000 — their
406
+ // minimum is 784,000. The oldest two (990,000 / 995,000, from the prior
407
+ // regime) must be excluded from the sample entirely, not just from the
408
+ // minimum, or a long-lived project would never fully leave a stale
409
+ // regime behind.
410
+ writeFileSync(
411
+ join(projectDir, "a.jsonl"),
412
+ [
413
+ compactBoundaryLine(995_000, "2026-08-16T00:00:00.000Z"),
414
+ compactBoundaryLine(990_000, "2026-08-20T00:00:00.000Z"),
415
+ ].join("\n") + "\n"
416
+ );
417
+ writeFileSync(
418
+ join(projectDir, "b.jsonl"),
419
+ [
420
+ compactBoundaryLine(998_000, "2026-09-12T00:00:00.000Z"),
421
+ compactBoundaryLine(786_000, "2026-09-13T00:00:00.000Z"),
422
+ compactBoundaryLine(784_000, "2026-09-14T00:00:00.000Z"),
423
+ ].join("\n") + "\n"
424
+ );
425
+
426
+ try {
427
+ const trigger = measureCompactionTrigger(cwd, projectsDir);
428
+ expect(trigger).toBe(784_000);
429
+ } finally {
430
+ rmSync(projectsDir, { recursive: true, force: true });
431
+ }
432
+ });
433
+
434
+ it("finds events in the sessions/ subdirectory too", () => {
435
+ const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
436
+ const cwd = "/fake/project/sessions-subdir";
437
+ const projectDir = join(projectsDir, encodeForFixture(cwd));
438
+ mkdirSync(join(projectDir, "sessions"), { recursive: true });
439
+
440
+ writeFileSync(
441
+ join(projectDir, "sessions", "archived.jsonl"),
442
+ compactBoundaryLine(850_000, "2026-09-15T00:00:00.000Z") + "\n"
443
+ );
444
+
445
+ try {
446
+ expect(measureCompactionTrigger(cwd, projectsDir)).toBe(850_000);
447
+ } finally {
448
+ rmSync(projectsDir, { recursive: true, force: true });
449
+ }
450
+ });
451
+
452
+ it("returns null for a project with no compaction history at all", () => {
453
+ const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
454
+ const cwd = "/fake/project/never-compacted";
455
+ const projectDir = join(projectsDir, encodeForFixture(cwd));
456
+ mkdirSync(projectDir, { recursive: true });
457
+ writeFileSync(
458
+ join(projectDir, "a.jsonl"),
459
+ JSON.stringify({ type: "user", message: { role: "user", content: "hi" } }) + "\n"
460
+ );
461
+
462
+ try {
463
+ expect(measureCompactionTrigger(cwd, projectsDir)).toBeNull();
464
+ } finally {
465
+ rmSync(projectsDir, { recursive: true, force: true });
466
+ }
467
+ });
468
+
469
+ it("returns null when the project directory does not exist yet", () => {
470
+ const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
471
+ try {
472
+ expect(measureCompactionTrigger("/fake/project/brand-new", projectsDir)).toBeNull();
473
+ } finally {
474
+ rmSync(projectsDir, { recursive: true, force: true });
475
+ }
476
+ });
477
+ });
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // contextFillThresholds — triggerSource reporting (measured preferred,
481
+ // configured as the fallback for a project with no history)
482
+ // ---------------------------------------------------------------------------
483
+
484
+ describe("contextFillThresholds — trigger source", () => {
485
+ it("reports triggerSource 'measured' and uses it as the effective trigger when a measured value is available", () => {
486
+ const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 784_000 });
487
+ expect(t.triggerSource).toBe("measured");
488
+ expect(t.effectiveTriggerTokens).toBe(784_000);
489
+ expect(t.warmupTokens).toBe(684_000); // this is where the coordinator's 684,000 actually comes from
490
+ // The configured chain is still computed and reported, just not used:
491
+ expect(t.autocompactPct).toBe(80);
492
+ });
493
+
494
+ it("reports triggerSource 'configured' and falls back to the env chain when there is no measured value", () => {
495
+ const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" }, { measuredTrigger: null });
496
+ expect(t.triggerSource).toBe("configured");
497
+ expect(t.effectiveTriggerTokens).toBe(800_000);
498
+ expect(t.warmupTokens).toBe(700_000);
499
+ });
500
+
501
+ it("falls back to configured when cwd is given but the project has no compaction history (real scan, empty fixture)", () => {
502
+ const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
503
+ const cwd = "/fake/project/no-history-yet";
504
+ try {
505
+ // No directory created at all for this cwd — genuinely no history.
506
+ const t = contextFillThresholds(readingAt(1_000_000), {}, {
507
+ measuredTrigger: measureCompactionTrigger(cwd, projectsDir),
508
+ });
509
+ expect(t.triggerSource).toBe("configured");
510
+ expect(t.effectiveTriggerTokens).toBe(800_000);
511
+ } finally {
512
+ rmSync(projectsDir, { recursive: true, force: true });
513
+ }
514
+ });
515
+ });