@tekmidian/pai 0.50.0 → 0.51.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.
@@ -58,7 +58,7 @@ Cache/input/output token split for a session transcript (default: newest)
58
58
 
59
59
  | Option | Description | Default |
60
60
  |--------|-------------|---------|
61
- | `--ctx-threshold <n>` | Per-turn context size above which a turn counts as 'above threshold' (default 200000) | |
61
+ | `--ctx-threshold <n>` | Override the derived compaction trigger (default: derived from the session's own window and measured/configured trigger — see 'window'/'trigger' in the report header) | |
62
62
  | `--history [n]` | List first-turn context of the newest n sessions in this project's transcript directory (default 20) | |
63
63
  | `--turn <n>` | Attribute API call n's context growth to the transcript lines injected since the previous call | |
64
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekmidian/pai",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "description": "PAI Knowledge OS — Personal AI Infrastructure with federated memory and project management",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -11,6 +11,7 @@ import {
11
11
  formatContextFill,
12
12
  statuslineStateFilePath,
13
13
  contextFillThresholds,
14
+ configuredTrigger,
14
15
  resolveAutocompactPct,
15
16
  measureCompactionTrigger,
16
17
  modelFamily,
@@ -269,40 +270,33 @@ function readingAt(windowSize: number, source: ContextFillReading["source"] = "s
269
270
  }
270
271
 
271
272
  describe("contextFillThresholds", () => {
272
- // Fixture requested verbatim: override=80 explicit, 1M window → warmUp
273
- // 684,000. That does not hold up: effectiveTrigger = 1,000,000 * 80/100 =
274
- // 800,000, and warmUp = effectiveTrigger - 100,000 = 700,000 by the stated
275
- // formula — 684,000 has no derivation from these inputs, and an EXPLICIT
276
- // override of 80 cannot legitimately produce a different effectiveTrigger
277
- // than an ABSENT override that defaults to the same 80, which the next
278
- // fixture below requires to be 700,000. Asserting 684,000 here would only
279
- // be possible by special-casing "override was explicitly set" versus
280
- // "override defaulted", which the formula gives no basis for. Testing the
281
- // formula as specified instead: both explicit-80 and absent-defaults-to-80
282
- // must agree, and they do.
283
- it("derives warmup from windowSize * (override/100) - 100k, for an explicit override", () => {
273
+ // effectiveTrigger = configuredTrigger(windowSize, pct) = pct/100 *
274
+ // (windowSize - 20,000) — NOT windowSize * pct/100. An EXPLICIT override
275
+ // of 80 and an ABSENT override that defaults to the same 80 must agree,
276
+ // and they do: both give 784,000 on a 1M window, warmUp 684,000.
277
+ it("derives warmup from configuredTrigger(windowSize, override) - 100k, for an explicit override", () => {
284
278
  const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" });
285
- expect(t.effectiveTriggerTokens).toBe(800_000);
286
- expect(t.warmupTokens).toBe(700_000);
279
+ expect(t.effectiveTriggerTokens).toBe(784_000);
280
+ expect(t.warmupTokens).toBe(684_000);
287
281
  });
288
282
 
289
283
  it("derives the same effective trigger when the override is absent (defaults to 80)", () => {
290
284
  const t = contextFillThresholds(readingAt(1_000_000), {});
291
285
  expect(t.autocompactPct).toBe(80);
292
- expect(t.effectiveTriggerTokens).toBe(800_000);
293
- expect(t.warmupTokens).toBe(700_000);
286
+ expect(t.effectiveTriggerTokens).toBe(784_000);
287
+ expect(t.warmupTokens).toBe(684_000);
294
288
  });
295
289
 
296
290
  it("scales down correctly for a 200k window with the override absent", () => {
297
291
  const t = contextFillThresholds(readingAt(200_000), {});
298
- expect(t.effectiveTriggerTokens).toBe(160_000);
299
- expect(t.warmupTokens).toBe(60_000);
292
+ expect(t.effectiveTriggerTokens).toBe(144_000);
293
+ expect(t.warmupTokens).toBe(44_000);
300
294
  });
301
295
 
302
296
  it("computes refresh 40k and immediate 15k below the effective trigger", () => {
303
297
  const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" });
304
- expect(t.refreshTokens).toBe(760_000);
305
- expect(t.immediateTokens).toBe(785_000);
298
+ expect(t.refreshTokens).toBe(744_000);
299
+ expect(t.immediateTokens).toBe(769_000);
306
300
  });
307
301
 
308
302
  it("marks the window confirmed only for a statusline-sourced reading", () => {
@@ -313,8 +307,15 @@ describe("contextFillThresholds", () => {
313
307
  it("clamps a threshold that would go negative instead of returning it", () => {
314
308
  // A tiny window where even the trigger itself is below the warmup margin.
315
309
  const t = contextFillThresholds(readingAt(50_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" });
316
- expect(t.effectiveTriggerTokens).toBe(40_000);
317
- expect(t.warmupTokens).toBe(0); // 40,000 - 100,000 would be negative
310
+ expect(t.effectiveTriggerTokens).toBe(24_000); // 0.8 * (50,000 - 20,000)
311
+ expect(t.warmupTokens).toBe(0); // 24,000 - 100,000 would be negative
312
+ });
313
+ });
314
+
315
+ describe("configuredTrigger", () => {
316
+ it("is pct/100 * (windowSize - 20,000), not windowSize * pct/100", () => {
317
+ expect(configuredTrigger(1_000_000, 80)).toBe(784_000);
318
+ expect(configuredTrigger(200_000, 80)).toBe(144_000);
318
319
  });
319
320
  });
320
321
 
@@ -570,8 +571,8 @@ describe("contextFillThresholds — trigger source", () => {
570
571
  it("reports triggerSource 'configured' and falls back to the env chain when there is no measured value", () => {
571
572
  const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" }, { measuredTrigger: null });
572
573
  expect(t.triggerSource).toBe("configured");
573
- expect(t.effectiveTriggerTokens).toBe(800_000);
574
- expect(t.warmupTokens).toBe(700_000);
574
+ expect(t.effectiveTriggerTokens).toBe(784_000);
575
+ expect(t.warmupTokens).toBe(684_000);
575
576
  });
576
577
 
577
578
  it("falls back to configured when cwd is given but the project has no compaction history (real scan, empty fixture)", () => {
@@ -583,7 +584,7 @@ describe("contextFillThresholds — trigger source", () => {
583
584
  measuredTrigger: measureCompactionTrigger(cwd, projectsDir),
584
585
  });
585
586
  expect(t.triggerSource).toBe("configured");
586
- expect(t.effectiveTriggerTokens).toBe(800_000);
587
+ expect(t.effectiveTriggerTokens).toBe(784_000);
587
588
  } finally {
588
589
  rmSync(projectsDir, { recursive: true, force: true });
589
590
  }
@@ -600,27 +601,27 @@ describe("contextFillThresholds — trigger source", () => {
600
601
 
601
602
  it("CLAMPS a stale-HIGH measured value down to the configured one — the real CaseLeaf case", () => {
602
603
  // measured=998,267 (this project's real, but stale, most-recent trigger)
603
- // configured=800,000 (80% of a 1,000,000 window)
604
+ // configured=784,000 (80% of a 1,000,000 window, platform formula)
604
605
  const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 998_267 });
605
606
  expect(t.measuredTriggerTokens).toBe(998_267);
606
- expect(t.configuredTriggerTokens).toBe(800_000);
607
+ expect(t.configuredTriggerTokens).toBe(784_000);
607
608
  expect(t.triggerSource).toBe("measured-clamped");
608
- expect(t.effectiveTriggerTokens).toBe(800_000); // min(998267, 800000)
609
- expect(t.warmupTokens).toBe(700_000); // fires well before the real ~784,000 boundary
609
+ expect(t.effectiveTriggerTokens).toBe(784_000); // min(998267, 784000)
610
+ expect(t.warmupTokens).toBe(684_000);
610
611
  });
611
612
 
612
613
  it("uses the measured value directly when it is LOWER than configured — no clamp needed", () => {
613
- // measured=784,000, configured=800,000 -> measured wins, tighter warm-up.
614
- const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 784_000 });
614
+ // measured=700,000, configured=784,000 -> measured wins, tighter warm-up.
615
+ const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 700_000 });
615
616
  expect(t.triggerSource).toBe("measured");
616
- expect(t.effectiveTriggerTokens).toBe(784_000);
617
- expect(t.warmupTokens).toBe(684_000);
617
+ expect(t.effectiveTriggerTokens).toBe(700_000);
618
+ expect(t.warmupTokens).toBe(600_000);
618
619
  });
619
620
 
620
621
  it("uses measured directly when it exactly equals configured (boundary case, not clamped)", () => {
621
- const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 800_000 });
622
+ const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 784_000 });
622
623
  expect(t.triggerSource).toBe("measured");
623
- expect(t.effectiveTriggerTokens).toBe(800_000);
624
+ expect(t.effectiveTriggerTokens).toBe(784_000);
624
625
  });
625
626
 
626
627
  it("reports both raw values on the result even when one of them wasn't used, so the clamp is visible", () => {
@@ -628,7 +629,7 @@ describe("contextFillThresholds — trigger source", () => {
628
629
  // Both numbers are on the object — a caller can see what was measured
629
630
  // AND what was configured, not just the winner.
630
631
  expect(t.measuredTriggerTokens).toBe(998_267);
631
- expect(t.configuredTriggerTokens).toBe(800_000);
632
+ expect(t.configuredTriggerTokens).toBe(784_000);
632
633
  });
633
634
  });
634
635
 
@@ -642,36 +643,113 @@ describe("contextFillThresholds — trigger source", () => {
642
643
  // ---------------------------------------------------------------------------
643
644
 
644
645
  describe("contextFillThresholds — 2026-09-20 regime change (override 80 -> 20)", () => {
645
- it("measured 784k + configured 200k (override=20, 1M window) -> clamps to 200k", () => {
646
+ it("measured 784k + configured 196k (override=20, 1M window) -> clamps to 196k", () => {
646
647
  const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "20" }, { measuredTrigger: 784_000 });
647
- expect(t.configuredTriggerTokens).toBe(200_000);
648
+ expect(t.configuredTriggerTokens).toBe(196_000); // 0.2 * (1,000,000 - 20,000)
648
649
  expect(t.triggerSource).toBe("measured-clamped");
649
- expect(t.effectiveTriggerTokens).toBe(200_000);
650
- expect(t.warmupTokens).toBe(100_000); // 200,000 - 100,000
650
+ expect(t.effectiveTriggerTokens).toBe(196_000);
651
+ expect(t.warmupTokens).toBe(96_000); // 196,000 - 100,000
651
652
  });
652
653
 
653
- it("measured 784k + configured 800k (override=80, 1M window) -> measured wins at 784k", () => {
654
+ it("measured 784k + configured 784k (override=80, 1M window) -> measured wins at 784k", () => {
654
655
  const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" }, { measuredTrigger: 784_000 });
655
- expect(t.configuredTriggerTokens).toBe(800_000);
656
+ expect(t.configuredTriggerTokens).toBe(784_000);
656
657
  expect(t.triggerSource).toBe("measured");
657
658
  expect(t.effectiveTriggerTokens).toBe(784_000);
658
659
  });
659
660
 
660
- it("measured null + configured 200k (override=20, no compaction history yet) -> 200k", () => {
661
+ it("measured null + configured 196k (override=20, no compaction history yet) -> 196k", () => {
661
662
  const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "20" }, { measuredTrigger: null });
662
663
  expect(t.triggerSource).toBe("configured");
663
- expect(t.effectiveTriggerTokens).toBe(200_000);
664
+ expect(t.effectiveTriggerTokens).toBe(196_000);
664
665
  });
665
666
 
666
- it("measured 784k + override unset (defaults to 80, configured 800k) -> measured wins at 784k", () => {
667
+ it("measured 784k + override unset (defaults to 80, configured 784k) -> measured wins at 784k", () => {
667
668
  const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 784_000 });
668
669
  expect(t.autocompactPct).toBe(80);
669
- expect(t.configuredTriggerTokens).toBe(800_000);
670
+ expect(t.configuredTriggerTokens).toBe(784_000);
670
671
  expect(t.triggerSource).toBe("measured");
671
672
  expect(t.effectiveTriggerTokens).toBe(784_000);
672
673
  });
673
674
  });
674
675
 
676
+ // ---------------------------------------------------------------------------
677
+ // measureCompactionTrigger — admissibility filter (Fault 1, 2026-09-20):
678
+ // samples from a different override regime must not win just because they
679
+ // are numerically <= the CURRENT configured trigger. A compaction that
680
+ // fired far below the current configured trigger came from a lower
681
+ // override or a smaller window; it is discarded, not averaged in.
682
+ // ---------------------------------------------------------------------------
683
+
684
+ describe("measureCompactionTrigger — other-regime samples are discarded", () => {
685
+ it("discards all samples below half of configured and falls back to configured (measured=null, source configured)", () => {
686
+ const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
687
+ const cwd = "/fake/project/other-regime-all-discarded";
688
+ const projectDir = join(projectsDir, encodeForFixture(cwd));
689
+ mkdirSync(projectDir, { recursive: true });
690
+ writeFileSync(
691
+ join(projectDir, "a.jsonl"),
692
+ [
693
+ compactBoundaryLine(196_765, "2026-09-20T13:10:00.000Z", "u1"),
694
+ compactBoundaryLine(195_075, "2026-09-20T15:00:00.000Z", "u2"),
695
+ compactBoundaryLine(197_902, "2026-09-20T19:00:00.000Z", "u3"),
696
+ ].join("\n") + "\n"
697
+ );
698
+ try {
699
+ // configured = configuredTrigger(1,000,000, 80) = 784,000; floor = 392,000.
700
+ const measured = measureCompactionTrigger(cwd, projectsDir, undefined, 784_000);
701
+ expect(measured).toBeNull();
702
+
703
+ const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" }, { measuredTrigger: measured });
704
+ expect(t.triggerSource).toBe("configured");
705
+ expect(t.effectiveTriggerTokens).toBe(784_000);
706
+ } finally {
707
+ rmSync(projectsDir, { recursive: true, force: true });
708
+ }
709
+ });
710
+
711
+ it("keeps admissible samples and discards only the other-regime one (measured=780,000, source measured)", () => {
712
+ const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
713
+ const cwd = "/fake/project/other-regime-partial";
714
+ const projectDir = join(projectsDir, encodeForFixture(cwd));
715
+ mkdirSync(projectDir, { recursive: true });
716
+ writeFileSync(
717
+ join(projectDir, "a.jsonl"),
718
+ [
719
+ compactBoundaryLine(780_000, "2026-09-19T09:00:00.000Z", "u1"),
720
+ compactBoundaryLine(790_000, "2026-09-19T10:00:00.000Z", "u2"),
721
+ compactBoundaryLine(195_000, "2026-09-20T15:00:00.000Z", "u3"), // other-regime, discarded
722
+ ].join("\n") + "\n"
723
+ );
724
+ try {
725
+ const measured = measureCompactionTrigger(cwd, projectsDir, undefined, 784_000);
726
+ expect(measured).toBe(780_000);
727
+
728
+ const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" }, { measuredTrigger: measured });
729
+ expect(t.triggerSource).toBe("measured");
730
+ expect(t.effectiveTriggerTokens).toBe(780_000);
731
+ } finally {
732
+ rmSync(projectsDir, { recursive: true, force: true });
733
+ }
734
+ });
735
+
736
+ it("applies no filter at all when configuredTriggerTokens is omitted (back-compat with the raw scan)", () => {
737
+ const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
738
+ const cwd = "/fake/project/no-filter";
739
+ const projectDir = join(projectsDir, encodeForFixture(cwd));
740
+ mkdirSync(projectDir, { recursive: true });
741
+ writeFileSync(
742
+ join(projectDir, "a.jsonl"),
743
+ compactBoundaryLine(195_000, "2026-09-20T15:00:00.000Z", "u1") + "\n"
744
+ );
745
+ try {
746
+ expect(measureCompactionTrigger(cwd, projectsDir)).toBe(195_000);
747
+ } finally {
748
+ rmSync(projectsDir, { recursive: true, force: true });
749
+ }
750
+ });
751
+ });
752
+
675
753
  // ---------------------------------------------------------------------------
676
754
  // Foreign-model transcripts — a headless worker on another provider (a
677
755
  // different context window) shares the project folder; its compactions must
@@ -294,7 +294,8 @@ export function formatContextFill(reading: ContextFillReading): FillDisplay {
294
294
  // The configured chain — env override, then a default — is the fallback,
295
295
  // used ONLY when a project has no compaction history yet:
296
296
  //
297
- // effectiveTrigger = measured ?? (windowSize * (override ?? 80) / 100)
297
+ // effectiveTrigger = measured ?? configuredTrigger(windowSize, override ?? 80)
298
+ // configuredTrigger(windowSize, pct) = pct/100 * (windowSize - 20,000)
298
299
  //
299
300
  // DEFAULT IS 80, NOT 100, WHEN NOTHING IS KNOWN. 100 was considered — it
300
301
  // matches the FIRST regime above — and rejected: the costs are asymmetric.
@@ -313,6 +314,17 @@ export function formatContextFill(reading: ContextFillReading): FillDisplay {
313
314
  // waiting for one.
314
315
  export const DEFAULT_AUTOCOMPACT_PCT = 80;
315
316
 
317
+ /**
318
+ * The platform's actual compaction trigger for a window/pct pair. NOT
319
+ * windowSize * pct/100 — that formula gives 800,000 on 1M/80 and 160,000 on
320
+ * 200k/80, but the platform fires at pct/100 * (windowSize - 20,000):
321
+ * 784,000 and 144,000, measured on every compact_boundary on this machine,
322
+ * 2026-09-20 (see the module comment above).
323
+ */
324
+ export function configuredTrigger(windowSize: number, pct: number): number {
325
+ return Math.round((pct / 100) * (windowSize - 20_000));
326
+ }
327
+
316
328
  /** How many of the most recent compact_boundary events to consider, and to
317
329
  * take the minimum of. */
318
330
  const MEASURED_TRIGGER_SAMPLE_SIZE = 3;
@@ -575,26 +587,57 @@ export function selectedCompactionSamples(
575
587
 
576
588
  /**
577
589
  * The measured compaction trigger for a project, or null when it has no
578
- * compaction history yet (a brand-new project, or one whose transcripts
579
- * this process cannot read). Minimum of the most recent
580
- * MEASURED_TRIGGER_SAMPLE_SIZE DISTINCT compact_boundary events, ordered by
581
- * the events' own timestamps across every transcript the project has
582
- * (live and archived) — see the module comment above for why minimum, not
583
- * mean, and readCompactBoundarySamples for why "distinct" and "own
584
- * timestamp" both matter (a file-mtime-ordered, non-deduplicated version of
585
- * this returned a stale pre-regime-change trigger on real project data).
590
+ * compaction history yet (a brand-new project, one whose transcripts this
591
+ * process cannot read, or one whose only recent samples belong to a
592
+ * different override regime — see the admissibility filter below).
593
+ * Minimum of the most recent MEASURED_TRIGGER_SAMPLE_SIZE DISTINCT
594
+ * compact_boundary events, ordered by the events' own timestamps across
595
+ * every transcript the project has (live and archived) — see the module
596
+ * comment above for why minimum, not mean, and readCompactBoundarySamples
597
+ * for why "distinct" and "own timestamp" both matter (a file-mtime-ordered,
598
+ * non-deduplicated version of this returned a stale pre-regime-change
599
+ * trigger on real project data).
600
+ *
601
+ * When `configuredTriggerTokens` is given, a sample is admissible only when
602
+ * preTokens >= 0.5 * configuredTriggerTokens — a compaction that fired far
603
+ * below the currently configured trigger came from a lower override or a
604
+ * smaller window, a different regime than the one in force now, and must
605
+ * not drag the trigger down with it. Real case, 2026-09-20:
606
+ * CLAUDE_AUTOCOMPACT_PCT_OVERRIDE was 20 for seven hours, this project
607
+ * compacted three times at ~195k-198k under it, then the override went
608
+ * back to 80 (configured ~784k) — those three samples are inadmissible and
609
+ * must be discarded, not averaged into the new regime's trigger, or the
610
+ * handover would fire at ~155k for days until three fresh ~784k
611
+ * compactions pushed them out on their own.
586
612
  */
587
613
  export function measureCompactionTrigger(
588
614
  cwd: string,
589
615
  projectsDir: string = CLAUDE_PROJECTS_DIR,
590
- configPath?: string
616
+ configPath?: string,
617
+ configuredTriggerTokens?: number
591
618
  ): number | null {
592
619
  const samples = selectedCompactionSamples(cwd, projectsDir, configPath);
593
620
  if (samples.length === 0) return null;
594
- const trigger = Math.min(...samples.map((s) => s.preTokens));
621
+
622
+ let admissible = samples;
623
+ if (configuredTriggerTokens !== undefined) {
624
+ const floor = 0.5 * configuredTriggerTokens;
625
+ admissible = samples.filter((s) => s.preTokens >= floor);
626
+ const discarded = samples.length - admissible.length;
627
+ if (discarded > 0) {
628
+ console.error(
629
+ `[context-fill] discarded ${discarded} compaction sample(s) as other-regime for ${cwd} ` +
630
+ `(preTokens below ${Math.round(floor)}, half of configured=${configuredTriggerTokens}): ` +
631
+ samples.filter((s) => s.preTokens < floor).map((s) => `${s.preTokens}@${s.timestamp}`).join(", ")
632
+ );
633
+ }
634
+ if (admissible.length === 0) return null;
635
+ }
636
+
637
+ const trigger = Math.min(...admissible.map((s) => s.preTokens));
595
638
  console.error(
596
639
  `[context-fill] measured trigger for ${cwd}: ${trigger} (minimum of ` +
597
- samples.map((s) => `${s.preTokens}@${s.timestamp}`).join(", ") + ")"
640
+ admissible.map((s) => `${s.preTokens}@${s.timestamp}`).join(", ") + ")"
598
641
  );
599
642
  return trigger;
600
643
  }
@@ -717,12 +760,12 @@ export function contextFillThresholds(
717
760
  ): ContextFillThresholds {
718
761
  const windowConfirmed = reading.source === "statusline";
719
762
  const autocompactPct = resolveAutocompactPct(env);
720
- const configuredTriggerTokens = Math.round(reading.windowSize * (autocompactPct / 100));
763
+ const configuredTriggerTokens = configuredTrigger(reading.windowSize, autocompactPct);
721
764
 
722
765
  const measuredTriggerTokens = opts.measuredTrigger !== undefined
723
766
  ? opts.measuredTrigger
724
767
  : opts.cwd
725
- ? measureCompactionTrigger(opts.cwd)
768
+ ? measureCompactionTrigger(opts.cwd, CLAUDE_PROJECTS_DIR, undefined, configuredTriggerTokens)
726
769
  : null;
727
770
 
728
771
  let effectiveTriggerTokens: number;
@@ -699,8 +699,12 @@ if [ "$session_provider" = "anthropic" ]; then
699
699
  remaining_secs=$((seven_reset_epoch - now_epoch))
700
700
  [ "$remaining_secs" -lt 0 ] && remaining_secs=0
701
701
  elapsed_secs=$((window_secs - remaining_secs))
702
- # Daily pace: actual spend/day vs dynamic budget
703
- # Budget = remaining capacity / remaining days (not static 100/7)
702
+ # Daily pace: actual spend/day vs the flat weekly share.
703
+ # Budget = static 100/7 (not remaining capacity / remaining days): the
704
+ # user reads "1d" as "what I may spend today", a fixed share of the
705
+ # week, not a figure that shrinks as the week is used — dividing the
706
+ # shrinking remainder by the shrinking remaining days double-counted
707
+ # overspend and drove the pace red for no new reason.
704
708
  elapsed_days_x10=$((elapsed_secs * 10 / 86400))
705
709
  # A per-day rate divided by a fraction of a day is not a rate, it is an
706
710
  # extrapolation from noise: an hour into a fresh window the old clamp to
@@ -708,10 +712,7 @@ if [ "$session_provider" = "anthropic" ]; then
708
712
  # Below half a day there is nothing to pace against yet — say nothing.
709
713
  if [ "$elapsed_days_x10" -ge 5 ]; then
710
714
  spend_per_day=$((seven_day_int * 10 / elapsed_days_x10))
711
- remaining_days_x10=$((remaining_secs * 10 / 86400))
712
- [ "$remaining_days_x10" -lt 1 ] && remaining_days_x10=1
713
- remaining_budget=$((100 - seven_day_int))
714
- budget_per_day=$((remaining_budget * 10 / remaining_days_x10))
715
+ budget_per_day=$((100 / 7))
715
716
  # Color: green = under budget, orange = near budget, red = over budget
716
717
  overspend=$((spend_per_day - budget_per_day))
717
718
  if [ "$overspend" -le -3 ] 2>/dev/null; then
@@ -903,10 +904,21 @@ if [ "$session_provider" != "anthropic" ]; then
903
904
  fi
904
905
 
905
906
  # LINE 3 - Context meter + usage limits
906
- # Auto-compact remaining: how much context left until compaction triggers
907
+ # Auto-compact remaining: how much context left until compaction triggers.
908
+ # Claude Code fires the auto-compact hook at pct/100 * (window - 20000)
909
+ # tokens, not at pct% of the raw window — the first 20k tokens of any window
910
+ # are reserved and never count toward the trigger. Computing "% left" against
911
+ # the raw window (as a plain threshold-context_pct subtraction did) reads as
912
+ # "the window is full" on a 1M-token session that still has 80% of its real
913
+ # capacity free. Work in tokens, then re-express as a percent of the real
914
+ # window so the figure is comparable across window sizes.
907
915
  ac_threshold="${CLAUDE_AUTOCOMPACT_PCT_OVERRIDE:-80}"
908
- ac_remaining=$((ac_threshold - context_pct))
909
- [ "$ac_remaining" -lt 0 ] && ac_remaining=0
916
+ ac_trigger_tokens=$((ac_threshold * (context_size - 20000) / 100))
917
+ ac_used_tokens=$((context_pct * context_size / 100))
918
+ ac_left_tokens=$((ac_trigger_tokens - ac_used_tokens))
919
+ [ "$ac_left_tokens" -lt 0 ] && ac_left_tokens=0
920
+ ac_remaining=$((ac_left_tokens * 100 / context_size))
921
+ ac_left_k=$((ac_left_tokens / 1000))
910
922
  # Color the remaining %: red ≤5, yellow ≤15, green otherwise
911
923
  if [ "$ac_remaining" -le 5 ] 2>/dev/null; then
912
924
  ac_color="$BRIGHT_RED"
@@ -915,7 +927,7 @@ elif [ "$ac_remaining" -le 15 ] 2>/dev/null; then
915
927
  else
916
928
  ac_color="$BRIGHT_GREEN"
917
929
  fi
918
- ac_suffix=" ${ac_color}(${ac_remaining}%% left)${RESET}"
930
+ ac_suffix=" ${ac_color}(${ac_remaining}%% left, ${ac_left_k}K)${RESET}"
919
931
 
920
932
  if [ "$context_pct" -gt 0 ] 2>/dev/null; then
921
933
  # Color based on usage: green < 50%, yellow 50-75%, red > 75%