pi-pier 0.4.1 → 0.4.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.
@@ -4,11 +4,11 @@
4
4
  * compaction, then continues the task. State persists to session entries (branch rebuilds restore it).
5
5
  */
6
6
 
7
- import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
7
+ import type { CompactionResult, ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
8
8
  import { decideCompaction, DEFAULT_COMPACTION_ECONOMICS, estimateRemainingRequests, MAX_BOUNDARY_SAMPLES, nativeCompactionFeasible, resolveCacheRatioFromCost, type CompactionDecision } from './compact-economics-core.ts';
9
9
  import type { TodoItem } from './todo-core.ts';
10
10
  import type { TodoCompletionSource } from './todos-service.ts';
11
- import type { OnlineContextCompactConfig } from './efficiency-config-core.ts';
11
+ import { SUMMARY_THINKING_LEVELS, type OnlineContextCompactConfig } from './efficiency-config-core.ts';
12
12
  import { appendEfficiencyLog, efficiencyLogPath, resolveSessionRoot } from './efficiency-store.ts';
13
13
  import { swallow } from './swallow.ts';
14
14
 
@@ -26,6 +26,20 @@ export const COMPACTION_INFLIGHT_TYPE = 'pi-herdr.compaction-inflight';
26
26
  export const COMPACTION_SETTLED_TYPE = 'pi-herdr.compaction-settled';
27
27
  const DEFAULT_KEEP_RECENT_TOKENS = 20_000;
28
28
  const DEFAULT_MEMO_TOKENS = 1_000;
29
+ /**
30
+ * A non-compacting decision is revisited after this many provider requests even without a fresh todo
31
+ * boundary: an empty or stale todo list must not block OCC until the session ends (observed: 25/43
32
+ * decisions deferred at zero remaining boundaries, with a median of 45 further requests).
33
+ */
34
+ export const DEFERRED_REEVALUATION_REQUESTS = 10;
35
+
36
+ /**
37
+ * Prefix-cache hit evidence is smoothed toward this many fully cached prefix tokens, so a single
38
+ * early miss cannot multiply the expected saving (~15 requests of a 60k context).
39
+ */
40
+ export const CACHE_HIT_PRIOR_TOKENS = 1_000_000;
41
+ /** Past this many observed prefix tokens both sums halve, so the rate tracks recent provider behavior. */
42
+ export const CACHE_HIT_WINDOW_TOKENS = 20_000_000;
29
43
 
30
44
  /** Fields persisted in the state marker; `version: 1` gates restores. */
31
45
  interface CoordinatorState {
@@ -39,7 +53,18 @@ interface CoordinatorState {
39
53
  positiveContextDeltaCount: number;
40
54
  lastContextTokens: number | null;
41
55
  currentBoundaryRequestCount: number;
56
+ /** Request count of the last non-compacting decision; -1 when no re-evaluation is scheduled. */
57
+ lastEvaluationRequestCount: number;
58
+ /**
59
+ * Context tokens pi estimated right after the last compaction (0 = unknown). Used as the retained
60
+ * floor for archive savings: fixed system-prompt/keepRecent arithmetic excludes tool schemas.
61
+ */
62
+ retainedTokensAfterCompaction: number;
42
63
  consecutiveCompactionFailures: number;
64
+ /** Prompt tokens of the previous request, summed per observed request (the reusable prefix). */
65
+ cachePrefixTokens: number;
66
+ /** Of those, tokens the provider served from cache. */
67
+ cacheReadTokens: number;
43
68
  /** P1-3: turn-end compact decisions to skip after a failure (each abort costs an in-flight turn). */
44
69
  compactBackoffTurnEnds: number;
45
70
  }
@@ -52,7 +77,11 @@ const NUMERIC_STATE_FIELDS = [
52
77
  'positiveContextDeltaTotal',
53
78
  'positiveContextDeltaCount',
54
79
  'currentBoundaryRequestCount',
80
+ 'lastEvaluationRequestCount',
81
+ 'retainedTokensAfterCompaction',
55
82
  'consecutiveCompactionFailures',
83
+ 'cachePrefixTokens',
84
+ 'cacheReadTokens',
56
85
  'compactBackoffTurnEnds',
57
86
  ] as const satisfies readonly (keyof CoordinatorState)[];
58
87
 
@@ -68,7 +97,11 @@ function initialCoordinatorState(): CoordinatorState {
68
97
  positiveContextDeltaCount: 0,
69
98
  lastContextTokens: null,
70
99
  currentBoundaryRequestCount: 0,
100
+ lastEvaluationRequestCount: -1,
101
+ retainedTokensAfterCompaction: 0,
71
102
  consecutiveCompactionFailures: 0,
103
+ cachePrefixTokens: 0,
104
+ cacheReadTokens: 0,
72
105
  compactBackoffTurnEnds: 0,
73
106
  };
74
107
  }
@@ -98,10 +131,23 @@ function averageContextIncrement(state: CoordinatorState): number | null {
98
131
  : null;
99
132
  }
100
133
 
134
+ export function prefixCacheHitRate(state: CoordinatorState): number {
135
+ return (state.cacheReadTokens + CACHE_HIT_PRIOR_TOKENS) / (state.cachePrefixTokens + CACHE_HIT_PRIOR_TOKENS);
136
+ }
137
+
101
138
  function remainingTodos(todos: readonly TodoItem[]): readonly TodoItem[] {
102
139
  return todos.filter((it) => it.status !== 'completed' && it.status !== 'abandoned');
103
140
  }
104
141
 
142
+ /**
143
+ * Todos that pace the remaining-work horizon. Blocked items wait on something outside the agent's
144
+ * control and may never become actionable, so they must not inflate the horizon (observed: 7 blocked
145
+ * items were counted as seven remaining boundaries).
146
+ */
147
+ export function horizonTodos(todos: readonly TodoItem[]): readonly TodoItem[] {
148
+ return remainingTodos(todos).filter((it) => it.status !== 'blocked');
149
+ }
150
+
105
151
  /** P1-3: bound the payload — a huge open-task list inflates the summary request itself. */
106
152
  function compactionInstructions(todos: readonly TodoItem[]): string {
107
153
  const remaining = remainingTodos(todos);
@@ -122,6 +168,10 @@ export class CompactCoordinator {
122
168
  intentionalAbort = false;
123
169
  selectedCompaction: CompactionDecision | null = null;
124
170
  pendingBoundaryCompleted = false;
171
+ /** Last carried-debt value written to the session; 0 on a fresh state (nothing to persist). */
172
+ private persistedDebtTokens = 0;
173
+ /** Prompt size of the previous request; 0 = no reusable prefix (session start, after compaction). */
174
+ private lastPromptTokens = 0;
125
175
 
126
176
  recordBoundaryCompleted(count: number, source?: TodoCompletionSource): void {
127
177
  // Only model tool calls count, and the sample is the requests spent reaching this boundary.
@@ -140,6 +190,15 @@ export class CompactCoordinator {
140
190
 
141
191
  onBeforeProviderRequest(contextTokens: number): void {
142
192
  this.state.currentBoundaryRequestCount++;
193
+ // Fallback calibration: when pi omitted estimatedTokensAfter, the first request after a
194
+ // compaction measures the retained context (summary + kept turns + fixed overhead) directly.
195
+ if (
196
+ this.state.retainedTokensAfterCompaction === 0 &&
197
+ this.state.priorCompactionCount > 0 &&
198
+ contextTokens > 0
199
+ ) {
200
+ this.state.retainedTokensAfterCompaction = contextTokens;
201
+ }
143
202
  if (this.state.carriedDebtTokens > 0 && this.state.cacheDebtRepaymentTokens > 0) {
144
203
  this.state.carriedDebtTokens = Math.max(
145
204
  0,
@@ -154,6 +213,25 @@ export class CompactCoordinator {
154
213
  this.state.lastContextTokens = contextTokens;
155
214
  }
156
215
 
216
+ /**
217
+ * Measures how much of the previous request's prompt the provider served from cache. Fed from each
218
+ * assistant message's `usage`; a request with no usage (error, abort) measures nothing.
219
+ */
220
+ onAssistantUsage(usage: { input?: number; cacheRead?: number; cacheWrite?: number } | undefined): void {
221
+ const cacheRead = usage?.cacheRead ?? 0;
222
+ const promptTokens = (usage?.input ?? 0) + cacheRead + (usage?.cacheWrite ?? 0);
223
+ if (promptTokens <= 0) return;
224
+ if (this.lastPromptTokens > 0) {
225
+ this.state.cachePrefixTokens += this.lastPromptTokens;
226
+ this.state.cacheReadTokens += Math.min(cacheRead, this.lastPromptTokens);
227
+ if (this.state.cachePrefixTokens > CACHE_HIT_WINDOW_TOKENS) {
228
+ this.state.cachePrefixTokens /= 2;
229
+ this.state.cacheReadTokens /= 2;
230
+ }
231
+ }
232
+ this.lastPromptTokens = promptTokens;
233
+ }
234
+
157
235
  onInput(event: { text?: string; source?: string; streamingBehavior?: string }): void {
158
236
  // Ignore messages dispatched by extensions (D96 reminders, pipe injections, followUps).
159
237
  if (event.source === 'extension' || event.text?.startsWith('CORRECTION:')) return;
@@ -167,6 +245,7 @@ export class CompactCoordinator {
167
245
  this.selectedCompaction = null;
168
246
  this.intentionalAbort = false;
169
247
  this.pendingBoundaryCompleted = false;
248
+ this.state.lastEvaluationRequestCount = -1;
170
249
  // completedBoundaryRequestCounts deliberately survives: it measures requests-per-boundary pacing,
171
250
  // which holds across human turns — clearing it pinned expectedRemainingRequests at 1 every prompt.
172
251
  this.state.carriedDebtTokens = 0;
@@ -177,16 +256,33 @@ export class CompactCoordinator {
177
256
  ctx: ExtensionContext;
178
257
  todos: readonly TodoItem[];
179
258
  config: OnlineContextCompactConfig;
259
+ pi: ExtensionAPI;
260
+ /** stopReason of the assistant message that ended this turn ('toolUse' = work continues). */
261
+ lastStopReason: string | null;
180
262
  cancelReminder?: () => void;
181
263
  }): void {
264
+ if (opts.config.enabled) this.persistDebt(opts.pi);
265
+
182
266
  const boundary = this.pendingBoundaryCompleted;
183
267
  this.pendingBoundaryCompleted = false;
184
268
 
185
- if (!boundary || this.selectedCompaction) return;
269
+ if (this.selectedCompaction) return;
186
270
  if (!opts.config.enabled) return;
187
271
  // Safety guard: never abort while the user has a queued message.
188
272
  if (opts.ctx.hasPendingMessages?.()) return;
189
273
 
274
+ // Revisit a non-compacting decision every N provider requests. Without this, a deferral made
275
+ // when the todo list is momentarily empty is never reassessed: the horizon stays collapsed at 1
276
+ // while the agent keeps working (evidence: 25/43 deferrals with zero open todos, median 45
277
+ // further requests, up to 249). Only a tool-call turn proves the work continues; a final 'stop'
278
+ // turn must not trigger a summary (and a spurious continuation) after the task is done.
279
+ const requestCount = this.state.currentBoundaryRequestCount;
280
+ const deferredDue =
281
+ opts.lastStopReason === 'toolUse' &&
282
+ this.state.lastEvaluationRequestCount >= 0 &&
283
+ requestCount - this.state.lastEvaluationRequestCount >= DEFERRED_REEVALUATION_REQUESTS;
284
+ if (!boundary && !deferredDue) return;
285
+
190
286
  const usage = opts.ctx.getContextUsage?.();
191
287
  const contextTokens =
192
288
  typeof usage?.tokens === 'number' && usage.tokens > 0
@@ -196,15 +292,24 @@ export class CompactCoordinator {
196
292
  // System prompt is re-sent verbatim, so it is never part of the compressible archive; ~4 chars/token.
197
293
  const fixedTokens = Math.ceil(Buffer.byteLength(opts.ctx.getSystemPrompt?.() ?? '') / 4);
198
294
  const keepRecentTokens = opts.config.keepRecentTokens ?? DEFAULT_KEEP_RECENT_TOKENS;
199
- const archiveTokens = Math.max(0, contextTokens - fixedTokens - keepRecentTokens);
295
+ const memoTokens = DEFAULT_MEMO_TOKENS;
296
+ // pi's estimatedTokensAfter is the measured retained context (summary + kept recent turns + all
297
+ // fixed overhead, tool schemas included). Prefer it over the fixedTokens/keepRecent arithmetic,
298
+ // which ignores tool schemas and understates the retained floor (observed: savings overstated
299
+ // ~23%). The estimate already contains the memo, so rebuild the gross archive the model expects.
300
+ const retainedTokens = this.state.retainedTokensAfterCompaction;
301
+ const archiveTokens = retainedTokens > 0 && retainedTokens < contextTokens
302
+ ? contextTokens - retainedTokens + memoTokens
303
+ : Math.max(0, contextTokens - fixedTokens - keepRecentTokens);
200
304
 
201
305
  const decision = decideCompaction({
202
306
  writeTokens: contextTokens,
203
307
  archiveTokens,
204
- memoTokens: DEFAULT_MEMO_TOKENS,
308
+ memoTokens,
205
309
  contextTokens,
206
310
  completedBoundaryRequestCounts: this.state.completedBoundaryRequestCounts,
207
- remainingBoundaries: remainingTodos(opts.todos).length,
311
+ remainingBoundaries: horizonTodos(opts.todos).length,
312
+ observedRequestsSinceBoundary: requestCount,
208
313
  averageContextTokenIncrement: averageContextIncrement(this.state),
209
314
  contextWindowTokens,
210
315
  priorCompactionCount: this.state.priorCompactionCount,
@@ -215,6 +320,7 @@ export class CompactCoordinator {
215
320
  opts.ctx.model?.cost,
216
321
  { provider: opts.ctx.model?.provider, modelId: opts.ctx.model?.id },
217
322
  ),
323
+ prefixCacheHitRate: prefixCacheHitRate(this.state),
218
324
  economics: {
219
325
  ...DEFAULT_COMPACTION_ECONOMICS,
220
326
  firstCompactionRequestScale: opts.config.firstCompactionRequestScale,
@@ -223,6 +329,7 @@ export class CompactCoordinator {
223
329
  });
224
330
 
225
331
  if (!decision.compact) {
332
+ this.state.lastEvaluationRequestCount = requestCount;
226
333
  if (opts.config.logEnabled) this.logDecision(opts.ctx, decision);
227
334
  return;
228
335
  }
@@ -232,6 +339,7 @@ export class CompactCoordinator {
232
339
  // class is gone since pi 1.4.0 uncapped summary output; transient timeouts/aborts remain.)
233
340
  if (this.state.compactBackoffTurnEnds > 0) {
234
341
  this.state.compactBackoffTurnEnds--;
342
+ this.state.lastEvaluationRequestCount = requestCount;
235
343
  if (opts.config.logEnabled) {
236
344
  this.logDecision(opts.ctx, { ...decision, compact: false, reason: 'failure_backoff' });
237
345
  }
@@ -240,12 +348,14 @@ export class CompactCoordinator {
240
348
 
241
349
  const branch = opts.ctx.sessionManager?.getBranch?.() ?? [];
242
350
  if (!nativeCompactionFeasible(branch, keepRecentTokens)) {
351
+ this.state.lastEvaluationRequestCount = requestCount;
243
352
  if (opts.config.logEnabled) {
244
353
  this.logDecision(opts.ctx, { ...decision, compact: false, reason: 'native_not_compactable' });
245
354
  }
246
355
  return;
247
356
  }
248
357
 
358
+ this.state.lastEvaluationRequestCount = -1;
249
359
  this.selectedCompaction = decision;
250
360
  this.intentionalAbort = true;
251
361
  opts.cancelReminder?.();
@@ -260,7 +370,15 @@ export class CompactCoordinator {
260
370
  cancelReminder?: () => void;
261
371
  onBeforeCompact?: (sessionRoot: string, ctx: ExtensionContext) => Promise<void>;
262
372
  }): Promise<void> {
263
- if (!this.selectedCompaction || !opts.ctx.isIdle()) return;
373
+ if (!this.selectedCompaction) return;
374
+ if (!opts.ctx.isIdle()) {
375
+ // The settlement is not consumable (another run already started): drop the stale selection.
376
+ // Keeping it would latch OCC off every later turn and suppress settle-wake notices until the
377
+ // next human input; a fresh decision re-selects when the economics still hold.
378
+ this.selectedCompaction = null;
379
+ this.intentionalAbort = false;
380
+ return;
381
+ }
264
382
 
265
383
  // Announce the compaction window before the summary request starts: the supervising master
266
384
  // polls this file, and its observation window is shorter than a large-context compaction.
@@ -282,41 +400,75 @@ export class CompactCoordinator {
282
400
  }
283
401
  }
284
402
 
403
+ // pi summarizes at the session's thinking level; at `max`, 67% of summary output was reasoning
404
+ // and one compaction took 144 s. Cap it for the summary only, and always restore the session's.
405
+ const sessionThinking = opts.pi.getThinkingLevel();
406
+ const cap = opts.config.summaryThinkingLevel;
407
+ const lowered = cap !== 'inherit'
408
+ && SUMMARY_THINKING_LEVELS.indexOf(cap) < SUMMARY_THINKING_LEVELS.indexOf(sessionThinking);
409
+ if (lowered) opts.pi.setThinkingLevel(cap);
285
410
  const startMs = Date.now();
286
411
  const settled = Promise.withResolvers<void>();
287
- opts.ctx.compact({
288
- customInstructions: compactionInstructions(opts.todos),
289
- onComplete: (compaction) => {
290
- try {
291
- this.finishCompaction(opts, decision, startMs, compaction);
292
- } finally {
293
- settled.resolve();
294
- }
295
- },
296
- onError: (error) => {
297
- try {
298
- this.failCompaction(opts, decision, startMs, error);
299
- } finally {
300
- settled.resolve();
301
- }
302
- },
303
- });
304
- await settled.promise;
412
+ try {
413
+ opts.ctx.compact({
414
+ customInstructions: compactionInstructions(opts.todos),
415
+ onComplete: (compaction) => {
416
+ try {
417
+ this.finishCompaction(opts, decision, startMs, compaction);
418
+ } finally {
419
+ settled.resolve();
420
+ }
421
+ },
422
+ onError: (error) => {
423
+ try {
424
+ this.failCompaction(opts, decision, startMs, error);
425
+ } finally {
426
+ settled.resolve();
427
+ }
428
+ },
429
+ });
430
+ await settled.promise;
431
+ } finally {
432
+ if (lowered) opts.pi.setThinkingLevel(sessionThinking);
433
+ }
305
434
  }
306
435
 
307
436
  private finishCompaction(
308
437
  opts: { ctx: ExtensionContext; pi: ExtensionAPI; config: OnlineContextCompactConfig },
309
438
  decision: CompactionDecision,
310
439
  startMs: number,
311
- compaction: { summary?: string } | undefined,
440
+ compaction: CompactionResult | undefined,
312
441
  ): void {
442
+ const usage = compaction?.usage;
443
+ // Debt is the summary request's real extra spend, converted to cache-read-token units so the
444
+ // ledger stays in the same unit as savingTokens. Fall back to the modeled rewrite surcharge when
445
+ // pi reports no usage or the model has no cache-read price.
446
+ const cacheReadPricePerToken = (opts.ctx.model?.cost?.cacheRead ?? 0) / 1_000_000;
447
+ const measuredDebtTokens =
448
+ usage !== undefined && cacheReadPricePerToken > 0
449
+ ? usage.cost.total / cacheReadPricePerToken
450
+ : null;
451
+ const summaryDebtTokens =
452
+ measuredDebtTokens ?? decision.writeTokens * (decision.incrementalCacheCostRatio ?? 0);
453
+ // Unpaid debt from the previous compaction survives: repayment happens per subsequent request,
454
+ // so a new compaction must not write off what the ledger has not collected yet.
455
+ const unpaidDebtTokens = this.state.carriedDebtTokens;
456
+
313
457
  this.state.priorCompactionCount++;
314
- this.state.carriedDebtTokens = decision.writeTokens * (decision.incrementalCacheCostRatio ?? 0);
315
- this.state.cacheDebtRepaymentTokens = Math.max(0, decision.archiveTokens - decision.memoTokens);
458
+ this.state.carriedDebtTokens = unpaidDebtTokens + summaryDebtTokens;
459
+ this.state.cacheDebtRepaymentTokens = decision.savingTokensPerRequest;
460
+ // The summary replaced the prefix: the next request is a cold rewrite the debt already prices.
461
+ this.lastPromptTokens = 0;
462
+ this.state.retainedTokensAfterCompaction =
463
+ typeof compaction?.estimatedTokensAfter === 'number' ? compaction.estimatedTokensAfter : 0;
464
+ // The pacing window restarts after a compaction: requests before it measured the old context.
465
+ this.state.currentBoundaryRequestCount = 0;
466
+ this.state.lastEvaluationRequestCount = -1;
316
467
  this.state.epoch++;
317
468
  this.state.consecutiveCompactionFailures = 0;
318
469
  this.state.compactBackoffTurnEnds = 0;
319
470
  this.appendMarker(opts.pi, COMPACT_STATE_CUSTOM_TYPE, this.state);
471
+ this.persistedDebtTokens = this.state.carriedDebtTokens;
320
472
 
321
473
  if (opts.config.logEnabled) {
322
474
  this.logCompact(opts.ctx, {
@@ -327,7 +479,9 @@ export class CompactCoordinator {
327
479
  breakevenRequests: decision.breakevenRequests,
328
480
  expectedRemainingRequests: decision.expectedRemainingRequests,
329
481
  carriedDebtTokens: this.state.carriedDebtTokens,
330
- summaryTokens: typeof compaction?.summary === 'string' ? Math.ceil(compaction.summary.length / 4) : 0,
482
+ summaryTokens: usage !== undefined
483
+ ? usage.output
484
+ : Math.ceil((compaction?.summary.length ?? 0) / 4),
331
485
  priorCompactionCount: this.state.priorCompactionCount,
332
486
  durationMs: Date.now() - startMs,
333
487
  });
@@ -364,6 +518,12 @@ export class CompactCoordinator {
364
518
  outcome: cancelled ? 'cancelled' : 'failed',
365
519
  at: Date.now(),
366
520
  });
521
+ // P1-3: record the failure and back off exponentially (cap 4 skipped decisions). Increment
522
+ // before logging so the record carries this failure's ordinal instead of the previous count.
523
+ if (!cancelled) {
524
+ this.state.consecutiveCompactionFailures++;
525
+ this.state.compactBackoffTurnEnds = Math.min(2 ** this.state.consecutiveCompactionFailures, 4);
526
+ }
367
527
  if (opts.config.logEnabled) {
368
528
  this.logCompact(opts.ctx, {
369
529
  event: 'compaction-failed',
@@ -376,12 +536,10 @@ export class CompactCoordinator {
376
536
  }
377
537
  if (cancelled) return;
378
538
 
379
- // P1-3: record the failure and back off exponentially (cap 4 skipped decisions).
380
- this.state.consecutiveCompactionFailures++;
381
- this.state.compactBackoffTurnEnds = Math.min(2 ** this.state.consecutiveCompactionFailures, 4);
382
539
  // Persist the bumped counters: state is restored from this marker, so a restart without it
383
540
  // would reset the P1-3 backoff window and retry the doomed compaction immediately.
384
541
  this.appendMarker(opts.pi, COMPACT_STATE_CUSTOM_TYPE, this.state);
542
+ this.persistedDebtTokens = this.state.carriedDebtTokens;
385
543
 
386
544
  // OCC aborted the turn on purpose, so a failed compaction would leave the session parked on an
387
545
  // aborted message; the notice must be visible rather than a silent failure.
@@ -406,6 +564,16 @@ export class CompactCoordinator {
406
564
  }
407
565
  }
408
566
 
567
+ /**
568
+ * Persist repayment progress when the debt moves. restoreCoordinatorState reads the last state
569
+ * marker, so an in-memory-only ledger resurrects already-repaid debt after a resume.
570
+ */
571
+ private persistDebt(pi: ExtensionAPI): void {
572
+ if (this.persistedDebtTokens === this.state.carriedDebtTokens) return;
573
+ this.appendMarker(pi, COMPACT_STATE_CUSTOM_TYPE, this.state);
574
+ this.persistedDebtTokens = this.state.carriedDebtTokens;
575
+ }
576
+
409
577
  /** Resume the task after compaction; must never throw into the detached settled handler. */
410
578
  private sendContinuation(pi: ExtensionAPI, content: string, display: boolean): void {
411
579
  try {
@@ -416,6 +584,7 @@ export class CompactCoordinator {
416
584
 
417
585
  rebuildFromBranch(entries: readonly unknown[]): void {
418
586
  this.state = restoreCoordinatorState(entries);
587
+ this.persistedDebtTokens = this.state.carriedDebtTokens;
419
588
  this.selectedCompaction = null;
420
589
  this.compactionInFlight = false;
421
590
  this.intentionalAbort = false;
@@ -429,10 +598,10 @@ export class CompactCoordinator {
429
598
  completedBoundaryRequestCounts: counts,
430
599
  remainingBoundaries,
431
600
  scale: DEFAULT_COMPACTION_ECONOMICS.remainingRequestScale,
432
- standardDeviationK: DEFAULT_COMPACTION_ECONOMICS.remainingRequestStddevK,
433
601
  contextTokens: this.state.lastContextTokens ?? 0,
434
602
  contextWindowTokens,
435
603
  averageContextTokenIncrement: averageContextIncrement(this.state),
604
+ observedRequestsSinceBoundary: this.state.currentBoundaryRequestCount,
436
605
  });
437
606
  return Math.max(1, est.expectedRemainingRequests);
438
607
  }
@@ -464,6 +633,8 @@ export class CompactCoordinator {
464
633
  // Without resolvedRatio/model a cache_ratio_unavailable run is undiagnosable from logs alone.
465
634
  resolvedRatio: decision.cacheWriteReadRatio,
466
635
  incrementalCacheCostRatio: decision.incrementalCacheCostRatio,
636
+ prefixCacheHitRate: decision.prefixCacheHitRate,
637
+ savingTokensPerRequest: decision.savingTokensPerRequest,
467
638
  remainingBoundaries: decision.remainingBoundaries,
468
639
  model: ctx.model?.id ?? undefined,
469
640
  provider: ctx.model?.provider ?? undefined,
@@ -8,7 +8,6 @@ import { findCutPoint, sessionEntryToContextMessages, type SessionEntry } from '
8
8
 
9
9
  interface CompactionEconomics {
10
10
  readonly remainingRequestScale: number;
11
- readonly remainingRequestStddevK: number;
12
11
  readonly windowReserveTokens: number;
13
12
  readonly firstCompactionRequestScale: number;
14
13
  readonly subsequentCompactionMargin: number;
@@ -16,7 +15,6 @@ interface CompactionEconomics {
16
15
 
17
16
  export const DEFAULT_COMPACTION_ECONOMICS: CompactionEconomics = Object.freeze({
18
17
  remainingRequestScale: 1.0,
19
- remainingRequestStddevK: 0.0,
20
18
  windowReserveTokens: 16_384,
21
19
  firstCompactionRequestScale: 2.0,
22
20
  subsequentCompactionMargin: 1.5,
@@ -51,8 +49,7 @@ type CompactionReason =
51
49
 
52
50
  interface RequestHorizonEstimate {
53
51
  readonly requestsPerBoundaryMean: number;
54
- readonly requestsPerBoundaryLowerBound: number;
55
- readonly unboundedExpectedRemainingRequests: number;
52
+ readonly requestsPerBoundaryEstimate: number;
56
53
  readonly windowRequestUpperBound: number | null;
57
54
  readonly expectedRemainingRequests: number;
58
55
  }
@@ -68,6 +65,13 @@ export interface CompactionDecision {
68
65
  readonly remainingBoundaries: number;
69
66
  readonly cacheWriteReadRatio: number | null;
70
67
  readonly incrementalCacheCostRatio: number | null;
68
+ /** Observed fraction of the reusable prefix served from cache (1 = every request a hit). */
69
+ readonly prefixCacheHitRate: number;
70
+ /**
71
+ * Expected per-request saving in cache-read-token units: a missed request bills the archived
72
+ * prefix at the write/input price, so it saves `cacheWriteReadRatio` times more than a hit.
73
+ */
74
+ readonly savingTokensPerRequest: number;
71
75
  readonly priorCompactionCount: number;
72
76
  readonly carriedDebtTokens: number;
73
77
  readonly cacheDebtRepaymentTokens: number;
@@ -75,8 +79,19 @@ export interface CompactionDecision {
75
79
  readonly reason: CompactionReason;
76
80
  }
77
81
 
78
- const MINIMUM_VARIANCE_SAMPLES = 3;
79
- const SMALL_SAMPLE_SCALE = 0.5;
82
+ /**
83
+ * Sparse boundary samples are noisy: a single 127-request boundary extrapolated over ten todos once
84
+ * predicted 509 remaining requests where 16 were left. Shrink each mean toward a small prior (a todo
85
+ * completion typically lands after a handful of model/tool steps) so one or two samples cannot
86
+ * dominate; after a few samples the prior fades out.
87
+ */
88
+ const SAMPLE_PRIOR_REQUESTS_PER_BOUNDARY = 5;
89
+ const SAMPLE_PRIOR_WEIGHT = 2;
90
+ /**
91
+ * Hard ceiling on any projected horizon. Compaction must pay for itself, and no observed boundary is
92
+ * evidence for an arbitrarily long remaining stretch.
93
+ */
94
+ export const MAX_HORIZON_REQUESTS = 100;
80
95
  /** Retained boundary-request sample cap so the mean tracks recent pacing. */
81
96
  export const MAX_BOUNDARY_SAMPLES = 16;
82
97
 
@@ -84,26 +99,31 @@ export function estimateRemainingRequests(input: {
84
99
  readonly completedBoundaryRequestCounts: readonly number[];
85
100
  readonly remainingBoundaries: number;
86
101
  readonly scale: number;
87
- readonly standardDeviationK: number;
88
102
  readonly contextTokens: number;
89
103
  readonly contextWindowTokens: number | null;
90
104
  readonly averageContextTokenIncrement: number | null;
105
+ /**
106
+ * Requests already spent since the last completed boundary or compaction. While work is visibly
107
+ * continuing the horizon must not collapse to 1 just because the todo list is momentarily empty.
108
+ */
109
+ readonly observedRequestsSinceBoundary?: number;
91
110
  }): RequestHorizonEstimate {
92
111
  const counts = input.completedBoundaryRequestCounts;
93
112
  const mean = counts.reduce((total, count) => total + count, 0) / Math.max(1, counts.length);
94
- let lowerBound = mean;
95
- if (input.standardDeviationK !== 0) {
96
- if (counts.length < MINIMUM_VARIANCE_SAMPLES) {
97
- lowerBound *= SMALL_SAMPLE_SCALE;
98
- } else {
99
- const variance = counts.reduce((total, count) => total + (count - mean) ** 2, 0);
100
- const deviation = Math.sqrt(variance / (counts.length - 1));
101
- lowerBound = Math.max(0, mean - input.standardDeviationK * deviation);
102
- }
103
- }
113
+ const estimatePerBoundary =
114
+ counts.length === 0
115
+ ? 0
116
+ : (mean * counts.length + SAMPLE_PRIOR_REQUESTS_PER_BOUNDARY * SAMPLE_PRIOR_WEIGHT)
117
+ / (counts.length + SAMPLE_PRIOR_WEIGHT);
104
118
 
105
- const unboundedExpectedRemainingRequests =
106
- 1 + Math.floor(lowerBound * Math.max(0, input.remainingBoundaries) * input.scale);
119
+ const observedWorkFloor = Math.min(
120
+ MAX_HORIZON_REQUESTS,
121
+ Math.max(0, input.observedRequestsSinceBoundary ?? 0),
122
+ );
123
+ const requestEstimate = Math.max(
124
+ 1 + Math.floor(estimatePerBoundary * Math.max(0, input.remainingBoundaries) * input.scale),
125
+ observedWorkFloor,
126
+ );
107
127
  const windowRequestUpperBound =
108
128
  input.contextWindowTokens === null ||
109
129
  input.averageContextTokenIncrement === null ||
@@ -116,13 +136,13 @@ export function estimateRemainingRequests(input: {
116
136
 
117
137
  return {
118
138
  requestsPerBoundaryMean: mean,
119
- requestsPerBoundaryLowerBound: lowerBound,
120
- unboundedExpectedRemainingRequests,
139
+ requestsPerBoundaryEstimate: estimatePerBoundary,
121
140
  windowRequestUpperBound,
122
- expectedRemainingRequests:
123
- windowRequestUpperBound === null
124
- ? unboundedExpectedRemainingRequests
125
- : Math.min(unboundedExpectedRemainingRequests, windowRequestUpperBound),
141
+ expectedRemainingRequests: Math.min(
142
+ MAX_HORIZON_REQUESTS,
143
+ requestEstimate,
144
+ windowRequestUpperBound ?? Number.POSITIVE_INFINITY,
145
+ ),
126
146
  };
127
147
  }
128
148
 
@@ -139,6 +159,10 @@ export function decideCompaction(input: {
139
159
  readonly carriedDebtTokens: number;
140
160
  readonly cacheDebtRepaymentTokens: number;
141
161
  readonly cacheWriteReadRatio: number | null;
162
+ /** Observed prefix cache hit rate in [0, 1]; omitted = every request priced as a hit. */
163
+ readonly prefixCacheHitRate?: number;
164
+ /** Requests observed since the last boundary/compaction; a floor on the remaining horizon. */
165
+ readonly observedRequestsSinceBoundary?: number;
142
166
  readonly economics?: CompactionEconomics;
143
167
  }): CompactionDecision {
144
168
  const economics = input.economics ?? DEFAULT_COMPACTION_ECONOMICS;
@@ -146,10 +170,10 @@ export function decideCompaction(input: {
146
170
  completedBoundaryRequestCounts: input.completedBoundaryRequestCounts,
147
171
  remainingBoundaries: input.remainingBoundaries,
148
172
  scale: economics.remainingRequestScale,
149
- standardDeviationK: economics.remainingRequestStddevK,
150
173
  contextTokens: input.contextTokens,
151
174
  contextWindowTokens: input.contextWindowTokens,
152
175
  averageContextTokenIncrement: input.averageContextTokenIncrement,
176
+ observedRequestsSinceBoundary: input.observedRequestsSinceBoundary,
153
177
  });
154
178
 
155
179
  const savingTokens = input.archiveTokens - input.memoTokens;
@@ -159,12 +183,19 @@ export function decideCompaction(input: {
159
183
  const incrementalWriteCost = incrementalCacheCostRatio === null
160
184
  ? null
161
185
  : input.writeTokens * incrementalCacheCostRatio;
186
+ // Pricing every request as a cache hit undervalues compaction: 1.4% of requests miss the prefix
187
+ // cache (TTL expiry) and bill it at the input price — 50x cache-read on deepseek, ~1.7x the
188
+ // expected saving. The ratio never drops below 1, so the scaled saving never shrinks.
189
+ const prefixCacheHitRate = Math.min(1, Math.max(0, input.prefixCacheHitRate ?? 1));
190
+ const missPriceRatio = Math.max(1, input.cacheWriteReadRatio ?? 1);
191
+ const savingTokensPerRequest = Math.max(0, savingTokens)
192
+ * (prefixCacheHitRate + (1 - prefixCacheHitRate) * missPriceRatio);
162
193
 
163
194
  const breakevenRequests = compressible && incrementalWriteCost !== null
164
- ? incrementalWriteCost / savingTokens
195
+ ? incrementalWriteCost / savingTokensPerRequest
165
196
  : null;
166
197
  const combinedBreakevenRequests = compressible && incrementalWriteCost !== null
167
- ? (input.carriedDebtTokens + incrementalWriteCost) / savingTokens
198
+ ? (input.carriedDebtTokens + incrementalWriteCost) / savingTokensPerRequest
168
199
  : null;
169
200
 
170
201
  const firstCompaction = input.priorCompactionCount === 0;
@@ -232,6 +263,8 @@ export function decideCompaction(input: {
232
263
  remainingBoundaries: input.remainingBoundaries,
233
264
  cacheWriteReadRatio: input.cacheWriteReadRatio,
234
265
  incrementalCacheCostRatio,
266
+ prefixCacheHitRate,
267
+ savingTokensPerRequest,
235
268
  priorCompactionCount: input.priorCompactionCount,
236
269
  carriedDebtTokens: input.carriedDebtTokens,
237
270
  cacheDebtRepaymentTokens: input.cacheDebtRepaymentTokens,
@@ -312,7 +345,7 @@ export function nativeCompactionFeasible(entries: readonly SessionEntry[], keepR
312
345
  for (let index = path.length - 1; index >= 0; index--) {
313
346
  const entry = path[index];
314
347
  if (entry?.type !== 'compaction') continue;
315
- const keptIndex = path.findIndex((item) => item.id === (entry as { firstKeptEntryId?: string }).firstKeptEntryId);
348
+ const keptIndex = path.findIndex((item) => item.id === entry.firstKeptEntryId);
316
349
  startIndex = keptIndex >= 0 ? keptIndex : index + 1;
317
350
  break;
318
351
  }
@@ -79,6 +79,7 @@ const EFFICIENCY_KNOBS: readonly ConfigKnob[] = [
79
79
  { plane: 'efficiency', key: 'onlineContextCompact.firstCompactionRequestScale', kind: 'number', defaultValue: 2.0, min: 1.0, impact: 'First-compaction horizon relaxation', docRef: 'docs/efficiency-trial.md' },
80
80
  { plane: 'efficiency', key: 'onlineContextCompact.subsequentCompactionMargin', kind: 'number', defaultValue: 1.5, min: 1.0, impact: 'Safety margin required for later compactions', docRef: 'docs/efficiency-trial.md' },
81
81
  { plane: 'efficiency', key: 'onlineContextCompact.keepRecentTokens', kind: 'number', defaultValue: 20000, min: 1000, impact: 'Native compaction tail window; inherited from pi unless set here', docRef: 'docs/configuration.md' },
82
+ { plane: 'efficiency', key: 'onlineContextCompact.summaryThinkingLevel', kind: 'enum', defaultValue: 'low', impact: 'Cap on the compaction summary thinking level (inherit | off | minimal | low | medium | high | xhigh | max); never raised above the session level', docRef: 'docs/efficiency-trial.md' },
82
83
  { plane: 'efficiency', key: 'observationPack.enabled', kind: 'boolean', defaultValue: false, envVar: 'PI_HERDR_OBS_PACK_ENABLE', impact: 'Project large tool outputs as placeholders (observationPack)', docRef: 'docs/efficiency-trial.md' },
83
84
  { plane: 'efficiency', key: 'observationPack.logEnabled', kind: 'boolean', defaultValue: false, envVar: 'PI_HERDR_OBS_PACK_LOG', impact: 'Write observation.jsonl packed/recall records', docRef: 'docs/efficiency-trial.md' },
84
85
  { plane: 'efficiency', key: 'observationPack.thresholdBytes', kind: 'number', defaultValue: 10240, min: 1024, impact: 'Minimum output size before packing applies', docRef: 'docs/efficiency-trial.md' },
@@ -26,7 +26,7 @@ export interface DelegationContext {
26
26
  readonly description: string;
27
27
  /** Role manifest name active at spawn. */
28
28
  readonly role: string;
29
- /** Phase 2: the master's pipe name — report_progress dials it directly (D49 reply-address
29
+ /** The master's pipe name — the worker's `todo_sync` push dials it directly (D49 reply-address
30
30
  * semantics, precomputed because the worker cannot derive the master's session cwd). */
31
31
  readonly parentPipe?: string;
32
32
  }