pi-pier 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +20 -7
  2. package/package.json +7 -2
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/skills/pi-herdr-workflow/SKILL.md +12 -0
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +60 -10
  8. package/src/bootstrap.ts +5 -2
  9. package/src/compact-coordinator.ts +454 -0
  10. package/src/compact-economics-core.ts +396 -0
  11. package/src/config-catalog-core.ts +452 -0
  12. package/src/config-command.ts +181 -0
  13. package/src/config-guide.ts +361 -0
  14. package/src/core/grid-shape.ts +4 -2
  15. package/src/core/heat-plan.ts +160 -0
  16. package/src/core/observation.ts +555 -0
  17. package/src/core/subagent.ts +187 -393
  18. package/src/core/terminal.ts +121 -47
  19. package/src/core/todo.ts +25 -11
  20. package/src/dashboard-command.ts +89 -0
  21. package/src/efficiency-config-core.ts +661 -0
  22. package/src/efficiency-config-io.ts +53 -0
  23. package/src/efficiency-store.ts +312 -0
  24. package/src/focus-poller.ts +244 -0
  25. package/src/gc-core.ts +85 -0
  26. package/src/git-adapter.ts +12 -3
  27. package/src/herdr-client.ts +292 -36
  28. package/src/index-locks.ts +27 -9
  29. package/src/index-master.ts +5 -1
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +3 -1
  33. package/src/index.ts +330 -34
  34. package/src/jev-client.ts +146 -0
  35. package/src/jev-core.ts +381 -0
  36. package/src/lock-core.ts +105 -16
  37. package/src/observation-core.ts +248 -0
  38. package/src/pier-options.ts +96 -0
  39. package/src/pipe-channel.ts +52 -7
  40. package/src/reducer-core.ts +358 -0
  41. package/src/reducer-invoker.ts +392 -0
  42. package/src/renderers.ts +198 -0
  43. package/src/role-loader.ts +2 -1
  44. package/src/role-manifest.ts +0 -4
  45. package/src/runtime-policy.ts +5 -8
  46. package/src/session-tail.ts +60 -5
  47. package/src/settle-wake-core.ts +3 -1
  48. package/src/slim-frame.ts +4 -2
  49. package/src/storage-layout.ts +23 -0
  50. package/src/subagent-core.ts +3 -1
  51. package/src/subagent-execute.ts +328 -0
  52. package/src/subagent-gc.ts +33 -13
  53. package/src/subagent-launch.ts +4 -0
  54. package/src/subagent-list-action.ts +69 -0
  55. package/src/subagent-output-action.ts +104 -0
  56. package/src/subagent-output-adapter.ts +27 -0
  57. package/src/subagent-output-core.ts +263 -0
  58. package/src/subagent-poll-loop.ts +59 -39
  59. package/src/subagent-poller.ts +46 -0
  60. package/src/subagent-ready.ts +89 -0
  61. package/src/subagent-registry.ts +70 -0
  62. package/src/subagent-resolution.ts +41 -0
  63. package/src/subagent-scope.ts +3 -1
  64. package/src/subagent-session-io.ts +115 -32
  65. package/src/subagent-spawn.ts +153 -8
  66. package/src/swallow.ts +75 -0
  67. package/src/terminal-core.ts +82 -10
  68. package/src/todo-core.ts +5 -4
  69. package/src/todo-reminder-core.ts +8 -1
  70. package/src/todos-service.ts +18 -6
  71. package/src/tool-error.ts +26 -0
  72. package/src/vocab.ts +7 -4
package/src/ask-user.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * so the model cannot forget a free-text escape and Esc is a real decline.
7
7
  */
8
8
  import { Type } from 'typebox';
9
+ import { runMultiSelect } from './ask-multi.ts';
9
10
 
10
11
  export const ASK_TOOL_NAME = 'ask_user_question';
11
12
  export const OTHER_OPTION = 'Other (type your own)';
