@tekmidian/pai 0.36.0 → 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.
- package/dist/cli/index.mjs +2 -2
- package/dist/cli/program.mjs +2 -2
- package/dist/{checkpoint-block-DKYxCkBL.mjs → context-handover-cache-PtNvj_8D.mjs} +34 -3
- package/dist/context-handover-cache-PtNvj_8D.mjs.map +1 -0
- package/dist/daemon/index.mjs +3 -3
- package/dist/{daemon--N2JFnUs.mjs → daemon-BZ93KRBo.mjs} +4 -4
- package/dist/{daemon--N2JFnUs.mjs.map → daemon-BZ93KRBo.mjs.map} +1 -1
- package/dist/{daemon-DJEFqV84.mjs → daemon-DRdoA489.mjs} +3 -3
- package/dist/hooks/context-compression-hook.mjs +18 -10
- package/dist/hooks/context-compression-hook.mjs.map +2 -2
- package/dist/{program-BnMNFb4O.mjs → program-C-fUghPv.mjs} +200 -73
- package/dist/{program-BnMNFb4O.mjs.map → program-C-fUghPv.mjs.map} +1 -1
- package/dist/{work-queue-worker-228XjABm.mjs → work-queue-worker-B8W8_3Rn.mjs} +23 -55
- package/dist/work-queue-worker-B8W8_3Rn.mjs.map +1 -0
- package/dist/{work-queue-worker-LRA9Fj9z.mjs → work-queue-worker-HN2Ufg-L.mjs} +2 -2
- package/package.json +1 -1
- package/src/hooks/ts/lib/context-fill.test.ts +117 -4
- package/src/hooks/ts/lib/context-fill.ts +136 -53
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +37 -18
- package/dist/checkpoint-block-DKYxCkBL.mjs.map +0 -1
- package/dist/work-queue-worker-228XjABm.mjs.map +0 -1
|
@@ -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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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
|
|
412
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
465
|
-
*
|
|
466
|
-
*
|
|
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"
|
|
469
|
-
* used
|
|
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
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
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
|
|
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
|
-
|
|
515
|
-
|
|
582
|
+
let effectiveTriggerTokens: number;
|
|
583
|
+
let triggerSource: TriggerSource;
|
|
516
584
|
|
|
517
|
-
if (
|
|
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 —
|
|
520
|
-
`(minimum of the most recent ${MEASURED_TRIGGER_SAMPLE_SIZE} compact_boundary events
|
|
521
|
-
`
|
|
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:
|
|
526
|
-
`
|
|
605
|
+
`[context-fill] trigger source: MEASURED-CLAMPED — measured=${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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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>',
|