pi-pier 0.2.7 → 0.3.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 +26 -12
- package/package.json +8 -3
- package/schemas/efficiency-config.schema.json +77 -0
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +11 -0
- package/src/ansi-text.ts +134 -0
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +139 -88
- package/src/bootstrap.ts +13 -14
- package/src/compact-coordinator.ts +471 -0
- package/src/compact-economics-core.ts +328 -0
- package/src/config-catalog-core.ts +332 -0
- package/src/config-command.ts +388 -0
- package/src/dashboard-command.ts +108 -0
- package/src/efficiency-config-core.ts +464 -0
- package/src/efficiency-store.ts +299 -0
- package/src/focus-poller.ts +223 -0
- package/src/gc-core.ts +77 -18
- package/src/herdr-client.ts +284 -154
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +29 -16
- package/src/index-master.ts +29 -19
- package/src/index-notices.ts +35 -11
- package/src/index-pipe.ts +17 -7
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +7 -9
- package/src/index.ts +425 -388
- package/src/jev-client.ts +141 -0
- package/src/jev-core.ts +431 -0
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +98 -37
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +200 -0
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +100 -0
- package/src/pipe-channel.ts +65 -52
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +12 -12
- package/src/plugins/heat-plan.ts +158 -0
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +237 -123
- package/src/{core → plugins}/todo.ts +75 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +322 -0
- package/src/reducer-invoker.ts +419 -0
- package/src/renderers.ts +187 -0
- package/src/role-loader.ts +35 -29
- package/src/role-manifest.ts +23 -32
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -43
- package/src/session-tail.ts +102 -23
- package/src/settle-wake-core.ts +55 -22
- package/src/slim-frame.ts +29 -124
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +24 -30
- package/src/subagent-core.ts +256 -119
- package/src/subagent-gc.ts +131 -69
- package/src/subagent-poller.ts +401 -12
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +651 -28
- package/src/swallow.ts +65 -0
- package/src/terminal-core.ts +160 -69
- package/src/todo-core.ts +62 -113
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +30 -48
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +27 -35
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +55 -25
- package/src/core/subagent.ts +0 -839
- package/src/git-adapter.ts +0 -104
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -110
- package/src/subagent-poll-loop.ts +0 -285
- package/src/subagent-port.ts +0 -27
- package/src/subagent-scope.ts +0 -39
- package/src/subagent-session-io.ts +0 -147
package/src/bootstrap.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bootstrap the master Cordis tree (D78 mount topology, D80 ecosystem adoption, D81 split).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* loader or HMR; this module and subagent-scope.ts are master-only dynamic imports.
|
|
9
|
-
*
|
|
10
|
-
* Core modules will gradually become loader entries; manual mounts remain non-hot-reloaded (D80③).
|
|
4
|
+
* Loads group, timer and HMR plugins in order so declarations resolve, with HMR as a development-only
|
|
5
|
+
* path (`--expose-internals` plus PIER_HMR=1); production has no watcher. Workers bypass this module
|
|
6
|
+
* (C3/D81): short-lived processes mount manually, and this module plus subagent-poller.ts are the
|
|
7
|
+
* master-only dynamic imports.
|
|
11
8
|
*/
|
|
12
9
|
import { Context } from '@deepseek-ai/cordis';
|
|
13
10
|
import Loader from '@deepseek-ai/cordis-plugin-loader';
|
|
@@ -15,6 +12,7 @@ import Group from '@deepseek-ai/cordis-plugin-group';
|
|
|
15
12
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
16
13
|
import * as path from 'node:path';
|
|
17
14
|
import { DisposeLedger } from './ledger.ts';
|
|
15
|
+
import { pierOption } from './pier-options.ts';
|
|
18
16
|
|
|
19
17
|
export interface BootstrapHooks {
|
|
20
18
|
onDispose?: () => void;
|
|
@@ -23,9 +21,8 @@ export interface BootstrapHooks {
|
|
|
23
21
|
export interface CordisApp {
|
|
24
22
|
/** Root owns subagent scopes and lifecycle effects. */
|
|
25
23
|
root: Context;
|
|
26
|
-
/** False
|
|
24
|
+
/** False means the loader was not mounted (bare-tree fallback). */
|
|
27
25
|
loaderReady: boolean;
|
|
28
|
-
/** HMR is enabled only in the development posture. */
|
|
29
26
|
hmrActive: boolean;
|
|
30
27
|
/** Shared ledger compensates HMR disposal and handles session_shutdown cleanup. */
|
|
31
28
|
ledger: DisposeLedger;
|
|
@@ -44,7 +41,7 @@ export async function createCordisApp(hooks: BootstrapHooks = {}): Promise<Cordi
|
|
|
44
41
|
if (hooks.onDispose) {
|
|
45
42
|
root.effect(() => () => { hooks.onDispose?.(); }, 'session-root');
|
|
46
43
|
}
|
|
47
|
-
// Resolve relative entry names (./
|
|
44
|
+
// Resolve relative entry names (./plugins/...) from this module's directory.
|
|
48
45
|
root.baseUrl = pathToFileURL(path.dirname(fileURLToPath(import.meta.url))).href + '/';
|
|
49
46
|
|
|
50
47
|
let loaderReady = false;
|
|
@@ -60,7 +57,8 @@ export async function createCordisApp(hooks: BootstrapHooks = {}): Promise<Cordi
|
|
|
60
57
|
console.error(`[pi-herdr] cordis loader degraded (bare tree): ${err instanceof Error ? err.message : String(err)}`);
|
|
61
58
|
}
|
|
62
59
|
|
|
63
|
-
|
|
60
|
+
// B10: canonical PIER_HMR, legacy PI_HERDR_HMR.
|
|
61
|
+
if (loaderReady && detectExposeInternals() && pierOption('PIER_HMR') === '1') {
|
|
64
62
|
try {
|
|
65
63
|
const withLoader = root as Context & {
|
|
66
64
|
loader?: { create: (o: { name: string; config?: unknown }) => Promise<unknown> };
|
|
@@ -76,9 +74,10 @@ export async function createCordisApp(hooks: BootstrapHooks = {}): Promise<Cordi
|
|
|
76
74
|
});
|
|
77
75
|
hmrActive = true;
|
|
78
76
|
console.error('[pi-herdr] hmr active (dev posture: --expose-internals + PI_HERDR_HMR=1)');
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
77
|
+
// HMR already disposes old fiber effects; disposeKey additionally retires the older pi-surface
|
|
78
|
+
// generations while keeping generations registered after the reload boundary alive (D80⑤/d87).
|
|
79
|
+
// Cordis' own HMR boundary event (not part of pi's typed Events map).
|
|
80
|
+
(root as unknown as { on(name: string, fn: (reloads: unknown) => void): void }).on('hmr/reload', (reloads: unknown) => {
|
|
82
81
|
try {
|
|
83
82
|
const files: string[] = [];
|
|
84
83
|
for (const r of (reloads as Map<unknown, { filename?: string }>).values()) {
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
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).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
8
|
+
import { decideCompaction, DEFAULT_COMPACTION_ECONOMICS, estimateRemainingRequests, MAX_BOUNDARY_SAMPLES, nativeCompactionFeasible, resolveCacheRatioFromCost, type CompactionDecision } from './compact-economics-core.ts';
|
|
9
|
+
import type { TodoItem } from './todo-core.ts';
|
|
10
|
+
import type { TodoCompletionSource } from './todos-service.ts';
|
|
11
|
+
import type { OnlineContextCompactConfig } from './efficiency-config-core.ts';
|
|
12
|
+
import { appendEfficiencyLog, efficiencyLogPath, resolveSessionRoot } from './efficiency-store.ts';
|
|
13
|
+
import { swallow } from './swallow.ts';
|
|
14
|
+
|
|
15
|
+
export const COMPACT_STATE_CUSTOM_TYPE = 'pi-herdr.efficiency-state';
|
|
16
|
+
export const COMPACTION_CONTINUE_TYPE = 'pi-herdr.compaction-continue';
|
|
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;
|
|
29
|
+
|
|
30
|
+
/** Fields persisted in the state marker; `version: 1` gates restores. */
|
|
31
|
+
interface CoordinatorState {
|
|
32
|
+
version: 1;
|
|
33
|
+
epoch: number;
|
|
34
|
+
completedBoundaryRequestCounts: number[];
|
|
35
|
+
carriedDebtTokens: number;
|
|
36
|
+
cacheDebtRepaymentTokens: number;
|
|
37
|
+
priorCompactionCount: number;
|
|
38
|
+
positiveContextDeltaTotal: number;
|
|
39
|
+
positiveContextDeltaCount: number;
|
|
40
|
+
lastContextTokens: number | null;
|
|
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;
|
|
45
|
+
}
|
|
46
|
+
|
|
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 {
|
|
60
|
+
return {
|
|
61
|
+
version: 1,
|
|
62
|
+
epoch: 0,
|
|
63
|
+
completedBoundaryRequestCounts: [],
|
|
64
|
+
carriedDebtTokens: 0,
|
|
65
|
+
cacheDebtRepaymentTokens: 0,
|
|
66
|
+
priorCompactionCount: 0,
|
|
67
|
+
positiveContextDeltaTotal: 0,
|
|
68
|
+
positiveContextDeltaCount: 0,
|
|
69
|
+
lastContextTokens: null,
|
|
70
|
+
currentBoundaryRequestCount: 0,
|
|
71
|
+
consecutiveCompactionFailures: 0,
|
|
72
|
+
compactBackoffTurnEnds: 0,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function restoreCoordinatorState(entries: readonly unknown[]): CoordinatorState {
|
|
77
|
+
const base = initialCoordinatorState();
|
|
78
|
+
if (!Array.isArray(entries)) return base;
|
|
79
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
80
|
+
const entry = entries[i] as { type?: string; customType?: string; data?: unknown };
|
|
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;
|
|
87
|
+
}
|
|
88
|
+
if (typeof d.lastContextTokens === 'number') base.lastContextTokens = d.lastContextTokens;
|
|
89
|
+
base.completedBoundaryRequestCounts = [...d.completedBoundaryRequestCounts];
|
|
90
|
+
return base;
|
|
91
|
+
}
|
|
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');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class CompactCoordinator {
|
|
120
|
+
state: CoordinatorState = initialCoordinatorState();
|
|
121
|
+
compactionInFlight = false;
|
|
122
|
+
intentionalAbort = false;
|
|
123
|
+
selectedCompaction: CompactionDecision | null = null;
|
|
124
|
+
pendingBoundaryCompleted = false;
|
|
125
|
+
|
|
126
|
+
recordBoundaryCompleted(count: number, source?: TodoCompletionSource): void {
|
|
127
|
+
// Only model tool calls count, and the sample is the requests spent reaching this boundary.
|
|
128
|
+
if (source !== 'tool' || count <= 0) return;
|
|
129
|
+
this.pendingBoundaryCompleted = true;
|
|
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
|
+
);
|
|
137
|
+
}
|
|
138
|
+
this.state.currentBoundaryRequestCount = 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
onBeforeProviderRequest(contextTokens: number): void {
|
|
142
|
+
this.state.currentBoundaryRequestCount++;
|
|
143
|
+
if (this.state.carriedDebtTokens > 0 && this.state.cacheDebtRepaymentTokens > 0) {
|
|
144
|
+
this.state.carriedDebtTokens = Math.max(
|
|
145
|
+
0,
|
|
146
|
+
this.state.carriedDebtTokens - this.state.cacheDebtRepaymentTokens,
|
|
147
|
+
);
|
|
148
|
+
if (this.state.carriedDebtTokens === 0) this.state.cacheDebtRepaymentTokens = 0;
|
|
149
|
+
}
|
|
150
|
+
if (this.state.lastContextTokens !== null && contextTokens > this.state.lastContextTokens) {
|
|
151
|
+
this.state.positiveContextDeltaTotal += contextTokens - this.state.lastContextTokens;
|
|
152
|
+
this.state.positiveContextDeltaCount++;
|
|
153
|
+
}
|
|
154
|
+
this.state.lastContextTokens = contextTokens;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
onInput(event: { text?: string; source?: string; streamingBehavior?: string }): void {
|
|
158
|
+
// Ignore messages dispatched by extensions (D96 reminders, pipe injections, followUps).
|
|
159
|
+
if (event.source === 'extension' || event.text?.startsWith('CORRECTION:')) return;
|
|
160
|
+
|
|
161
|
+
const fromHuman =
|
|
162
|
+
event.source === 'interactive' ||
|
|
163
|
+
event.source === 'rpc' ||
|
|
164
|
+
event.source === undefined;
|
|
165
|
+
if (!fromHuman) return;
|
|
166
|
+
|
|
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++;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
onTurnEnd(opts: {
|
|
177
|
+
ctx: ExtensionContext;
|
|
178
|
+
todos: readonly TodoItem[];
|
|
179
|
+
config: OnlineContextCompactConfig;
|
|
180
|
+
cancelReminder?: () => void;
|
|
181
|
+
}): void {
|
|
182
|
+
const boundary = this.pendingBoundaryCompleted;
|
|
183
|
+
this.pendingBoundaryCompleted = false;
|
|
184
|
+
|
|
185
|
+
if (!boundary || this.selectedCompaction) return;
|
|
186
|
+
if (!opts.config.enabled) return;
|
|
187
|
+
// Safety guard: never abort while the user has a queued message.
|
|
188
|
+
if (opts.ctx.hasPendingMessages?.()) return;
|
|
189
|
+
|
|
190
|
+
const usage = opts.ctx.getContextUsage?.();
|
|
191
|
+
const contextTokens =
|
|
192
|
+
typeof usage?.tokens === 'number' && usage.tokens > 0
|
|
193
|
+
? usage.tokens
|
|
194
|
+
: this.state.lastContextTokens ?? 0;
|
|
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.
|
|
197
|
+
const fixedTokens = Math.ceil(Buffer.byteLength(opts.ctx.getSystemPrompt?.() ?? '') / 4);
|
|
198
|
+
const keepRecentTokens = opts.config.keepRecentTokens ?? DEFAULT_KEEP_RECENT_TOKENS;
|
|
199
|
+
const archiveTokens = Math.max(0, contextTokens - fixedTokens - keepRecentTokens);
|
|
200
|
+
|
|
201
|
+
const decision = decideCompaction({
|
|
202
|
+
writeTokens: contextTokens,
|
|
203
|
+
archiveTokens,
|
|
204
|
+
memoTokens: DEFAULT_MEMO_TOKENS,
|
|
205
|
+
contextTokens,
|
|
206
|
+
completedBoundaryRequestCounts: this.state.completedBoundaryRequestCounts,
|
|
207
|
+
remainingBoundaries: remainingTodos(opts.todos).length,
|
|
208
|
+
averageContextTokenIncrement: averageContextIncrement(this.state),
|
|
209
|
+
contextWindowTokens,
|
|
210
|
+
priorCompactionCount: this.state.priorCompactionCount,
|
|
211
|
+
carriedDebtTokens: this.state.carriedDebtTokens,
|
|
212
|
+
cacheDebtRepaymentTokens: this.state.cacheDebtRepaymentTokens,
|
|
213
|
+
cacheWriteReadRatio: resolveCacheRatioFromCost(
|
|
214
|
+
opts.config.cacheWriteReadRatio,
|
|
215
|
+
opts.ctx.model?.cost,
|
|
216
|
+
{ provider: opts.ctx.model?.provider, modelId: opts.ctx.model?.id },
|
|
217
|
+
),
|
|
218
|
+
economics: {
|
|
219
|
+
...DEFAULT_COMPACTION_ECONOMICS,
|
|
220
|
+
firstCompactionRequestScale: opts.config.firstCompactionRequestScale,
|
|
221
|
+
subsequentCompactionMargin: opts.config.subsequentCompactionMargin,
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
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 (token-cap class), skip compact decisions for a
|
|
231
|
+
// bounded window — each attempt aborts an in-flight turn, so a doomed retry burns turns.
|
|
232
|
+
if (this.state.compactBackoffTurnEnds > 0) {
|
|
233
|
+
this.state.compactBackoffTurnEnds--;
|
|
234
|
+
if (opts.config.logEnabled) {
|
|
235
|
+
this.logDecision(opts.ctx, { ...decision, compact: false, reason: 'failure_backoff' });
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const branch = opts.ctx.sessionManager?.getBranch?.() ?? [];
|
|
241
|
+
if (!nativeCompactionFeasible(branch, keepRecentTokens)) {
|
|
242
|
+
if (opts.config.logEnabled) {
|
|
243
|
+
this.logDecision(opts.ctx, { ...decision, compact: false, reason: 'native_not_compactable' });
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
this.selectedCompaction = decision;
|
|
249
|
+
this.intentionalAbort = true;
|
|
250
|
+
opts.cancelReminder?.();
|
|
251
|
+
opts.ctx.abort();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async onAgentSettled(opts: {
|
|
255
|
+
ctx: ExtensionContext;
|
|
256
|
+
todos: readonly TodoItem[];
|
|
257
|
+
pi: ExtensionAPI;
|
|
258
|
+
config: OnlineContextCompactConfig;
|
|
259
|
+
cancelReminder?: () => void;
|
|
260
|
+
onBeforeCompact?: (sessionRoot: string, ctx: ExtensionContext) => Promise<void>;
|
|
261
|
+
}): Promise<void> {
|
|
262
|
+
if (!this.selectedCompaction || !opts.ctx.isIdle()) return;
|
|
263
|
+
|
|
264
|
+
// Announce the compaction window before the summary request starts: the supervising master
|
|
265
|
+
// polls this file, and its observation window is shorter than a large-context compaction.
|
|
266
|
+
this.appendMarker(opts.pi, COMPACTION_INFLIGHT_TYPE, { at: Date.now() });
|
|
267
|
+
const decision = this.selectedCompaction;
|
|
268
|
+
this.selectedCompaction = null;
|
|
269
|
+
|
|
270
|
+
opts.cancelReminder?.();
|
|
271
|
+
this.compactionInFlight = true;
|
|
272
|
+
|
|
273
|
+
const sessionRoot = resolveSessionRoot(
|
|
274
|
+
opts.ctx.sessionManager?.getSessionDir?.(),
|
|
275
|
+
opts.ctx.sessionManager?.getSessionId?.(),
|
|
276
|
+
);
|
|
277
|
+
if (sessionRoot && opts.onBeforeCompact) {
|
|
278
|
+
try {
|
|
279
|
+
await opts.onBeforeCompact(sessionRoot, opts.ctx);
|
|
280
|
+
} catch {
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const startMs = Date.now();
|
|
285
|
+
const settled = Promise.withResolvers<void>();
|
|
286
|
+
opts.ctx.compact({
|
|
287
|
+
customInstructions: compactionInstructions(opts.todos),
|
|
288
|
+
onComplete: (compaction) => {
|
|
289
|
+
try {
|
|
290
|
+
this.finishCompaction(opts, decision, startMs, compaction);
|
|
291
|
+
} finally {
|
|
292
|
+
settled.resolve();
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
onError: (error) => {
|
|
296
|
+
try {
|
|
297
|
+
this.failCompaction(opts, decision, startMs, error);
|
|
298
|
+
} finally {
|
|
299
|
+
settled.resolve();
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
await settled.promise;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private finishCompaction(
|
|
307
|
+
opts: { ctx: ExtensionContext; pi: ExtensionAPI; config: OnlineContextCompactConfig },
|
|
308
|
+
decision: CompactionDecision,
|
|
309
|
+
startMs: number,
|
|
310
|
+
compaction: { summary?: string } | undefined,
|
|
311
|
+
): void {
|
|
312
|
+
this.state.priorCompactionCount++;
|
|
313
|
+
this.state.carriedDebtTokens = decision.writeTokens * (decision.incrementalCacheCostRatio ?? 0);
|
|
314
|
+
this.state.cacheDebtRepaymentTokens = Math.max(0, decision.archiveTokens - decision.memoTokens);
|
|
315
|
+
this.state.epoch++;
|
|
316
|
+
this.state.consecutiveCompactionFailures = 0;
|
|
317
|
+
this.state.compactBackoffTurnEnds = 0;
|
|
318
|
+
this.appendMarker(opts.pi, COMPACT_STATE_CUSTOM_TYPE, this.state);
|
|
319
|
+
|
|
320
|
+
if (opts.config.logEnabled) {
|
|
321
|
+
this.logCompact(opts.ctx, {
|
|
322
|
+
decision: decision.reason,
|
|
323
|
+
writeTokens: decision.writeTokens,
|
|
324
|
+
archiveTokens: decision.archiveTokens,
|
|
325
|
+
savedTokens: Math.max(0, decision.archiveTokens - decision.memoTokens),
|
|
326
|
+
breakevenRequests: decision.breakevenRequests,
|
|
327
|
+
expectedRemainingRequests: decision.expectedRemainingRequests,
|
|
328
|
+
carriedDebtTokens: this.state.carriedDebtTokens,
|
|
329
|
+
summaryTokens: typeof compaction?.summary === 'string' ? Math.ceil(compaction.summary.length / 4) : 0,
|
|
330
|
+
priorCompactionCount: this.state.priorCompactionCount,
|
|
331
|
+
durationMs: Date.now() - startMs,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
this.sendContinuation(
|
|
336
|
+
opts.pi,
|
|
337
|
+
COMPACTION_CONTINUE_TEXT,
|
|
338
|
+
false,
|
|
339
|
+
);
|
|
340
|
+
// Settled marker last: the master's poller releases its hold as soon as it appears.
|
|
341
|
+
this.appendMarker(opts.pi, COMPACTION_SETTLED_TYPE, { outcome: 'completed', at: Date.now() });
|
|
342
|
+
this.compactionInFlight = false;
|
|
343
|
+
this.intentionalAbort = false;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private failCompaction(
|
|
347
|
+
opts: { ctx: ExtensionContext; pi: ExtensionAPI; config: OnlineContextCompactConfig },
|
|
348
|
+
decision: CompactionDecision,
|
|
349
|
+
startMs: number,
|
|
350
|
+
error: unknown,
|
|
351
|
+
): void {
|
|
352
|
+
this.compactionInFlight = false;
|
|
353
|
+
this.intentionalAbort = false;
|
|
354
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
355
|
+
// 'Compaction cancelled' / AbortError mean the user (or pi) cancelled on purpose: telemetry keeps
|
|
356
|
+
// that apart from real failures, and a cancel must NOT trigger a continuation turn.
|
|
357
|
+
const cancelled =
|
|
358
|
+
error instanceof Error &&
|
|
359
|
+
(error.name === 'AbortError' || error.message === 'Compaction cancelled');
|
|
360
|
+
|
|
361
|
+
// Release the cross-session hold in every outcome — the cancel path returns before continuing.
|
|
362
|
+
this.appendMarker(opts.pi, COMPACTION_SETTLED_TYPE, {
|
|
363
|
+
outcome: cancelled ? 'cancelled' : 'failed',
|
|
364
|
+
at: Date.now(),
|
|
365
|
+
});
|
|
366
|
+
if (opts.config.logEnabled) {
|
|
367
|
+
this.logCompact(opts.ctx, {
|
|
368
|
+
event: 'compaction-failed',
|
|
369
|
+
decision: decision.reason,
|
|
370
|
+
cancelled,
|
|
371
|
+
error: message,
|
|
372
|
+
consecutiveCompactionFailures: this.state.consecutiveCompactionFailures,
|
|
373
|
+
durationMs: Date.now() - startMs,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
if (cancelled) return;
|
|
377
|
+
|
|
378
|
+
// P1-3: record the failure and back off exponentially (cap 4 skipped decisions).
|
|
379
|
+
this.state.consecutiveCompactionFailures++;
|
|
380
|
+
this.state.compactBackoffTurnEnds = Math.min(2 ** this.state.consecutiveCompactionFailures, 4);
|
|
381
|
+
// Persist the bumped counters: state is restored from this marker, so a restart without it
|
|
382
|
+
// would reset the P1-3 backoff window and retry the doomed compaction immediately.
|
|
383
|
+
this.appendMarker(opts.pi, COMPACT_STATE_CUSTOM_TYPE, this.state);
|
|
384
|
+
|
|
385
|
+
// OCC aborted the turn on purpose, so a failed compaction would leave the session parked on an
|
|
386
|
+
// aborted message; the notice must be visible rather than a silent failure.
|
|
387
|
+
const approxTokens = this.state.lastContextTokens;
|
|
388
|
+
this.sendContinuation(
|
|
389
|
+
opts.pi,
|
|
390
|
+
`Context compaction FAILED (${message}). `
|
|
391
|
+
+ `Context ≈ ${approxTokens != null ? `${Math.round(approxTokens / 1000)}K tokens` : 'near the window limit'}; `
|
|
392
|
+
+ `OCC backs off for the next ${this.state.compactBackoffTurnEnds} opportunities. `
|
|
393
|
+
+ 'If this repeats, run /compact manually, prune large outputs, or restart the session.',
|
|
394
|
+
true,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Best-effort session entry write: onAgentSettled runs detached, so a full disk must not become an
|
|
399
|
+
* unhandled rejection (a missing marker only delays one settlement notice). */
|
|
400
|
+
private appendMarker(pi: ExtensionAPI, customType: string, data: unknown): void {
|
|
401
|
+
try {
|
|
402
|
+
pi.appendEntry(customType, data);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
swallow(`compact.append-${customType}`, err);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Resume the task after compaction; must never throw into the detached settled handler. */
|
|
409
|
+
private sendContinuation(pi: ExtensionAPI, content: string, display: boolean): void {
|
|
410
|
+
try {
|
|
411
|
+
pi.sendMessage({ customType: COMPACTION_CONTINUE_TYPE, content, display }, { triggerTurn: true });
|
|
412
|
+
} catch {
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
rebuildFromBranch(entries: readonly unknown[]): void {
|
|
417
|
+
this.state = restoreCoordinatorState(entries);
|
|
418
|
+
this.selectedCompaction = null;
|
|
419
|
+
this.compactionInFlight = false;
|
|
420
|
+
this.intentionalAbort = false;
|
|
421
|
+
this.pendingBoundaryCompleted = false;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
getRemainingHorizon(remainingBoundaries = 3, contextWindowTokens: number | null = null): number {
|
|
425
|
+
const counts = this.state.completedBoundaryRequestCounts;
|
|
426
|
+
if (counts.length === 0) return 4; // No samples yet: conservative default horizon.
|
|
427
|
+
const est = estimateRemainingRequests({
|
|
428
|
+
completedBoundaryRequestCounts: counts,
|
|
429
|
+
remainingBoundaries,
|
|
430
|
+
scale: DEFAULT_COMPACTION_ECONOMICS.remainingRequestScale,
|
|
431
|
+
standardDeviationK: DEFAULT_COMPACTION_ECONOMICS.remainingRequestStddevK,
|
|
432
|
+
contextTokens: this.state.lastContextTokens ?? 0,
|
|
433
|
+
contextWindowTokens,
|
|
434
|
+
averageContextTokenIncrement: averageContextIncrement(this.state),
|
|
435
|
+
});
|
|
436
|
+
return Math.max(1, est.expectedRemainingRequests);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Append one compact.jsonl record (shared envelope); logging is always best-effort. */
|
|
440
|
+
private logCompact(ctx: ExtensionContext, fields: Record<string, unknown>): void {
|
|
441
|
+
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
442
|
+
const root = resolveSessionRoot(ctx.sessionManager?.getSessionDir?.(), sessionId);
|
|
443
|
+
if (!root) return;
|
|
444
|
+
void appendEfficiencyLog(efficiencyLogPath(root, 'compact'), {
|
|
445
|
+
schema: 'pier-efficiency/1',
|
|
446
|
+
mechanism: 'onlineContextCompact',
|
|
447
|
+
ts: new Date().toISOString(),
|
|
448
|
+
sessionId: sessionId ?? 'unknown',
|
|
449
|
+
epoch: this.state.epoch,
|
|
450
|
+
...fields,
|
|
451
|
+
}).catch(() => {});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private logDecision(ctx: ExtensionContext, decision: CompactionDecision): void {
|
|
455
|
+
this.logCompact(ctx, {
|
|
456
|
+
event: 'decision',
|
|
457
|
+
decision: decision.reason,
|
|
458
|
+
compact: decision.compact,
|
|
459
|
+
writeTokens: decision.writeTokens,
|
|
460
|
+
archiveTokens: decision.archiveTokens,
|
|
461
|
+
breakevenRequests: decision.breakevenRequests,
|
|
462
|
+
expectedRemainingRequests: decision.expectedRemainingRequests,
|
|
463
|
+
// Without resolvedRatio/model a cache_ratio_unavailable run is undiagnosable from logs alone.
|
|
464
|
+
resolvedRatio: decision.cacheWriteReadRatio,
|
|
465
|
+
incrementalCacheCostRatio: decision.incrementalCacheCostRatio,
|
|
466
|
+
remainingBoundaries: decision.remainingBoundaries,
|
|
467
|
+
model: ctx.model?.id ?? undefined,
|
|
468
|
+
provider: ctx.model?.provider ?? undefined,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|