@@ -44,16 +45,18 @@ export const ASK_PARAMETERS = Type.Object({
44
45
  options: Type.Optional(Type.Array(OptionSchema, {
45
46
  description: '2-5 authored choices. Do NOT include Other; the UI appends it.',
46
47
  })),
47
- multi: Type.Optional(Type.Boolean({ description: 'Allow multiple selections (default false)' })),
48
+ multi: Type.Optional(Type.Boolean({ description: 'Allow multiple selections (default false). In the TUI this opens an interactive toggle list (space toggles, enter confirms, esc declines).' })),
49
+ allowOther: Type.Optional(Type.Boolean({ description: 'Set false to offer the authored choices only, with no free-text row (default true).' })),
48
50
  recommended: Type.Optional(Type.Number({ description: '0-based index of the recommended option; UI adds (Recommended)' })),
49
51
  questions: Type.Optional(Type.Array(
50
52
  Type.Object({
51
53
  id: Type.Optional(Type.String({ description: 'Stable id for mapping answers' })),
52
54
  question: Type.String({ description: 'Question text' }),
53
55
  options: Type.Array(OptionSchema, {
54
- description: '2-5 authored choices. Do NOT include Other; the UI appends it.',
56
+ description: '2-5 authored choices. Do NOT include Other; the UI appends it unless allowOther is false.',
55
57
  }),
56
- multi: Type.Optional(Type.Boolean({ description: 'Allow multiple selections (default false)' })),
58
+ multi: Type.Optional(Type.Boolean({ description: 'Allow multiple selections (default false). In the TUI this opens an interactive toggle list (space toggles, enter confirms).' })),
59
+ allowOther: Type.Optional(Type.Boolean({ description: 'Set false to offer the authored choices only, with no free-text row (default true).' })),
57
60
  recommended: Type.Optional(Type.Number({ description: '0-based recommended option index' })),
58
61
  }),
59
62
  { description: '1-4 related questions in one call. Wins over top-level question/options when set.' },
@@ -66,14 +69,16 @@ export const ASK_TOOL_DESCRIPTION = [
66
69
  'While waiting, the pane shows as blocked in herdr (the human sees it and can step in).',
67
70
  'Prefer 2-5 options with short labels; put tradeoffs in description.',
68
71
  'Do NOT include an "Other" option — the UI appends "Other (type your own)" automatically.',
69
- 'Use recommended (0-based) to mark the default; "(Recommended)" is added automatically.',
70
- 'Use multi: true when several options can apply. Group related questions in questions (max 4) rather than calling this tool repeatedly.',
72
+ 'Use recommended (0-based) to mark the default; "(Recommended)" is added automatically — do NOT write it in the label.',
73
+ 'Use multi: true when several options can apply — the TUI opens an interactive toggle list (space toggles a row, a toggles all, enter confirms, esc declines) instead of making the human type numbers.',
74
+ 'Set allowOther: false when the authored choices are exhaustive and a free-text answer would be misleading.',
71
75
  'The answer comes back as the tool result; then continue your work.',
72
76
  ].join(' ');
73
77
 
74
78
  export const ASK_PROMPT_GUIDELINES = [
75
79
  'Default to action. Ask only when options have materially different tradeoffs the user must decide.',
76
- 'Do NOT include "Other" in options; the UI appends it.',
80
+ 'Do NOT include "Other" in options; the UI appends it unless allowOther is false. Group related questions in questions (max 4) rather than calling this tool repeatedly.',
81
+ 'For multi questions the human toggles rows in a list and confirms with enter — never instruct them to type indices.',
77
82
  'Short option labels; explanatory tradeoffs in description.',
78
83
  ];
79
84
 
@@ -83,6 +88,8 @@ export type AskQuestion = {
83
88
  question: string;
84
89
  options: AskOption[];
85
90
  multi: boolean;
91
+ /** When false the UI offers the authored choices only (no free-text row). */
92
+ allowOther: boolean;
86
93
  recommended?: number;
87
94
  };
88
95
  export type AskSpec =
@@ -112,6 +119,8 @@ export type AskToolResult = {
112
119
  export type AskUi = {
113
120
  select?: (title: string, options: string[], opts?: { signal?: AbortSignal }) => Promise<string | undefined>;
114
121
  input: (title: string, placeholder?: string, opts?: { signal?: AbortSignal }) => Promise<string | undefined>;
122
+ /** TUI-only: full custom components (absent in RPC mode, where it returns undefined). */
123
+ custom?: (factory: unknown) => Promise<unknown>;
115
124
  };
116
125
 
117
126
  export type PrepareAskResult =
@@ -169,8 +178,19 @@ function parseOptions(raw: unknown): { ok: true; options: AskOption[] } | { ok:
169
178
  const seen = new Set<string>();
170
179
  for (const item of raw) {
171
180
  const rec = asRecord(item);
172
- const label = typeof rec?.label === 'string' ? rec.label.trim() : '';
173
- if (!label) return fail('Error: each option needs a non-empty label', 'empty_options');
181
+ const rawLabel = typeof rec?.label === 'string' ? rec.label.trim() : '';
182
+ // B3: models often repeat the UI's own marker (`label: 'Redis (Recommended)'`), which rendered
183
+ // as "Redis (Recommended) (Recommended)". Only a trailing bracketed marker is stripped, so a
184
+ // label that merely mentions the word ("Recommended approach") survives untouched.
185
+ const label = rawLabel.replace(/\s*[([]\s*recommended\s*[)\]]\s*$/i, '').trim();
186
+ if (!label) {
187
+ return fail(
188
+ rawLabel
189
+ ? `Error: option label "${rawLabel}" is only the marker the UI appends — give the choice a real name and pass "recommended" instead`
190
+ : 'Error: each option needs a non-empty label',
191
+ 'empty_options',
192
+ );
193
+ }
174
194
  if (label.length > MAX_LABEL_CHARS) {
175
195
  return fail(`Error: option label exceeds ${MAX_LABEL_CHARS} characters`, 'label_too_long');
176
196
  }
@@ -205,6 +225,7 @@ function parseQuestion(raw: unknown): { ok: true; question: AskQuestion } | { ok
205
225
  question,
206
226
  options: parsed.options,
207
227
  multi: rec.multi === true,
228
+ allowOther: rec.allowOther !== false,
208
229
  ...(id ? { id } : {}),
209
230
  ...(recommended !== undefined ? { recommended } : {}),
210
231
  },
@@ -255,6 +276,7 @@ export function prepareAsk(params: unknown): PrepareAskResult {
255
276
  question,
256
277
  options: parsed.options,
257
278
  multi: rec.multi === true,
279
+ allowOther: rec.allowOther !== false,
258
280
  ...(recommended !== undefined ? { recommended } : {}),
259
281
  }],
260
282
  },
@@ -276,7 +298,7 @@ export function formatAuthoredLine(option: AskOption, index: number, recommended
276
298
 
277
299
  export function optionLines(question: AskQuestion): string[] {
278
300
  const lines = question.options.map((option, i) => formatAuthoredLine(option, i, question.recommended));
279
- lines.push(`${question.options.length + 1}. ${OTHER_OPTION}`);
301
+ if (question.allowOther !== false) lines.push(`${question.options.length + 1}. ${OTHER_OPTION}`);
280
302
  return lines;
281
303
  }
282
304
 
@@ -333,11 +355,37 @@ async function askSingle(ui: AskUi, question: AskQuestion, signal?: AbortSignal)
333
355
  }
334
356
 
335
357
  async function askMulti(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
358
+ // TUI path: a real toggle list. ctx.ui.custom is absent in RPC mode (or returns undefined),
359
+ // in which case we fall through to the typed-index prompt below.
360
+ if (typeof ui.custom === 'function') {
361
+ const outcome = await runMultiSelect(ui.custom, question.question, {
362
+ options: question.options,
363
+ allowOther: question.allowOther,
364
+ ...(question.recommended !== undefined ? { recommended: question.recommended } : {}),
365
+ });
366
+ if (outcome?.kind === 'cancel') return undefined;
367
+ if (outcome?.kind === 'other') {
368
+ const typed = await ui.input(`${question.question}\n\n${CUSTOM_ANSWER_TITLE}`, '', signal ? { signal } : undefined);
369
+ if (typed == null || !typed.trim()) return undefined;
370
+ return withId(question, {
371
+ question: question.question,
372
+ kind: 'custom',
373
+ answer: OTHER_ANSWER,
374
+ customInput: typed.trim(),
375
+ });
376
+ }
377
+ if (outcome?.kind === 'selected') {
378
+ return withId(question, { question: question.question, kind: 'multi', answer: null, selected: outcome.labels });
379
+ }
380
+ }
336
381
  const list = question.options
337
382
  .map((option, i) => formatAuthoredLine(option, i, question.recommended))
338
383
  .join('\n');
384
+ const hint = question.allowOther === false
385
+ ? `Enter the numbers of all that apply, comma-separated (e.g. "1,3"). There is no free-text answer for this question.`
386
+ : MULTI_INSTRUCTIONS;
339
387
  const value = await ui.input(
340
- `${question.question}\n\n${list}\n\n${MULTI_INSTRUCTIONS}`,
388
+ `${question.question}\n\n${list}\n\n${hint}`,
341
389
  '1,3',
342
390
  signal ? { signal } : undefined,
343
391
  );
@@ -360,6 +408,8 @@ async function askMulti(ui: AskUi, question: AskQuestion, signal?: AbortSignal):
360
408
  }
361
409
  return withId(question, { question: question.question, kind: 'multi', answer: null, selected });
362
410
  }
411
+ // Unparsable input is a free-text answer only when the free-text row was offered.
412
+ if (question.allowOther === false) return undefined;
363
413
  return withId(question, {
364
414
  question: question.question,
365
415
  kind: 'custom',
package/src/bootstrap.ts CHANGED
@@ -15,6 +15,7 @@ import Group from '@deepseek-ai/cordis-plugin-group';
15
15
  import { pathToFileURL, fileURLToPath } from 'node:url';
16
16
  import * as path from 'node:path';
17
17
  import { DisposeLedger } from './ledger.ts';
18
+ import { pierOption } from './pier-options.ts';
18
19
 
19
20
  export interface BootstrapHooks {
20
21
  onDispose?: () => void;
@@ -60,7 +61,8 @@ export async function createCordisApp(hooks: BootstrapHooks = {}): Promise<Cordi
60
61
  console.error(`[pi-herdr] cordis loader degraded (bare tree): ${err instanceof Error ? err.message : String(err)}`);
61
62
  }
62
63
 
63
- if (loaderReady && detectExposeInternals() && process.env.PI_HERDR_HMR === '1') {
64
+ // B10: canonical PIER_HMR, legacy PI_HERDR_HMR.
65
+ if (loaderReady && detectExposeInternals() && pierOption('PIER_HMR') === '1') {
64
66
  try {
65
67
  const withLoader = root as Context & {
66
68
  loader?: { create: (o: { name: string; config?: unknown }) => Promise<unknown> };
@@ -78,7 +80,8 @@ export async function createCordisApp(hooks: BootstrapHooks = {}): Promise<Cordi
78
80
  console.error('[pi-herdr] hmr active (dev posture: --expose-internals + PI_HERDR_HMR=1)');
79
81
  // D80⑤: HMR already disposes old fiber effects; this hook additionally retires old pi-surface generations.
80
82
  // d87 keeps generations registered after the reload boundary alive; disposeKey retires only older entries.
81
- root.on('hmr/reload', (reloads: unknown) => {
83
+ // Cordis' own HMR boundary event (not part of pi's typed Events map).
84
+ (root as unknown as { on(name: string, fn: (reloads: unknown) => void): void }).on('hmr/reload', (reloads: unknown) => {
82
85
  try {
83
86
  const files: string[] = [];
84
87
  for (const r of (reloads as Map<unknown, { filename?: string }>).values()) {
@@ -0,0 +1,454 @@
1
+ /**
2
+ * D100 Online Context Compact Coordinator.
3
+ *
4
+ * Orchestrates:
5
+ * - Tracking todo completion boundaries (filtered to source === 'tool').
6
+ * - Evaluating KV-cache economics & window protection on turn_end.
7
+ * - Guarding against ejecting queued messages (hasPendingMessages check).
8
+ * - Aborting turn and executing Pi native compaction with custom instructions.
9
+ * - Feeding remaining tasks forward and injecting continuation turn.
10
+ * - Persisting state to session entries across branches and restores.
11
+ */
12
+
13
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
14
+ import {
15
+ decideCompaction,
16
+ DEFAULT_COMPACTION_ECONOMICS,
17
+ estimateRemainingRequests,
18
+ MAX_BOUNDARY_SAMPLES,
19
+ nativeCompactionFeasible,
20
+ resolveCacheRatioFromCost,
21
+ type CompactionDecision,
22
+ } from './compact-economics-core.ts';
23
+ import type { TodoItem } from './todo-core.ts';
24
+ import type { TodoCompletionSource } from './todos-service.ts';
25
+ import type { OnlineContextCompactConfig } from './efficiency-config-core.ts';
26
+ import {
27
+ appendEfficiencyLog,
28
+ efficiencyLogPath,
29
+ resolveSessionRoot,
30
+ } from './efficiency-store.ts';
31
+
32
+ export const COMPACT_STATE_CUSTOM_TYPE = 'pi-herdr.efficiency-state';
33
+ export const COMPACTION_CONTINUE_TYPE = 'pi-herdr.compaction-continue';
34
+ export const DEFAULT_KEEP_RECENT_TOKENS = 20_000;
35
+ export const DEFAULT_MEMO_TOKENS = 1_000;
36
+
37
+ export interface CoordinatorState {
38
+ version: 1;
39
+ epoch: number;
40
+ completedBoundaryRequestCounts: number[];
41
+ carriedDebtTokens: number;
42
+ cacheDebtRepaymentTokens: number;
43
+ priorCompactionCount: number;
44
+ positiveContextDeltaTotal: number;
45
+ positiveContextDeltaCount: number;
46
+ lastContextTokens: number | null;
47
+ currentBoundaryRequestCount: number;
48
+ }
49
+
50
+ export function initialCoordinatorState(): CoordinatorState {
51
+ return {
52
+ version: 1,
53
+ epoch: 0,
54
+ completedBoundaryRequestCounts: [],
55
+ carriedDebtTokens: 0,
56
+ cacheDebtRepaymentTokens: 0,
57
+ priorCompactionCount: 0,
58
+ positiveContextDeltaTotal: 0,
59
+ positiveContextDeltaCount: 0,
60
+ lastContextTokens: null,
61
+ currentBoundaryRequestCount: 0,
62
+ };
63
+ }
64
+
65
+ export function restoreCoordinatorState(entries: readonly unknown[]): CoordinatorState {
66
+ if (!Array.isArray(entries)) return initialCoordinatorState();
67
+ for (let i = entries.length - 1; i >= 0; i--) {
68
+ const entry = entries[i] as { type?: string; customType?: string; data?: unknown };
69
+ if (entry && entry.type === 'custom' && entry.customType === COMPACT_STATE_CUSTOM_TYPE) {
70
+ const d = entry.data as Partial<CoordinatorState>;
71
+ if (d && d.version === 1 && Array.isArray(d.completedBoundaryRequestCounts)) {
72
+ return {
73
+ version: 1,
74
+ epoch: typeof d.epoch === 'number' ? d.epoch : 0,
75
+ completedBoundaryRequestCounts: [...d.completedBoundaryRequestCounts],
76
+ carriedDebtTokens: typeof d.carriedDebtTokens === 'number' ? d.carriedDebtTokens : 0,
77
+ cacheDebtRepaymentTokens: typeof d.cacheDebtRepaymentTokens === 'number' ? d.cacheDebtRepaymentTokens : 0,
78
+ priorCompactionCount: typeof d.priorCompactionCount === 'number' ? d.priorCompactionCount : 0,
79
+ positiveContextDeltaTotal: typeof d.positiveContextDeltaTotal === 'number' ? d.positiveContextDeltaTotal : 0,
80
+ positiveContextDeltaCount: typeof d.positiveContextDeltaCount === 'number' ? d.positiveContextDeltaCount : 0,
81
+ lastContextTokens: typeof d.lastContextTokens === 'number' ? d.lastContextTokens : null,
82
+ currentBoundaryRequestCount: typeof d.currentBoundaryRequestCount === 'number' ? d.currentBoundaryRequestCount : 0,
83
+ };
84
+ }
85
+ }
86
+ }
87
+ return initialCoordinatorState();
88
+ }
89
+
90
+ export class CompactCoordinator {
91
+ state: CoordinatorState = initialCoordinatorState();
92
+ compactionInFlight = false;
93
+ intentionalAbort = false;
94
+ selectedCompaction: CompactionDecision | null = null;
95
+ pendingBoundaryCompleted = false;
96
+
97
+ recordBoundaryCompleted(count: number, source?: TodoCompletionSource): void {
98
+ // Only count completed transitions originating from model tool calls
99
+ if (source !== 'tool' || count <= 0) return;
100
+ this.pendingBoundaryCompleted = true;
101
+ if (this.state.currentBoundaryRequestCount > 0) {
102
+ this.state.completedBoundaryRequestCounts.push(this.state.currentBoundaryRequestCount);
103
+ if (this.state.completedBoundaryRequestCounts.length > MAX_BOUNDARY_SAMPLES) {
104
+ this.state.completedBoundaryRequestCounts.splice(
105
+ 0,
106
+ this.state.completedBoundaryRequestCounts.length - MAX_BOUNDARY_SAMPLES,
107
+ );
108
+ }
109
+ this.state.currentBoundaryRequestCount = 0;
110
+ }
111
+ }
112
+
113
+ onBeforeProviderRequest(contextTokens: number): void {
114
+ this.state.currentBoundaryRequestCount++;
115
+ if (this.state.carriedDebtTokens > 0 && this.state.cacheDebtRepaymentTokens > 0) {
116
+ this.state.carriedDebtTokens = Math.max(
117
+ 0,
118
+ this.state.carriedDebtTokens - this.state.cacheDebtRepaymentTokens,
119
+ );
120
+ if (this.state.carriedDebtTokens === 0) {
121
+ this.state.cacheDebtRepaymentTokens = 0;
122
+ }
123
+ }
124
+ if (this.state.lastContextTokens !== null && contextTokens > this.state.lastContextTokens) {
125
+ this.state.positiveContextDeltaTotal += contextTokens - this.state.lastContextTokens;
126
+ this.state.positiveContextDeltaCount++;
127
+ }
128
+ this.state.lastContextTokens = contextTokens;
129
+ }
130
+
131
+ onInput(event: { text?: string; source?: string; streamingBehavior?: string }): void {
132
+ // Ignore internal messages dispatched by extensions (D96 reminders, pipe injections, followUps)
133
+ if (event.source === 'extension' || event.text?.startsWith('CORRECTION:')) {
134
+ return;
135
+ }
136
+
137
+ const fromHuman =
138
+ event.source === 'interactive' ||
139
+ event.source === 'rpc' ||
140
+ event.source === undefined;
141
+
142
+ // If human typed a new prompt or steer directive, reset expectations (correction)
143
+ if (fromHuman) {
144
+ this.selectedCompaction = null;
145
+ this.intentionalAbort = false;
146
+ this.pendingBoundaryCompleted = false;
147
+ // completedBoundaryRequestCounts deliberately survives: it measures
148
+ // requests-per-boundary pacing, which holds across human turns. Clearing it
149
+ // zeroed the mean and pinned expectedRemainingRequests at 1 after every
150
+ // prompt, so OCC never saw a horizon worth compacting under (2026-09-17
151
+ // review: compact.jsonl epoch kept climbing 1→9 with zero compactions).
152
+ this.state.carriedDebtTokens = 0;
153
+ this.state.epoch++;
154
+ }
155
+ }
156
+
157
+ onTurnEnd(opts: {
158
+ ctx: ExtensionContext;
159
+ todos: readonly TodoItem[];
160
+ config: OnlineContextCompactConfig;
161
+ cancelReminder?: () => void;
162
+ }): void {
163
+ const boundary = this.pendingBoundaryCompleted;
164
+ this.pendingBoundaryCompleted = false;
165
+
166
+ if (!boundary || this.selectedCompaction) return;
167
+ if (!opts.config.enabled) return;
168
+
169
+ // Safety guard: if user typed something that is queued, don't abort!
170
+ if (opts.ctx.hasPendingMessages?.()) return;
171
+
172
+ const usage = opts.ctx.getContextUsage?.();
173
+ const contextTokens =
174
+ typeof usage?.tokens === 'number' && usage.tokens > 0
175
+ ? usage.tokens
176
+ : this.state.lastContextTokens ?? 0;
177
+ const contextWindowTokens = usage?.contextWindow ?? opts.ctx.model?.contextWindow ?? null;
178
+ const fixedTokens = Math.ceil(Buffer.byteLength(opts.ctx.getSystemPrompt?.() ?? '') / 4);
179
+ const keepRecentTokens = opts.config.keepRecentTokens ?? DEFAULT_KEEP_RECENT_TOKENS;
180
+ const archiveTokens = Math.max(0, contextTokens - fixedTokens - keepRecentTokens);
181
+ const remainingBoundaries = opts.todos.filter(
182
+ (it) => it.status !== 'completed' && it.status !== 'abandoned',
183
+ ).length;
184
+
185
+ const averageContextTokenIncrement =
186
+ this.state.positiveContextDeltaCount > 0
187
+ ? this.state.positiveContextDeltaTotal / this.state.positiveContextDeltaCount
188
+ : null;
189
+ const cacheRatio = resolveCacheRatioFromCost(
190
+ opts.config.cacheWriteReadRatio,
191
+ opts.ctx.model?.cost,
192
+ { provider: opts.ctx.model?.provider, modelId: opts.ctx.model?.id },
193
+ );
194
+
195
+ const decision = decideCompaction({
196
+ writeTokens: contextTokens,
197
+ archiveTokens,
198
+ memoTokens: DEFAULT_MEMO_TOKENS,
199
+ contextTokens,
200
+ completedBoundaryRequestCounts: this.state.completedBoundaryRequestCounts,
201
+ remainingBoundaries,
202
+ averageContextTokenIncrement,
203
+ contextWindowTokens,
204
+ priorCompactionCount: this.state.priorCompactionCount,
205
+ carriedDebtTokens: this.state.carriedDebtTokens,
206
+ cacheDebtRepaymentTokens: this.state.cacheDebtRepaymentTokens,
207
+ cacheWriteReadRatio: cacheRatio,
208
+ economics: {
209
+ ...DEFAULT_COMPACTION_ECONOMICS,
210
+ firstCompactionRequestScale: opts.config.firstCompactionRequestScale,
211
+ subsequentCompactionMargin: opts.config.subsequentCompactionMargin,
212
+ },
213
+ });
214
+
215
+ if (decision.compact) {
216
+ const branch = opts.ctx.sessionManager?.getBranch?.() ?? [];
217
+ const keepRecentTokens = opts.config.keepRecentTokens ?? DEFAULT_KEEP_RECENT_TOKENS;
218
+ const feasible = nativeCompactionFeasible(branch, keepRecentTokens);
219
+ if (!feasible) {
220
+ if (opts.config.logEnabled) {
221
+ this.logDecision(opts.ctx, { ...decision, compact: false, reason: 'native_not_compactable' });
222
+ }
223
+ return;
224
+ }
225
+
226
+ this.selectedCompaction = decision;
227
+ this.intentionalAbort = true;
228
+ opts.cancelReminder?.();
229
+ opts.ctx.abort();
230
+ } else if (opts.config.logEnabled) {
231
+ this.logDecision(opts.ctx, decision);
232
+ }
233
+ }
234
+
235
+ async onAgentSettled(opts: {
236
+ ctx: ExtensionContext;
237
+ todos: readonly TodoItem[];
238
+ pi: ExtensionAPI;
239
+ config: OnlineContextCompactConfig;
240
+ cancelReminder?: () => void;
241
+ onBeforeCompact?: (sessionRoot: string, ctx: ExtensionContext) => Promise<void>;
242
+ }): Promise<void> {
243
+ if (!this.selectedCompaction || !opts.ctx.isIdle()) return;
244
+ const decision = this.selectedCompaction;
245
+ this.selectedCompaction = null;
246
+
247
+ opts.cancelReminder?.();
248
+ this.compactionInFlight = true;
249
+
250
+ const sessionDir = opts.ctx.sessionManager?.getSessionDir?.();
251
+ const sessionId = opts.ctx.sessionManager?.getSessionId?.();
252
+ const sessionRoot = resolveSessionRoot(sessionDir, sessionId);
253
+
254
+ if (sessionRoot && opts.onBeforeCompact) {
255
+ try {
256
+ await opts.onBeforeCompact(sessionRoot, opts.ctx);
257
+ } catch {
258
+ /* ignore batch packing errors before compaction */
259
+ }
260
+ }
261
+
262
+ const remaining = opts.todos.filter(
263
+ (it) => it.status !== 'completed' && it.status !== 'abandoned',
264
+ );
265
+ const taskLines = remaining
266
+ .map((it) => `- [${it.status}] ${it.content}${it.blocker ? ` (waiting on: ${it.blocker})` : ''}`)
267
+ .join('\n');
268
+
269
+ const customInstructions = [
270
+ 'Preserve completed work, verification results, important decisions, and remaining work.',
271
+ remaining.length > 0 ? `Active/remaining tasks to preserve:\n${taskLines}` : 'All listed tasks are completed.',
272
+ ].join('\n\n');
273
+
274
+ const startMs = Date.now();
275
+ await new Promise<void>((resolve) => {
276
+ let settled = false;
277
+ const finish = () => {
278
+ if (!settled) {
279
+ settled = true;
280
+ resolve();
281
+ }
282
+ };
283
+
284
+ opts.ctx.compact({
285
+ customInstructions,
286
+ onComplete: (compaction) => {
287
+ try {
288
+ this.state.priorCompactionCount++;
289
+ const incrementalRatio = decision.incrementalCacheCostRatio ?? 0;
290
+ this.state.carriedDebtTokens = decision.writeTokens * incrementalRatio;
291
+ this.state.cacheDebtRepaymentTokens = Math.max(0, decision.archiveTokens - decision.memoTokens);
292
+ this.state.epoch++;
293
+
294
+ opts.pi.appendEntry(COMPACT_STATE_CUSTOM_TYPE, this.state);
295
+
296
+ if (opts.config.logEnabled) {
297
+ const sessionDir = opts.ctx.sessionManager?.getSessionDir?.();
298
+ const sessionId = opts.ctx.sessionManager?.getSessionId?.();
299
+ const root = resolveSessionRoot(sessionDir, sessionId);
300
+ if (root) {
301
+ const logPath = efficiencyLogPath(root, 'compact');
302
+ void appendEfficiencyLog(logPath, {
303
+ schema: 'pier-efficiency/1',
304
+ mechanism: 'onlineContextCompact',
305
+ ts: new Date().toISOString(),
306
+ sessionId: sessionId ?? 'unknown',
307
+ epoch: this.state.epoch,
308
+ decision: decision.reason,
309
+ writeTokens: decision.writeTokens,
310
+ archiveTokens: decision.archiveTokens,
311
+ savedTokens: Math.max(0, decision.archiveTokens - decision.memoTokens),
312
+ breakevenRequests: decision.breakevenRequests,
313
+ expectedRemainingRequests: decision.expectedRemainingRequests,
314
+ carriedDebtTokens: this.state.carriedDebtTokens,
315
+ summaryTokens: typeof compaction?.summary === 'string' ? Math.ceil(compaction.summary.length / 4) : 0,
316
+ priorCompactionCount: this.state.priorCompactionCount,
317
+ durationMs: Date.now() - startMs,
318
+ }).catch(() => {});
319
+ }
320
+ }
321
+
322
+ try {
323
+ opts.pi.sendMessage(
324
+ {
325
+ customType: COMPACTION_CONTINUE_TYPE,
326
+ content: 'Online context compaction finished. Active tasks preserved. Continue working on remaining tasks.',
327
+ display: false,
328
+ },
329
+ { triggerTurn: true },
330
+ );
331
+ } catch {
332
+ /* ignore continuation failure */
333
+ }
334
+ } finally {
335
+ this.compactionInFlight = false;
336
+ this.intentionalAbort = false;
337
+ finish();
338
+ }
339
+ },
340
+ onError: (error) => {
341
+ this.compactionInFlight = false;
342
+ this.intentionalAbort = false;
343
+
344
+ // `Compaction cancelled` / AbortError mean the user (or pi) cancelled on purpose.
345
+ // Telemetry must keep those apart from real failures, and a cancelled compaction
346
+ // must NOT be answered with a turn-triggering continuation.
347
+ const cancelled =
348
+ error instanceof Error &&
349
+ (error.name === 'AbortError' || error.message === 'Compaction cancelled');
350
+
351
+ if (opts.config.logEnabled) {
352
+ const sessionDir = opts.ctx.sessionManager?.getSessionDir?.();
353
+ const sessionId = opts.ctx.sessionManager?.getSessionId?.();
354
+ const root = resolveSessionRoot(sessionDir, sessionId);
355
+ if (root) {
356
+ const logPath = efficiencyLogPath(root, 'compact');
357
+ void appendEfficiencyLog(logPath, {
358
+ schema: 'pier-efficiency/1',
359
+ mechanism: 'onlineContextCompact',
360
+ event: 'compaction-failed',
361
+ ts: new Date().toISOString(),
362
+ sessionId: sessionId ?? 'unknown',
363
+ epoch: this.state.epoch,
364
+ decision: decision.reason,
365
+ cancelled,
366
+ error: error instanceof Error ? error.message : String(error),
367
+ durationMs: Date.now() - startMs,
368
+ }).catch(() => {});
369
+ }
370
+ }
371
+
372
+ if (cancelled) {
373
+ finish();
374
+ return;
375
+ }
376
+
377
+ // Resume the task: OCC aborted the turn on purpose, so a failed compaction would
378
+ // otherwise leave the session parked on an aborted assistant message. Pi's own
379
+ // threshold compaction stays available as the safety net.
380
+ try {
381
+ opts.pi.sendMessage(
382
+ {
383
+ customType: COMPACTION_CONTINUE_TYPE,
384
+ content: 'Context compaction failed. Continuing with the current context.',
385
+ display: false,
386
+ },
387
+ { triggerTurn: true },
388
+ );
389
+ } catch {
390
+ /* ignore continuation failure */
391
+ }
392
+
393
+ finish();
394
+ },
395
+ });
396
+ });
397
+ }
398
+
399
+ rebuildFromBranch(entries: readonly unknown[]): void {
400
+ this.state = restoreCoordinatorState(entries);
401
+ this.selectedCompaction = null;
402
+ this.compactionInFlight = false;
403
+ this.intentionalAbort = false;
404
+ this.pendingBoundaryCompleted = false;
405
+ }
406
+
407
+ getRemainingHorizon(remainingBoundaries = 3, contextWindowTokens: number | null = null): number {
408
+ const counts = this.state.completedBoundaryRequestCounts;
409
+ if (counts.length === 0) return 4;
410
+
411
+ const est = estimateRemainingRequests({
412
+ completedBoundaryRequestCounts: counts,
413
+ remainingBoundaries,
414
+ scale: 1.0,
415
+ standardDeviationK: 0.0,
416
+ contextTokens: this.state.lastContextTokens ?? 0,
417
+ contextWindowTokens,
418
+ averageContextTokenIncrement:
419
+ this.state.positiveContextDeltaCount > 0
420
+ ? this.state.positiveContextDeltaTotal / this.state.positiveContextDeltaCount
421
+ : null,
422
+ });
423
+ return Math.max(1, est.expectedRemainingRequests);
424
+ }
425
+
426
+ private logDecision(ctx: ExtensionContext, decision: CompactionDecision): void {
427
+ const sessionDir = ctx.sessionManager?.getSessionDir?.();
428
+ const sessionId = ctx.sessionManager?.getSessionId?.();
429
+ const root = resolveSessionRoot(sessionDir, sessionId);
430
+ if (!root) return;
431
+ const logPath = efficiencyLogPath(root, 'compact');
432
+ void appendEfficiencyLog(logPath, {
433
+ schema: 'pier-efficiency/1',
434
+ mechanism: 'onlineContextCompact',
435
+ event: 'decision',
436
+ ts: new Date().toISOString(),
437
+ sessionId: sessionId ?? 'unknown',
438
+ epoch: this.state.epoch,
439
+ decision: decision.reason,
440
+ compact: decision.compact,
441
+ writeTokens: decision.writeTokens,
442
+ archiveTokens: decision.archiveTokens,
443
+ breakevenRequests: decision.breakevenRequests,
444
+ expectedRemainingRequests: decision.expectedRemainingRequests,
445
+ // 2026-09-17: without resolvedRatio/model the 61x cache_ratio_unavailable
446
+ // run was un-diagnosable from logs alone (which price table? which family?).
447
+ resolvedRatio: decision.cacheWriteReadRatio,
448
+ incrementalCacheCostRatio: decision.incrementalCacheCostRatio,
449
+ remainingBoundaries: decision.remainingBoundaries,
450
+ model: ctx.model?.id ?? undefined,
451
+ provider: ctx.model?.provider ?? undefined,
452
+ }).catch(() => {});
453
+ }
454
+ }