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