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.
- package/README.md +20 -7
- package/package.json +7 -2
- package/schemas/efficiency-config.schema.json +77 -0
- package/skills/pi-herdr-workflow/SKILL.md +12 -0
- package/src/ansi-text.ts +83 -0
- package/src/ask-multi.ts +260 -0
- package/src/ask-user.ts +60 -10
- package/src/bootstrap.ts +5 -2
- package/src/compact-coordinator.ts +454 -0
- package/src/compact-economics-core.ts +396 -0
- package/src/config-catalog-core.ts +452 -0
- package/src/config-command.ts +181 -0
- package/src/config-guide.ts +361 -0
- package/src/core/grid-shape.ts +4 -2
- package/src/core/heat-plan.ts +160 -0
- package/src/core/observation.ts +555 -0
- package/src/core/subagent.ts +187 -393
- package/src/core/terminal.ts +121 -47
- package/src/core/todo.ts +25 -11
- package/src/dashboard-command.ts +89 -0
- package/src/efficiency-config-core.ts +661 -0
- package/src/efficiency-config-io.ts +53 -0
- package/src/efficiency-store.ts +312 -0
- package/src/focus-poller.ts +244 -0
- package/src/gc-core.ts +85 -0
- package/src/git-adapter.ts +12 -3
- package/src/herdr-client.ts +292 -36
- package/src/index-locks.ts +27 -9
- package/src/index-master.ts +5 -1
- package/src/index-notices.ts +13 -2
- package/src/index-pipe.ts +5 -0
- package/src/index-worker.ts +3 -1
- package/src/index.ts +330 -34
- package/src/jev-client.ts +146 -0
- package/src/jev-core.ts +381 -0
- package/src/lock-core.ts +105 -16
- package/src/observation-core.ts +248 -0
- package/src/pier-options.ts +96 -0
- package/src/pipe-channel.ts +52 -7
- package/src/reducer-core.ts +358 -0
- package/src/reducer-invoker.ts +392 -0
- package/src/renderers.ts +198 -0
- package/src/role-loader.ts +2 -1
- package/src/role-manifest.ts +0 -4
- package/src/runtime-policy.ts +5 -8
- package/src/session-tail.ts +60 -5
- package/src/settle-wake-core.ts +3 -1
- package/src/slim-frame.ts +4 -2
- package/src/storage-layout.ts +23 -0
- package/src/subagent-core.ts +3 -1
- package/src/subagent-execute.ts +328 -0
- package/src/subagent-gc.ts +33 -13
- package/src/subagent-launch.ts +4 -0
- package/src/subagent-list-action.ts +69 -0
- package/src/subagent-output-action.ts +104 -0
- package/src/subagent-output-adapter.ts +27 -0
- package/src/subagent-output-core.ts +263 -0
- package/src/subagent-poll-loop.ts +59 -39
- package/src/subagent-poller.ts +46 -0
- package/src/subagent-ready.ts +89 -0
- package/src/subagent-registry.ts +70 -0
- package/src/subagent-resolution.ts +41 -0
- package/src/subagent-scope.ts +3 -1
- package/src/subagent-session-io.ts +115 -32
- package/src/subagent-spawn.ts +153 -8
- package/src/swallow.ts +75 -0
- package/src/terminal-core.ts +82 -10
- package/src/todo-core.ts +5 -4
- package/src/todo-reminder-core.ts +8 -1
- package/src/todos-service.ts +18 -6
- package/src/tool-error.ts +26 -0
- 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
|
|
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
|
|
173
|
-
|
|
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${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|