pi-pier 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -12
- package/package.json +3 -3
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +4 -5
- package/src/ansi-text.ts +62 -11
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +113 -112
- package/src/bootstrap.ts +10 -13
- package/src/compact-coordinator.ts +279 -261
- package/src/compact-economics-core.ts +77 -145
- package/src/config-catalog-core.ts +77 -197
- package/src/config-command.ts +271 -64
- package/src/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +77 -33
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +286 -486
- package/src/efficiency-store.ts +16 -29
- package/src/gc-core.ts +10 -36
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +202 -233
- package/src/history-store.ts +38 -23
- package/src/index-gates.ts +118 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +31 -29
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +31 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +16 -6
- package/src/index-worker.ts +8 -18
- package/src/index.ts +561 -570
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +138 -90
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +28 -56
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +52 -100
- package/src/pane-title.ts +19 -38
- package/src/panel-open.ts +47 -0
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +53 -50
- package/src/pipe-channel.ts +44 -59
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +8 -10
- package/src/{core → plugins}/heat-plan.ts +20 -22
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +766 -0
- package/src/{core → plugins}/terminal.ts +198 -139
- package/src/{core → plugins}/todo.ts +73 -134
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +26 -64
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +108 -84
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +25 -31
- package/src/role-state.ts +119 -0
- package/src/roles/worker-default.json +3 -3
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -40
- package/src/session-tail.ts +65 -41
- package/src/settle-wake-core.ts +61 -22
- package/src/slim-frame.ts +25 -122
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +14 -43
- package/src/subagent-core.ts +423 -120
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +383 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +577 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +187 -134
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +46 -39
- package/src/todo-reminder-core.ts +26 -51
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +15 -35
- package/src/tool-error.ts +6 -13
- package/src/tool-gate.ts +38 -21
- package/src/tree-view.ts +162 -0
- package/src/vocab.ts +52 -25
- package/src/worker-marker.ts +76 -0
- package/src/ask-multi.ts +0 -260
- package/src/config-guide.ts +0 -361
- package/src/core/observation.ts +0 -555
- package/src/core/subagent.ts +0 -633
- package/src/efficiency-config-io.ts +0 -53
- package/src/focus-poller.ts +0 -244
- package/src/git-adapter.ts +0 -113
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-execute.ts +0 -328
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -114
- package/src/subagent-list-action.ts +0 -69
- package/src/subagent-output-action.ts +0 -104
- package/src/subagent-output-adapter.ts +0 -27
- package/src/subagent-output-core.ts +0 -263
- package/src/subagent-poll-loop.ts +0 -305
- package/src/subagent-port.ts +0 -27
- package/src/subagent-ready.ts +0 -89
- package/src/subagent-registry.ts +0 -70
- package/src/subagent-resolution.ts +0 -41
- package/src/subagent-scope.ts +0 -41
- package/src/subagent-session-io.ts +0 -230
|
@@ -1,40 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* D100 Online Context Compact Coordinator
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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.
|
|
2
|
+
* D100 Online Context Compact Coordinator: tracks tool-sourced todo completion boundaries, evaluates
|
|
3
|
+
* KV-cache economics and window protection on turn_end, aborts the in-flight turn, runs pi's native
|
|
4
|
+
* compaction, then continues the task. State persists to session entries (branch rebuilds restore it).
|
|
11
5
|
*/
|
|
12
6
|
|
|
13
7
|
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';
|
|
8
|
+
import { decideCompaction, DEFAULT_COMPACTION_ECONOMICS, estimateRemainingRequests, MAX_BOUNDARY_SAMPLES, nativeCompactionFeasible, resolveCacheRatioFromCost, type CompactionDecision } from './compact-economics-core.ts';
|
|
23
9
|
import type { TodoItem } from './todo-core.ts';
|
|
24
10
|
import type { TodoCompletionSource } from './todos-service.ts';
|
|
25
11
|
import type { OnlineContextCompactConfig } from './efficiency-config-core.ts';
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
efficiencyLogPath,
|
|
29
|
-
resolveSessionRoot,
|
|
30
|
-
} from './efficiency-store.ts';
|
|
12
|
+
import { appendEfficiencyLog, efficiencyLogPath, resolveSessionRoot } from './efficiency-store.ts';
|
|
13
|
+
import { swallow } from './swallow.ts';
|
|
31
14
|
|
|
32
15
|
export const COMPACT_STATE_CUSTOM_TYPE = 'pi-herdr.efficiency-state';
|
|
33
16
|
export const COMPACTION_CONTINUE_TYPE = 'pi-herdr.compaction-continue';
|
|
34
|
-
|
|
35
|
-
export const
|
|
17
|
+
/** Shown to the model after a successful OCC. Names the abort so it is not retried as a failure. */
|
|
18
|
+
export const COMPACTION_CONTINUE_TEXT =
|
|
19
|
+
'Online context compaction finished. The preceding abort ("This operation was aborted" / "Operation aborted") was this compaction aborting the in-flight turn on purpose — not a model failure, not a user cancel, and not a reason to retry that call. Active tasks are preserved. Continue the remaining work.';
|
|
20
|
+
/**
|
|
21
|
+
* Cross-session compaction markers: OCC's abort lands in the child transcript as an assistant
|
|
22
|
+
* message with stopReason 'error', which a master's poller would read as a settled turn. The
|
|
23
|
+
* inflight/settled pair gives it a "do not settle" window pane state cannot provide (pane shows idle).
|
|
24
|
+
*/
|
|
25
|
+
export const COMPACTION_INFLIGHT_TYPE = 'pi-herdr.compaction-inflight';
|
|
26
|
+
export const COMPACTION_SETTLED_TYPE = 'pi-herdr.compaction-settled';
|
|
27
|
+
const DEFAULT_KEEP_RECENT_TOKENS = 20_000;
|
|
28
|
+
const DEFAULT_MEMO_TOKENS = 1_000;
|
|
36
29
|
|
|
37
|
-
|
|
30
|
+
/** Fields persisted in the state marker; `version: 1` gates restores. */
|
|
31
|
+
interface CoordinatorState {
|
|
38
32
|
version: 1;
|
|
39
33
|
epoch: number;
|
|
40
34
|
completedBoundaryRequestCounts: number[];
|
|
@@ -45,9 +39,24 @@ export interface CoordinatorState {
|
|
|
45
39
|
positiveContextDeltaCount: number;
|
|
46
40
|
lastContextTokens: number | null;
|
|
47
41
|
currentBoundaryRequestCount: number;
|
|
42
|
+
consecutiveCompactionFailures: number;
|
|
43
|
+
/** P1-3: turn-end compact decisions to skip after a failure (each abort costs an in-flight turn). */
|
|
44
|
+
compactBackoffTurnEnds: number;
|
|
48
45
|
}
|
|
49
46
|
|
|
50
|
-
|
|
47
|
+
const NUMERIC_STATE_FIELDS = [
|
|
48
|
+
'epoch',
|
|
49
|
+
'carriedDebtTokens',
|
|
50
|
+
'cacheDebtRepaymentTokens',
|
|
51
|
+
'priorCompactionCount',
|
|
52
|
+
'positiveContextDeltaTotal',
|
|
53
|
+
'positiveContextDeltaCount',
|
|
54
|
+
'currentBoundaryRequestCount',
|
|
55
|
+
'consecutiveCompactionFailures',
|
|
56
|
+
'compactBackoffTurnEnds',
|
|
57
|
+
] as const satisfies readonly (keyof CoordinatorState)[];
|
|
58
|
+
|
|
59
|
+
function initialCoordinatorState(): CoordinatorState {
|
|
51
60
|
return {
|
|
52
61
|
version: 1,
|
|
53
62
|
epoch: 0,
|
|
@@ -59,32 +68,52 @@ export function initialCoordinatorState(): CoordinatorState {
|
|
|
59
68
|
positiveContextDeltaCount: 0,
|
|
60
69
|
lastContextTokens: null,
|
|
61
70
|
currentBoundaryRequestCount: 0,
|
|
71
|
+
consecutiveCompactionFailures: 0,
|
|
72
|
+
compactBackoffTurnEnds: 0,
|
|
62
73
|
};
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
export function restoreCoordinatorState(entries: readonly unknown[]): CoordinatorState {
|
|
66
|
-
|
|
77
|
+
const base = initialCoordinatorState();
|
|
78
|
+
if (!Array.isArray(entries)) return base;
|
|
67
79
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
68
80
|
const entry = entries[i] as { type?: string; customType?: string; data?: unknown };
|
|
69
|
-
if (entry
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
}
|
|
81
|
+
if (entry?.type !== 'custom' || entry.customType !== COMPACT_STATE_CUSTOM_TYPE) continue;
|
|
82
|
+
const d = entry.data as Partial<CoordinatorState> | null | undefined;
|
|
83
|
+
if (!d || d.version !== 1 || !Array.isArray(d.completedBoundaryRequestCounts)) continue;
|
|
84
|
+
for (const field of NUMERIC_STATE_FIELDS) {
|
|
85
|
+
const value = d[field];
|
|
86
|
+
if (typeof value === 'number') base[field] = value;
|
|
85
87
|
}
|
|
88
|
+
if (typeof d.lastContextTokens === 'number') base.lastContextTokens = d.lastContextTokens;
|
|
89
|
+
base.completedBoundaryRequestCounts = [...d.completedBoundaryRequestCounts];
|
|
90
|
+
return base;
|
|
86
91
|
}
|
|
87
|
-
return
|
|
92
|
+
return base;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function averageContextIncrement(state: CoordinatorState): number | null {
|
|
96
|
+
return state.positiveContextDeltaCount > 0
|
|
97
|
+
? state.positiveContextDeltaTotal / state.positiveContextDeltaCount
|
|
98
|
+
: null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function remainingTodos(todos: readonly TodoItem[]): readonly TodoItem[] {
|
|
102
|
+
return todos.filter((it) => it.status !== 'completed' && it.status !== 'abandoned');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** P1-3: bound the payload — a huge open-task list inflates the summary request itself. */
|
|
106
|
+
function compactionInstructions(todos: readonly TodoItem[]): string {
|
|
107
|
+
const remaining = remainingTodos(todos);
|
|
108
|
+
const taskLines = remaining
|
|
109
|
+
.slice(0, 25)
|
|
110
|
+
.map((it) => `- [${it.status}] ${it.content.slice(0, 160)}${it.blocker ? ` (waiting on: ${it.blocker.slice(0, 80)})` : ''}`)
|
|
111
|
+
.join('\n');
|
|
112
|
+
const elided = remaining.length > 25 ? `\n… (+${remaining.length - 25} more, elided)` : '';
|
|
113
|
+
return [
|
|
114
|
+
'Preserve completed work, verification results, important decisions, and remaining work.',
|
|
115
|
+
remaining.length > 0 ? `Active/remaining tasks to preserve:\n${taskLines}${elided}` : 'All listed tasks are completed.',
|
|
116
|
+
].join('\n\n');
|
|
88
117
|
}
|
|
89
118
|
|
|
90
119
|
export class CompactCoordinator {
|
|
@@ -95,19 +124,18 @@ export class CompactCoordinator {
|
|
|
95
124
|
pendingBoundaryCompleted = false;
|
|
96
125
|
|
|
97
126
|
recordBoundaryCompleted(count: number, source?: TodoCompletionSource): void {
|
|
98
|
-
// Only count
|
|
127
|
+
// Only model tool calls count, and the sample is the requests spent reaching this boundary.
|
|
99
128
|
if (source !== 'tool' || count <= 0) return;
|
|
100
129
|
this.pendingBoundaryCompleted = true;
|
|
101
|
-
if (this.state.currentBoundaryRequestCount
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
this.state.currentBoundaryRequestCount = 0;
|
|
130
|
+
if (this.state.currentBoundaryRequestCount <= 0) return;
|
|
131
|
+
this.state.completedBoundaryRequestCounts.push(this.state.currentBoundaryRequestCount);
|
|
132
|
+
if (this.state.completedBoundaryRequestCounts.length > MAX_BOUNDARY_SAMPLES) {
|
|
133
|
+
this.state.completedBoundaryRequestCounts.splice(
|
|
134
|
+
0,
|
|
135
|
+
this.state.completedBoundaryRequestCounts.length - MAX_BOUNDARY_SAMPLES,
|
|
136
|
+
);
|
|
110
137
|
}
|
|
138
|
+
this.state.currentBoundaryRequestCount = 0;
|
|
111
139
|
}
|
|
112
140
|
|
|
113
141
|
onBeforeProviderRequest(contextTokens: number): void {
|
|
@@ -117,9 +145,7 @@ export class CompactCoordinator {
|
|
|
117
145
|
0,
|
|
118
146
|
this.state.carriedDebtTokens - this.state.cacheDebtRepaymentTokens,
|
|
119
147
|
);
|
|
120
|
-
if (this.state.carriedDebtTokens === 0)
|
|
121
|
-
this.state.cacheDebtRepaymentTokens = 0;
|
|
122
|
-
}
|
|
148
|
+
if (this.state.carriedDebtTokens === 0) this.state.cacheDebtRepaymentTokens = 0;
|
|
123
149
|
}
|
|
124
150
|
if (this.state.lastContextTokens !== null && contextTokens > this.state.lastContextTokens) {
|
|
125
151
|
this.state.positiveContextDeltaTotal += contextTokens - this.state.lastContextTokens;
|
|
@@ -129,29 +155,22 @@ export class CompactCoordinator {
|
|
|
129
155
|
}
|
|
130
156
|
|
|
131
157
|
onInput(event: { text?: string; source?: string; streamingBehavior?: string }): void {
|
|
132
|
-
// Ignore
|
|
133
|
-
if (event.source === 'extension' || event.text?.startsWith('CORRECTION:'))
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
158
|
+
// Ignore messages dispatched by extensions (D96 reminders, pipe injections, followUps).
|
|
159
|
+
if (event.source === 'extension' || event.text?.startsWith('CORRECTION:')) return;
|
|
136
160
|
|
|
137
161
|
const fromHuman =
|
|
138
162
|
event.source === 'interactive' ||
|
|
139
163
|
event.source === 'rpc' ||
|
|
140
164
|
event.source === undefined;
|
|
165
|
+
if (!fromHuman) return;
|
|
141
166
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
}
|
|
167
|
+
this.selectedCompaction = null;
|
|
168
|
+
this.intentionalAbort = false;
|
|
169
|
+
this.pendingBoundaryCompleted = false;
|
|
170
|
+
// completedBoundaryRequestCounts deliberately survives: it measures requests-per-boundary pacing,
|
|
171
|
+
// which holds across human turns — clearing it pinned expectedRemainingRequests at 1 every prompt.
|
|
172
|
+
this.state.carriedDebtTokens = 0;
|
|
173
|
+
this.state.epoch++;
|
|
155
174
|
}
|
|
156
175
|
|
|
157
176
|
onTurnEnd(opts: {
|
|
@@ -165,8 +184,7 @@ export class CompactCoordinator {
|
|
|
165
184
|
|
|
166
185
|
if (!boundary || this.selectedCompaction) return;
|
|
167
186
|
if (!opts.config.enabled) return;
|
|
168
|
-
|
|
169
|
-
// Safety guard: if user typed something that is queued, don't abort!
|
|
187
|
+
// Safety guard: never abort while the user has a queued message.
|
|
170
188
|
if (opts.ctx.hasPendingMessages?.()) return;
|
|
171
189
|
|
|
172
190
|
const usage = opts.ctx.getContextUsage?.();
|
|
@@ -175,22 +193,10 @@ export class CompactCoordinator {
|
|
|
175
193
|
? usage.tokens
|
|
176
194
|
: this.state.lastContextTokens ?? 0;
|
|
177
195
|
const contextWindowTokens = usage?.contextWindow ?? opts.ctx.model?.contextWindow ?? null;
|
|
196
|
+
// System prompt is re-sent verbatim, so it is never part of the compressible archive; ~4 chars/token.
|
|
178
197
|
const fixedTokens = Math.ceil(Buffer.byteLength(opts.ctx.getSystemPrompt?.() ?? '') / 4);
|
|
179
198
|
const keepRecentTokens = opts.config.keepRecentTokens ?? DEFAULT_KEEP_RECENT_TOKENS;
|
|
180
199
|
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
200
|
|
|
195
201
|
const decision = decideCompaction({
|
|
196
202
|
writeTokens: contextTokens,
|
|
@@ -198,13 +204,17 @@ export class CompactCoordinator {
|
|
|
198
204
|
memoTokens: DEFAULT_MEMO_TOKENS,
|
|
199
205
|
contextTokens,
|
|
200
206
|
completedBoundaryRequestCounts: this.state.completedBoundaryRequestCounts,
|
|
201
|
-
remainingBoundaries,
|
|
202
|
-
averageContextTokenIncrement,
|
|
207
|
+
remainingBoundaries: remainingTodos(opts.todos).length,
|
|
208
|
+
averageContextTokenIncrement: averageContextIncrement(this.state),
|
|
203
209
|
contextWindowTokens,
|
|
204
210
|
priorCompactionCount: this.state.priorCompactionCount,
|
|
205
211
|
carriedDebtTokens: this.state.carriedDebtTokens,
|
|
206
212
|
cacheDebtRepaymentTokens: this.state.cacheDebtRepaymentTokens,
|
|
207
|
-
cacheWriteReadRatio:
|
|
213
|
+
cacheWriteReadRatio: resolveCacheRatioFromCost(
|
|
214
|
+
opts.config.cacheWriteReadRatio,
|
|
215
|
+
opts.ctx.model?.cost,
|
|
216
|
+
{ provider: opts.ctx.model?.provider, modelId: opts.ctx.model?.id },
|
|
217
|
+
),
|
|
208
218
|
economics: {
|
|
209
219
|
...DEFAULT_COMPACTION_ECONOMICS,
|
|
210
220
|
firstCompactionRequestScale: opts.config.firstCompactionRequestScale,
|
|
@@ -212,24 +222,34 @@ export class CompactCoordinator {
|
|
|
212
222
|
},
|
|
213
223
|
});
|
|
214
224
|
|
|
215
|
-
if (decision.compact) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
225
|
+
if (!decision.compact) {
|
|
226
|
+
if (opts.config.logEnabled) this.logDecision(opts.ctx, decision);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// P1-3 backoff: after a failed summarization, skip compact decisions for a bounded window —
|
|
231
|
+
// each attempt aborts an in-flight turn, so a doomed retry burns turns. (The token-cap failure
|
|
232
|
+
// class is gone since pi 1.4.0 uncapped summary output; transient timeouts/aborts remain.)
|
|
233
|
+
if (this.state.compactBackoffTurnEnds > 0) {
|
|
234
|
+
this.state.compactBackoffTurnEnds--;
|
|
235
|
+
if (opts.config.logEnabled) {
|
|
236
|
+
this.logDecision(opts.ctx, { ...decision, compact: false, reason: 'failure_backoff' });
|
|
224
237
|
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
225
240
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
opts.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
241
|
+
const branch = opts.ctx.sessionManager?.getBranch?.() ?? [];
|
|
242
|
+
if (!nativeCompactionFeasible(branch, keepRecentTokens)) {
|
|
243
|
+
if (opts.config.logEnabled) {
|
|
244
|
+
this.logDecision(opts.ctx, { ...decision, compact: false, reason: 'native_not_compactable' });
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
232
247
|
}
|
|
248
|
+
|
|
249
|
+
this.selectedCompaction = decision;
|
|
250
|
+
this.intentionalAbort = true;
|
|
251
|
+
opts.cancelReminder?.();
|
|
252
|
+
opts.ctx.abort();
|
|
233
253
|
}
|
|
234
254
|
|
|
235
255
|
async onAgentSettled(opts: {
|
|
@@ -241,159 +261,157 @@ export class CompactCoordinator {
|
|
|
241
261
|
onBeforeCompact?: (sessionRoot: string, ctx: ExtensionContext) => Promise<void>;
|
|
242
262
|
}): Promise<void> {
|
|
243
263
|
if (!this.selectedCompaction || !opts.ctx.isIdle()) return;
|
|
264
|
+
|
|
265
|
+
// Announce the compaction window before the summary request starts: the supervising master
|
|
266
|
+
// polls this file, and its observation window is shorter than a large-context compaction.
|
|
267
|
+
this.appendMarker(opts.pi, COMPACTION_INFLIGHT_TYPE, { at: Date.now() });
|
|
244
268
|
const decision = this.selectedCompaction;
|
|
245
269
|
this.selectedCompaction = null;
|
|
246
270
|
|
|
247
271
|
opts.cancelReminder?.();
|
|
248
272
|
this.compactionInFlight = true;
|
|
249
273
|
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
274
|
+
const sessionRoot = resolveSessionRoot(
|
|
275
|
+
opts.ctx.sessionManager?.getSessionDir?.(),
|
|
276
|
+
opts.ctx.sessionManager?.getSessionId?.(),
|
|
277
|
+
);
|
|
254
278
|
if (sessionRoot && opts.onBeforeCompact) {
|
|
255
279
|
try {
|
|
256
280
|
await opts.onBeforeCompact(sessionRoot, opts.ctx);
|
|
257
281
|
} catch {
|
|
258
|
-
/* ignore batch packing errors before compaction */
|
|
259
282
|
}
|
|
260
283
|
}
|
|
261
284
|
|
|
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
285
|
const startMs = Date.now();
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
286
|
+
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();
|
|
281
294
|
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
},
|
|
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;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private finishCompaction(
|
|
308
|
+
opts: { ctx: ExtensionContext; pi: ExtensionAPI; config: OnlineContextCompactConfig },
|
|
309
|
+
decision: CompactionDecision,
|
|
310
|
+
startMs: number,
|
|
311
|
+
compaction: { summary?: string } | undefined,
|
|
312
|
+
): void {
|
|
313
|
+
this.state.priorCompactionCount++;
|
|
314
|
+
this.state.carriedDebtTokens = decision.writeTokens * (decision.incrementalCacheCostRatio ?? 0);
|
|
315
|
+
this.state.cacheDebtRepaymentTokens = Math.max(0, decision.archiveTokens - decision.memoTokens);
|
|
316
|
+
this.state.epoch++;
|
|
317
|
+
this.state.consecutiveCompactionFailures = 0;
|
|
318
|
+
this.state.compactBackoffTurnEnds = 0;
|
|
319
|
+
this.appendMarker(opts.pi, COMPACT_STATE_CUSTOM_TYPE, this.state);
|
|
320
|
+
|
|
321
|
+
if (opts.config.logEnabled) {
|
|
322
|
+
this.logCompact(opts.ctx, {
|
|
323
|
+
decision: decision.reason,
|
|
324
|
+
writeTokens: decision.writeTokens,
|
|
325
|
+
archiveTokens: decision.archiveTokens,
|
|
326
|
+
savedTokens: Math.max(0, decision.archiveTokens - decision.memoTokens),
|
|
327
|
+
breakevenRequests: decision.breakevenRequests,
|
|
328
|
+
expectedRemainingRequests: decision.expectedRemainingRequests,
|
|
329
|
+
carriedDebtTokens: this.state.carriedDebtTokens,
|
|
330
|
+
summaryTokens: typeof compaction?.summary === 'string' ? Math.ceil(compaction.summary.length / 4) : 0,
|
|
331
|
+
priorCompactionCount: this.state.priorCompactionCount,
|
|
332
|
+
durationMs: Date.now() - startMs,
|
|
395
333
|
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
this.sendContinuation(
|
|
337
|
+
opts.pi,
|
|
338
|
+
COMPACTION_CONTINUE_TEXT,
|
|
339
|
+
false,
|
|
340
|
+
);
|
|
341
|
+
// Settled marker last: the master's poller releases its hold as soon as it appears.
|
|
342
|
+
this.appendMarker(opts.pi, COMPACTION_SETTLED_TYPE, { outcome: 'completed', at: Date.now() });
|
|
343
|
+
this.compactionInFlight = false;
|
|
344
|
+
this.intentionalAbort = false;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private failCompaction(
|
|
348
|
+
opts: { ctx: ExtensionContext; pi: ExtensionAPI; config: OnlineContextCompactConfig },
|
|
349
|
+
decision: CompactionDecision,
|
|
350
|
+
startMs: number,
|
|
351
|
+
error: unknown,
|
|
352
|
+
): void {
|
|
353
|
+
this.compactionInFlight = false;
|
|
354
|
+
this.intentionalAbort = false;
|
|
355
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
356
|
+
// 'Compaction cancelled' / AbortError mean the user (or pi) cancelled on purpose: telemetry keeps
|
|
357
|
+
// that apart from real failures, and a cancel must NOT trigger a continuation turn.
|
|
358
|
+
const cancelled =
|
|
359
|
+
error instanceof Error &&
|
|
360
|
+
(error.name === 'AbortError' || error.message === 'Compaction cancelled');
|
|
361
|
+
|
|
362
|
+
// Release the cross-session hold in every outcome — the cancel path returns before continuing.
|
|
363
|
+
this.appendMarker(opts.pi, COMPACTION_SETTLED_TYPE, {
|
|
364
|
+
outcome: cancelled ? 'cancelled' : 'failed',
|
|
365
|
+
at: Date.now(),
|
|
396
366
|
});
|
|
367
|
+
if (opts.config.logEnabled) {
|
|
368
|
+
this.logCompact(opts.ctx, {
|
|
369
|
+
event: 'compaction-failed',
|
|
370
|
+
decision: decision.reason,
|
|
371
|
+
cancelled,
|
|
372
|
+
error: message,
|
|
373
|
+
consecutiveCompactionFailures: this.state.consecutiveCompactionFailures,
|
|
374
|
+
durationMs: Date.now() - startMs,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
if (cancelled) return;
|
|
378
|
+
|
|
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
|
+
// Persist the bumped counters: state is restored from this marker, so a restart without it
|
|
383
|
+
// would reset the P1-3 backoff window and retry the doomed compaction immediately.
|
|
384
|
+
this.appendMarker(opts.pi, COMPACT_STATE_CUSTOM_TYPE, this.state);
|
|
385
|
+
|
|
386
|
+
// OCC aborted the turn on purpose, so a failed compaction would leave the session parked on an
|
|
387
|
+
// aborted message; the notice must be visible rather than a silent failure.
|
|
388
|
+
const approxTokens = this.state.lastContextTokens;
|
|
389
|
+
this.sendContinuation(
|
|
390
|
+
opts.pi,
|
|
391
|
+
`Context compaction FAILED (${message}). `
|
|
392
|
+
+ `Context ≈ ${approxTokens != null ? `${Math.round(approxTokens / 1000)}K tokens` : 'near the window limit'}; `
|
|
393
|
+
+ `OCC backs off for the next ${this.state.compactBackoffTurnEnds} opportunities. `
|
|
394
|
+
+ 'If this repeats, run /compact manually, prune large outputs, or restart the session.',
|
|
395
|
+
true,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Best-effort session entry write: onAgentSettled runs detached, so a full disk must not become an
|
|
400
|
+
* unhandled rejection (a missing marker only delays one settlement notice). */
|
|
401
|
+
private appendMarker(pi: ExtensionAPI, customType: string, data: unknown): void {
|
|
402
|
+
try {
|
|
403
|
+
pi.appendEntry(customType, data);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
swallow(`compact.append-${customType}`, err);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Resume the task after compaction; must never throw into the detached settled handler. */
|
|
410
|
+
private sendContinuation(pi: ExtensionAPI, content: string, display: boolean): void {
|
|
411
|
+
try {
|
|
412
|
+
pi.sendMessage({ customType: COMPACTION_CONTINUE_TYPE, content, display }, { triggerTurn: true });
|
|
413
|
+
} catch {
|
|
414
|
+
}
|
|
397
415
|
}
|
|
398
416
|
|
|
399
417
|
rebuildFromBranch(entries: readonly unknown[]): void {
|
|
@@ -406,49 +424,49 @@ export class CompactCoordinator {
|
|
|
406
424
|
|
|
407
425
|
getRemainingHorizon(remainingBoundaries = 3, contextWindowTokens: number | null = null): number {
|
|
408
426
|
const counts = this.state.completedBoundaryRequestCounts;
|
|
409
|
-
if (counts.length === 0) return 4;
|
|
410
|
-
|
|
427
|
+
if (counts.length === 0) return 4; // No samples yet: conservative default horizon.
|
|
411
428
|
const est = estimateRemainingRequests({
|
|
412
429
|
completedBoundaryRequestCounts: counts,
|
|
413
430
|
remainingBoundaries,
|
|
414
|
-
scale:
|
|
415
|
-
standardDeviationK:
|
|
431
|
+
scale: DEFAULT_COMPACTION_ECONOMICS.remainingRequestScale,
|
|
432
|
+
standardDeviationK: DEFAULT_COMPACTION_ECONOMICS.remainingRequestStddevK,
|
|
416
433
|
contextTokens: this.state.lastContextTokens ?? 0,
|
|
417
434
|
contextWindowTokens,
|
|
418
|
-
averageContextTokenIncrement:
|
|
419
|
-
this.state.positiveContextDeltaCount > 0
|
|
420
|
-
? this.state.positiveContextDeltaTotal / this.state.positiveContextDeltaCount
|
|
421
|
-
: null,
|
|
435
|
+
averageContextTokenIncrement: averageContextIncrement(this.state),
|
|
422
436
|
});
|
|
423
437
|
return Math.max(1, est.expectedRemainingRequests);
|
|
424
438
|
}
|
|
425
439
|
|
|
426
|
-
|
|
427
|
-
|
|
440
|
+
/** Append one compact.jsonl record (shared envelope); logging is always best-effort. */
|
|
441
|
+
private logCompact(ctx: ExtensionContext, fields: Record<string, unknown>): void {
|
|
428
442
|
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
429
|
-
const root = resolveSessionRoot(
|
|
443
|
+
const root = resolveSessionRoot(ctx.sessionManager?.getSessionDir?.(), sessionId);
|
|
430
444
|
if (!root) return;
|
|
431
|
-
|
|
432
|
-
void appendEfficiencyLog(logPath, {
|
|
445
|
+
void appendEfficiencyLog(efficiencyLogPath(root, 'compact'), {
|
|
433
446
|
schema: 'pier-efficiency/1',
|
|
434
447
|
mechanism: 'onlineContextCompact',
|
|
435
|
-
event: 'decision',
|
|
436
448
|
ts: new Date().toISOString(),
|
|
437
449
|
sessionId: sessionId ?? 'unknown',
|
|
438
450
|
epoch: this.state.epoch,
|
|
451
|
+
...fields,
|
|
452
|
+
}).catch(() => {});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private logDecision(ctx: ExtensionContext, decision: CompactionDecision): void {
|
|
456
|
+
this.logCompact(ctx, {
|
|
457
|
+
event: 'decision',
|
|
439
458
|
decision: decision.reason,
|
|
440
459
|
compact: decision.compact,
|
|
441
460
|
writeTokens: decision.writeTokens,
|
|
442
461
|
archiveTokens: decision.archiveTokens,
|
|
443
462
|
breakevenRequests: decision.breakevenRequests,
|
|
444
463
|
expectedRemainingRequests: decision.expectedRemainingRequests,
|
|
445
|
-
//
|
|
446
|
-
// run was un-diagnosable from logs alone (which price table? which family?).
|
|
464
|
+
// Without resolvedRatio/model a cache_ratio_unavailable run is undiagnosable from logs alone.
|
|
447
465
|
resolvedRatio: decision.cacheWriteReadRatio,
|
|
448
466
|
incrementalCacheCostRatio: decision.incrementalCacheCostRatio,
|
|
449
467
|
remainingBoundaries: decision.remainingBoundaries,
|
|
450
468
|
model: ctx.model?.id ?? undefined,
|
|
451
469
|
provider: ctx.model?.provider ?? undefined,
|
|
452
|
-
})
|
|
470
|
+
});
|
|
453
471
|
}
|
|
454
472
|
}
|