@tekmidian/pai 0.36.0 → 0.36.2

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.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { randomUUID } from "node:crypto";
@@ -13,6 +13,7 @@ import {
13
13
  contextFillThresholds,
14
14
  resolveAutocompactPct,
15
15
  measureCompactionTrigger,
16
+ selectedCompactionSamples,
16
17
  crossedThresholds,
17
18
  isImmediate,
18
19
  DEFAULT_CONTEXT_WINDOW,
@@ -336,7 +337,7 @@ describe("resolveAutocompactPct", () => {
336
337
  // ---------------------------------------------------------------------------
337
338
 
338
339
  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
+ 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 };
340
341
 
341
342
  it("fires warmup only on a clean crossing", () => {
342
343
  expect(crossedThresholds(910_000, thresholds, [])).toEqual(["warmup"]);
@@ -360,7 +361,7 @@ describe("crossedThresholds", () => {
360
361
  });
361
362
 
362
363
  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
+ 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 };
364
365
 
365
366
  it("is false below the immediate floor", () => {
366
367
  expect(isImmediate(950_000, thresholds)).toBe(false);
@@ -385,12 +386,13 @@ function encodeForFixture(cwd: string): string {
385
386
  return cwd.replace(/[/\s.-]/g, "-");
386
387
  }
387
388
 
388
- function compactBoundaryLine(preTokens: number, timestamp: string): string {
389
+ function compactBoundaryLine(preTokens: number, timestamp: string, uuid?: string): string {
389
390
  return JSON.stringify({
390
391
  type: "system",
391
392
  subtype: "compact_boundary",
392
393
  compactMetadata: { trigger: "auto", preTokens },
393
394
  timestamp,
395
+ ...(uuid ? { uuid } : {}),
394
396
  });
395
397
  }
396
398
 
@@ -431,6 +433,75 @@ describe("measureCompactionTrigger", () => {
431
433
  }
432
434
  });
433
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
+
434
505
  it("finds events in the sessions/ subdirectory too", () => {
435
506
  const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
436
507
  const cwd = "/fake/project/sessions-subdir";
@@ -512,4 +583,46 @@ describe("contextFillThresholds — trigger source", () => {
512
583
  rmSync(projectsDir, { recursive: true, force: true });
513
584
  }
514
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
+ });
515
628
  });
@@ -29,7 +29,7 @@
29
29
  * them) for the entire session.
30
30
  */
31
31
 
32
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
32
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
33
33
  import { homedir, tmpdir } from "node:os";
34
34
  import { join } from "node:path";
35
35
 
@@ -291,13 +291,6 @@ export const DEFAULT_AUTOCOMPACT_PCT = 80;
291
291
  * take the minimum of. */
292
292
  const MEASURED_TRIGGER_SAMPLE_SIZE = 3;
293
293
 
294
- /** How many of a project's most-recently-modified transcripts to scan for
295
- * compact_boundary events. compact_boundary events cluster in whichever
296
- * files were touched most recently — a full-history scan would cost a lot
297
- * for a long-lived project and buy nothing this doesn't already get from
298
- * the last handful of files. */
299
- const MEASURED_TRIGGER_MAX_FILES = 8;
300
-
301
294
  export const THRESHOLD_MARGIN_TOKENS = {
302
295
  warmup: 100_000,
303
296
  refresh: 40_000,
@@ -328,16 +321,33 @@ function encodeProjectPath(cwd: string): string {
328
321
  return cwd.replace(/[/\s.-]/g, "-");
329
322
  }
330
323
 
331
- interface CompactBoundarySample {
324
+ export interface CompactBoundarySample {
332
325
  preTokens: number;
333
326
  timestampMs: number;
327
+ /** ISO string, kept alongside timestampMs so callers can print it. */
328
+ timestamp: string;
329
+ /** The event's own uuid when present — the dedup key. Claude Code's
330
+ * `sessions/` archive directory mirrors the live project transcript, so
331
+ * the SAME compact_boundary event can legitimately appear in two files;
332
+ * without deduping by identity, "most recent three" can silently become
333
+ * three copies of one event, which is a stale reading wearing a
334
+ * plausible-looking sample size. */
335
+ uuid?: string;
334
336
  }
335
337
 
338
+ /** Every `.jsonl` transcript belonging to a project — top-level (the live
339
+ * file) and `sessions/` (Claude Code's archive, which mirrors it). No file
340
+ * is excluded and no ordering is applied here: ordering by EVENT
341
+ * timestamp, not by file mtime, is the whole point (see
342
+ * readCompactBoundarySamples) — a file's mtime does not reliably track
343
+ * which events inside it are recent, and pre-filtering by mtime is exactly
344
+ * what caused this function to return a stale, pre-regime-change trigger
345
+ * on real data. */
336
346
  function listProjectTranscripts(cwd: string, projectsDir: string): string[] {
337
347
  const projectDir = join(projectsDir, encodeProjectPath(cwd));
338
348
  if (!existsSync(projectDir)) return [];
339
349
 
340
- const candidates: Array<{ path: string; mtimeMs: number }> = [];
350
+ const paths: string[] = [];
341
351
  const collect = (dir: string): void => {
342
352
  if (!existsSync(dir)) return;
343
353
  let entries: string[];
@@ -347,28 +357,23 @@ function listProjectTranscripts(cwd: string, projectsDir: string): string[] {
347
357
  return;
348
358
  }
349
359
  for (const entry of entries) {
350
- if (!entry.endsWith(".jsonl")) continue;
351
- const full = join(dir, entry);
352
- try {
353
- candidates.push({ path: full, mtimeMs: statSync(full).mtimeMs });
354
- } catch {
355
- // Unreadable — skip.
356
- }
360
+ if (entry.endsWith(".jsonl")) paths.push(join(dir, entry));
357
361
  }
358
362
  };
359
363
  collect(projectDir);
360
364
  collect(join(projectDir, "sessions"));
361
-
362
- candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
363
- return candidates.slice(0, MEASURED_TRIGGER_MAX_FILES).map((c) => c.path);
365
+ return paths;
364
366
  }
365
367
 
366
368
  /**
367
- * Every compact_boundary sample found in a project's most-recently-modified
368
- * transcripts, newest first.
369
+ * Every DISTINCT compact_boundary sample found across ALL of a project's
370
+ * transcripts (live + archived), newest first by the event's OWN timestamp
371
+ * — never by which file it came from or that file's mtime. Deduplicated by
372
+ * the event's uuid (falling back to a timestamp+preTokens key for the rare
373
+ * line with no uuid) so an event mirrored into `sessions/` is counted once.
369
374
  */
370
375
  function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBoundarySample[] {
371
- const samples: CompactBoundarySample[] = [];
376
+ const byKey = new Map<string, CompactBoundarySample>();
372
377
 
373
378
  for (const path of listProjectTranscripts(cwd, projectsDir)) {
374
379
  let raw: string;
@@ -384,6 +389,7 @@ function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBo
384
389
  type?: string;
385
390
  subtype?: string;
386
391
  timestamp?: string;
392
+ uuid?: string;
387
393
  compactMetadata?: { preTokens?: number };
388
394
  };
389
395
  try {
@@ -396,29 +402,55 @@ function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBo
396
402
  if (typeof preTokens !== "number" || !Number.isFinite(preTokens)) continue;
397
403
  const timestampMs = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
398
404
  if (!Number.isFinite(timestampMs)) continue;
399
- samples.push({ preTokens, timestampMs });
405
+
406
+ const key = entry.uuid ?? `${timestampMs}:${preTokens}`;
407
+ if (!byKey.has(key)) {
408
+ byKey.set(key, { preTokens, timestampMs, timestamp: entry.timestamp!, uuid: entry.uuid });
409
+ }
400
410
  }
401
411
  }
402
412
 
403
- samples.sort((a, b) => b.timestampMs - a.timestampMs);
404
- return samples;
413
+ return [...byKey.values()].sort((a, b) => b.timestampMs - a.timestampMs);
414
+ }
415
+
416
+ /**
417
+ * The most recent MEASURED_TRIGGER_SAMPLE_SIZE distinct compact_boundary
418
+ * events for a project, newest first — exposed on its own (not just the
419
+ * derived minimum) so the number `measureCompactionTrigger` returns is
420
+ * checkable: print these and the timestamps prove which three events
421
+ * produced it, rather than asking for trust.
422
+ */
423
+ export function selectedCompactionSamples(
424
+ cwd: string,
425
+ projectsDir: string = CLAUDE_PROJECTS_DIR
426
+ ): CompactBoundarySample[] {
427
+ if (!cwd) return [];
428
+ return readCompactBoundarySamples(cwd, projectsDir).slice(0, MEASURED_TRIGGER_SAMPLE_SIZE);
405
429
  }
406
430
 
407
431
  /**
408
432
  * The measured compaction trigger for a project, or null when it has no
409
433
  * compaction history yet (a brand-new project, or one whose transcripts
410
434
  * this process cannot read). Minimum of the most recent
411
- * MEASURED_TRIGGER_SAMPLE_SIZE compact_boundary events see the module
412
- * comment above for why minimum, not mean.
435
+ * MEASURED_TRIGGER_SAMPLE_SIZE DISTINCT compact_boundary events, ordered by
436
+ * the events' own timestamps across every transcript the project has
437
+ * (live and archived) — see the module comment above for why minimum, not
438
+ * mean, and readCompactBoundarySamples for why "distinct" and "own
439
+ * timestamp" both matter (a file-mtime-ordered, non-deduplicated version of
440
+ * this returned a stale pre-regime-change trigger on real project data).
413
441
  */
414
442
  export function measureCompactionTrigger(
415
443
  cwd: string,
416
444
  projectsDir: string = CLAUDE_PROJECTS_DIR
417
445
  ): number | null {
418
- if (!cwd) return null;
419
- const samples = readCompactBoundarySamples(cwd, projectsDir).slice(0, MEASURED_TRIGGER_SAMPLE_SIZE);
446
+ const samples = selectedCompactionSamples(cwd, projectsDir);
420
447
  if (samples.length === 0) return null;
421
- return Math.min(...samples.map((s) => s.preTokens));
448
+ const trigger = Math.min(...samples.map((s) => s.preTokens));
449
+ console.error(
450
+ `[context-fill] measured trigger for ${cwd}: ${trigger} (minimum of ` +
451
+ samples.map((s) => `${s.preTokens}@${s.timestamp}`).join(", ") + ")"
452
+ );
453
+ return trigger;
422
454
  }
423
455
 
424
456
  /**
@@ -453,20 +485,36 @@ export type ThresholdName = "warmup" | "refresh";
453
485
 
454
486
  /** Which basis actually produced effectiveTriggerTokens — reported so the
455
487
  * number is checkable rather than trusted. */
456
- export type TriggerSource = "measured" | "configured";
488
+ export type TriggerSource = "measured" | "configured" | "measured-clamped";
457
489
 
458
490
  export interface ContextFillThresholds {
459
491
  warmupTokens: number;
460
492
  refreshTokens: number;
461
493
  immediateTokens: number;
462
- /** The derived compaction trigger these were measured back from. */
494
+ /** The derived compaction trigger these were measured back from
495
+ * min(measured, configured) when a measured value exists. */
463
496
  effectiveTriggerTokens: number;
464
- /** The autocompact percentage from the configured chain — computed and
465
- * reported even when `triggerSource` is "measured" (in which case it
466
- * describes what the fallback WOULD have used, not what was used). */
497
+ /** The raw measured value from this project's own compact_boundary
498
+ * history, before any clamping null when the project has no history.
499
+ * Kept alongside effectiveTriggerTokens so a clamp is visible rather
500
+ * than silent: a caller can see both what was measured and what was
501
+ * actually used. */
502
+ measuredTriggerTokens: number | null;
503
+ /** The raw configured-chain value (env override or default, as a
504
+ * fraction of the window) — always computed and reported, even when it
505
+ * wasn't what ended up being used. */
506
+ configuredTriggerTokens: number;
507
+ /** The autocompact percentage from the configured chain. */
467
508
  autocompactPct: number;
468
- /** "measured" when this project has compact_boundary history and that was
469
- * used; "configured" when it fell back to the env-override/default chain. */
509
+ /** "measured": a measured value existed and was <= configured, so it was
510
+ * used directly the better estimate, since it reflects reality the
511
+ * configured percentage cannot know.
512
+ * "measured-clamped": a measured value existed but was HIGHER than
513
+ * configured — it reflects a regime that may no longer apply (this
514
+ * project's last compaction predates a since-changed trigger), so the
515
+ * lower, safer configured value was used instead.
516
+ * "configured": no measured value exists yet (no compaction history for
517
+ * this project) — the configured chain is all there is. */
470
518
  triggerSource: TriggerSource;
471
519
  /** True when these were computed against a window size Claude Code itself
472
520
  * reported (the statusline source). False when the window is only an
@@ -489,12 +537,32 @@ export interface ContextFillThresholdOpts {
489
537
  }
490
538
 
491
539
  /**
492
- * Derive warmup/refresh/immediate thresholds from a fill reading, preferring
493
- * this project's own measured compaction history over the configured
494
- * override chain see the module comment above for why. Margins are
495
- * clamped at 0 (and logged) for a window small enough that a margin would
496
- * otherwise go negative a pathological input should degrade to "fire
497
- * immediately", never to a threshold below zero.
540
+ * Derive warmup/refresh/immediate thresholds from a fill reading.
541
+ *
542
+ * effectiveTrigger = min(measured, configuredChainValue) when a measured
543
+ * value exists NOT the measured value outright. A project whose newest
544
+ * compaction predates a regime change measures a stale-HIGH trigger: a real
545
+ * case on this machine measured 998,267 for a project whose ACTUAL current
546
+ * boundary (from a different project's fresher history, cross-checked
547
+ * independently) is ~784,000 — using 998,267 directly would compute a
548
+ * warm-up of 898,267, above the real boundary, so the handover would never
549
+ * fire there. The measurement is honest; it is just old.
550
+ *
551
+ * The minimum is correct in both directions: the measured value is the
552
+ * better estimate when it is LOWER than configured (it reflects reality the
553
+ * configured percentage cannot know — see the module comment above, the
554
+ * 100%-to-78% regime change this project itself lived through); it is
555
+ * unsafe when it is HIGHER (it reflects a regime that no longer applies).
556
+ * Taking the minimum costs nothing in the safe direction — one wasted
557
+ * summary if the project's regime actually did move up — and prevents the
558
+ * unsafe direction, where a stale-high measurement suppresses the handover
559
+ * past the real boundary. That asymmetry is the same one the 80-not-100
560
+ * default was chosen for.
561
+ *
562
+ * Margins below effectiveTrigger are clamped at 0 (and logged) for a window
563
+ * small enough that a margin would otherwise go negative — a pathological
564
+ * input should degrade to "fire immediately", never to a threshold below
565
+ * zero.
498
566
  */
499
567
  export function contextFillThresholds(
500
568
  reading: ContextFillReading,
@@ -505,25 +573,38 @@ export function contextFillThresholds(
505
573
  const autocompactPct = resolveAutocompactPct(env);
506
574
  const configuredTriggerTokens = Math.round(reading.windowSize * (autocompactPct / 100));
507
575
 
508
- const measured = opts.measuredTrigger !== undefined
576
+ const measuredTriggerTokens = opts.measuredTrigger !== undefined
509
577
  ? opts.measuredTrigger
510
578
  : opts.cwd
511
579
  ? measureCompactionTrigger(opts.cwd)
512
580
  : null;
513
581
 
514
- const triggerSource: TriggerSource = measured !== null ? "measured" : "configured";
515
- const effectiveTriggerTokens = measured !== null ? measured : configuredTriggerTokens;
582
+ let effectiveTriggerTokens: number;
583
+ let triggerSource: TriggerSource;
516
584
 
517
- if (triggerSource === "measured") {
585
+ if (measuredTriggerTokens === null) {
586
+ effectiveTriggerTokens = configuredTriggerTokens;
587
+ triggerSource = "configured";
588
+ console.error(
589
+ `[context-fill] trigger source: CONFIGURED — no compaction history for this project yet. ` +
590
+ `measured=none, configured=${configuredTriggerTokens} (${autocompactPct}% of a ${reading.windowSize}-token ` +
591
+ `window) -> using ${effectiveTriggerTokens}.`
592
+ );
593
+ } else if (measuredTriggerTokens <= configuredTriggerTokens) {
594
+ effectiveTriggerTokens = measuredTriggerTokens;
595
+ triggerSource = "measured";
518
596
  console.error(
519
- `[context-fill] trigger source: MEASURED — ${effectiveTriggerTokens} tokens ` +
520
- `(minimum of the most recent ${MEASURED_TRIGGER_SAMPLE_SIZE} compact_boundary events for ` +
521
- `this project; the configured chain would have given ${configuredTriggerTokens}).`
597
+ `[context-fill] trigger source: MEASURED — measured=${measuredTriggerTokens} ` +
598
+ `(minimum of the most recent ${MEASURED_TRIGGER_SAMPLE_SIZE} compact_boundary events), ` +
599
+ `configured=${configuredTriggerTokens} -> using ${effectiveTriggerTokens} (measured, ≤ configured).`
522
600
  );
523
601
  } else {
602
+ effectiveTriggerTokens = configuredTriggerTokens;
603
+ triggerSource = "measured-clamped";
524
604
  console.error(
525
- `[context-fill] trigger source: CONFIGUREDno compaction history for this project yet, ` +
526
- `using ${effectiveTriggerTokens} tokens (${autocompactPct}% of a ${reading.windowSize}-token window).`
605
+ `[context-fill] trigger source: MEASURED-CLAMPEDmeasured=${measuredTriggerTokens} is HIGHER than ` +
606
+ `configured=${configuredTriggerTokens} (a stale regime this project's history predates) -> ` +
607
+ `using ${effectiveTriggerTokens} (configured, the safer bound).`
527
608
  );
528
609
  }
529
610
 
@@ -541,6 +622,8 @@ export function contextFillThresholds(
541
622
  refreshTokens: clamp("refresh", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.refresh),
542
623
  immediateTokens: clamp("immediate", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.immediate),
543
624
  effectiveTriggerTokens,
625
+ measuredTriggerTokens,
626
+ configuredTriggerTokens,
544
627
  autocompactPct,
545
628
  triggerSource,
546
629
  windowConfirmed,
@@ -700,25 +700,44 @@ async function main() {
700
700
  // generated after this session's PREVIOUS compaction: a handover
701
701
  // cached before that point describes state the last compaction
702
702
  // already accounted for, not what has happened since.
703
+ //
704
+ // BUG (live): a production digest was found with NO "HANDOVER SOURCE:"
705
+ // line at all — its cause was never pinned down for certain (the
706
+ // deployed hook is a compiled dist/ artifact reached through
707
+ // ${PAI_DIR}/Hooks/..., a separate build step from editing this
708
+ // source, which is itself a plausible way for "works when I run the
709
+ // .ts source" and "missing in production" to diverge). Regardless of
710
+ // cause: sourceLabel and handoverBlock are now computed in their own
711
+ // try/catch, so ANY failure in the cache read or the freshness
712
+ // comparison degrades to an explicit error label rather than being
713
+ // capable of preventing the line from existing at all.
703
714
  // -------------------------------------------------------------------
704
- const cachedHandover = readContextHandoverCache(hookInput.session_id);
705
- const handoverIsFresh =
706
- cachedHandover !== null &&
707
- (!previousCompactionAt || new Date(cachedHandover.generatedAt) > new Date(previousCompactionAt));
708
-
709
- const sourceLabel = handoverIsFresh
710
- ? `model-written handover (${cachedHandover!.model}, generated ${cachedHandover!.generatedAt}, ` +
711
- `threshold=${cachedHandover!.threshold}) + mechanical scrape`
712
- : 'mechanical scrape only (no fresh model-written handover was available)';
713
-
714
- const handoverBlock = handoverIsFresh
715
- ? [
716
- '',
717
- '--- MODEL-WRITTEN HANDOVER (decisions, reasoning, open threads — not in the scrape above) ---',
718
- cachedHandover!.summary,
719
- '--- end model-written handover ---',
720
- ].join('\n')
721
- : '';
715
+ let sourceLabel: string;
716
+ let handoverBlock: string;
717
+ try {
718
+ const cachedHandover = readContextHandoverCache(hookInput.session_id);
719
+ const handoverIsFresh =
720
+ cachedHandover !== null &&
721
+ (!previousCompactionAt || new Date(cachedHandover.generatedAt) > new Date(previousCompactionAt));
722
+
723
+ sourceLabel = handoverIsFresh
724
+ ? `model-written handover (${cachedHandover!.model}, generated ${cachedHandover!.generatedAt}, ` +
725
+ `threshold=${cachedHandover!.threshold}) + mechanical scrape`
726
+ : 'mechanical scrape only (no fresh model-written handover was available)';
727
+
728
+ handoverBlock = handoverIsFresh
729
+ ? [
730
+ '',
731
+ '--- MODEL-WRITTEN HANDOVER (decisions, reasoning, open threads — not in the scrape above) ---',
732
+ cachedHandover!.summary,
733
+ '--- end model-written handover ---',
734
+ ].join('\n')
735
+ : '';
736
+ } catch (err) {
737
+ console.error(`Failed to resolve handover cache — falling back to scrape-only: ${err}`);
738
+ sourceLabel = `mechanical scrape only (error resolving handover cache: ${err})`;
739
+ handoverBlock = '';
740
+ }
722
741
 
723
742
  const injection = [
724
743
  '<system-reminder>',
@@ -19,6 +19,7 @@
19
19
  import { readFileSync, existsSync, unlinkSync } from 'fs';
20
20
  import { join } from 'path';
21
21
  import { tmpdir } from 'os';
22
+ import { resetHandoverTriggerState } from '../../../session/context-handover-trigger.js';
22
23
 
23
24
  interface HookInput {
24
25
  session_id: string;
@@ -53,6 +54,16 @@ async function main() {
53
54
  process.exit(0);
54
55
  }
55
56
 
57
+ // This hook fires exactly once per compaction for this session — the
58
+ // right moment to reset the handover-trigger marker (confirmed/pending
59
+ // thresholds) so the NEXT context-fill cycle gets its own handover
60
+ // instead of being judged "already done" forever. Unconditional: it must
61
+ // run even when no compact-state digest file was found below, since a
62
+ // compaction still happened. Never touches the separate handover CACHE
63
+ // file — that is what gets injected below and must survive this call.
64
+ // See resetHandoverTriggerState() for the full rationale.
65
+ resetHandoverTriggerState(hookInput.session_id);
66
+
56
67
  // Look for the state file saved by context-compression-hook during PreCompact
57
68
  const stateFile = join(tmpdir(), `pai-compact-state-${hookInput.session_id}.txt`);
58
69