pi-precompact 0.0.0-stage → 0.2.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/CHANGELOG.md +28 -0
- package/CONTRIBUTING.md +152 -0
- package/LICENSE +21 -0
- package/README.md +219 -2
- package/package.json +71 -4
- package/src/card.ts +194 -0
- package/src/controller.ts +446 -0
- package/src/files.ts +24 -0
- package/src/footer.ts +471 -0
- package/src/format.ts +20 -0
- package/src/index.ts +107 -0
- package/src/native.ts +47 -0
- package/src/validation.ts +82 -0
package/src/card.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { ExtensionContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { Label, StatusDetails } from "./controller.ts";
|
|
4
|
+
import { formatDuration, formatShare, formatTokens } from "./format.ts";
|
|
5
|
+
|
|
6
|
+
type Theme = ExtensionContext["ui"]["theme"];
|
|
7
|
+
type Color = Parameters<Theme["fg"]>[0];
|
|
8
|
+
type Segment = readonly [text: string, color?: Color];
|
|
9
|
+
|
|
10
|
+
const MAX_WIDTH = 76;
|
|
11
|
+
const CELLS = 24;
|
|
12
|
+
const LABEL = 10;
|
|
13
|
+
const GLYPH: Record<Label, Segment> = {
|
|
14
|
+
idle: ["○", "muted"],
|
|
15
|
+
running: ["◐", "accent"],
|
|
16
|
+
ready: ["●", "success"],
|
|
17
|
+
paused: ["‖", "warning"],
|
|
18
|
+
off: ["–", "dim"],
|
|
19
|
+
unavailable: ["×", "dim"],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** What the card can change. The controller implements it. */
|
|
23
|
+
export interface CardActions {
|
|
24
|
+
details(): StatusDetails;
|
|
25
|
+
reset(): void;
|
|
26
|
+
setEnabled(enabled: boolean): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Context bar with the background start and Pi's threshold as marks. */
|
|
30
|
+
export function gauge(
|
|
31
|
+
percent: number | null,
|
|
32
|
+
start: number | undefined,
|
|
33
|
+
threshold: number | undefined,
|
|
34
|
+
fill: Color,
|
|
35
|
+
) {
|
|
36
|
+
const filled = percent === null ? 0 : Math.round((Math.min(Math.max(percent, 0), 100) / 100) * CELLS);
|
|
37
|
+
const marks = new Map<number, Color>();
|
|
38
|
+
if (start !== undefined) marks.set(Math.min(CELLS - 1, Math.floor(start * CELLS)), "accent");
|
|
39
|
+
if (threshold !== undefined) marks.set(Math.min(CELLS - 1, Math.floor(threshold * CELLS)), "error");
|
|
40
|
+
const segments: Segment[] = [];
|
|
41
|
+
for (let cell = 0; cell < CELLS; cell++) {
|
|
42
|
+
const mark = marks.get(cell);
|
|
43
|
+
if (mark) segments.push(["│", mark]);
|
|
44
|
+
segments.push(cell < filled ? ["━", fill] : ["─", "dim"]);
|
|
45
|
+
}
|
|
46
|
+
return segments;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function contextColor(percent: number | null): Color {
|
|
50
|
+
if (percent === null) return "dim";
|
|
51
|
+
return percent > 90 ? "error" : percent > 70 ? "warning" : "text";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ago(at: number, now: number): string {
|
|
55
|
+
const ms = now - at;
|
|
56
|
+
return ms < 60_000 ? "just now" : `${Math.floor(ms / 60_000)} min ago`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function rows(details: StatusDetails, now: number): Segment[][] {
|
|
60
|
+
const [glyph, color] = GLYPH[details.label];
|
|
61
|
+
const label = details.applied && details.label === "idle" ? "applied" : details.label;
|
|
62
|
+
const out: Segment[][] = [
|
|
63
|
+
[
|
|
64
|
+
[glyph, color],
|
|
65
|
+
[` ${label}`, color],
|
|
66
|
+
[` ${details.reason}`, "text"],
|
|
67
|
+
],
|
|
68
|
+
[],
|
|
69
|
+
];
|
|
70
|
+
const context = details.context;
|
|
71
|
+
if (context) {
|
|
72
|
+
const percent = context.percent;
|
|
73
|
+
out.push([
|
|
74
|
+
["Context".padEnd(LABEL), "dim"],
|
|
75
|
+
...gauge(percent, details.start, details.threshold, color ?? "muted"),
|
|
76
|
+
[
|
|
77
|
+
percent === null
|
|
78
|
+
? " unknown until the next response"
|
|
79
|
+
: ` ${percent.toFixed(1)}% of ${formatTokens(context.window)}`,
|
|
80
|
+
contextColor(percent),
|
|
81
|
+
],
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
const marks = [
|
|
85
|
+
details.start === undefined ? undefined : `start ${formatShare(details.start)}`,
|
|
86
|
+
details.threshold === undefined ? undefined : `Pi compacts at ${formatShare(details.threshold)}`,
|
|
87
|
+
].filter((text) => text !== undefined);
|
|
88
|
+
if (marks.length) out.push([[" ".repeat(LABEL)], [marks.join(" · "), "dim"]]);
|
|
89
|
+
const job = details.job;
|
|
90
|
+
const tokens = context?.tokens;
|
|
91
|
+
if (job?.phase === "running") {
|
|
92
|
+
out.push([
|
|
93
|
+
["Summary".padEnd(LABEL), "dim"],
|
|
94
|
+
[`preparing for ${formatDuration(now - job.startedAt)}`, "text"],
|
|
95
|
+
]);
|
|
96
|
+
} else if (job?.phase === "ready") {
|
|
97
|
+
const prepared = ` · prepared in ${formatDuration((job.readyAt ?? now) - job.startedAt)}, ${ago(job.readyAt ?? now, now)}`;
|
|
98
|
+
out.push([
|
|
99
|
+
["Summary".padEnd(LABEL), "dim"],
|
|
100
|
+
job.estimate === undefined || tokens == null
|
|
101
|
+
? ["estimate unavailable", "text"]
|
|
102
|
+
: [`${formatTokens(tokens)} → ~${formatTokens(job.estimate)} tokens`, "text"],
|
|
103
|
+
[prepared, "dim"],
|
|
104
|
+
]);
|
|
105
|
+
} else if (details.applied) {
|
|
106
|
+
const applied = details.applied;
|
|
107
|
+
out.push([
|
|
108
|
+
["Summary".padEnd(LABEL), "dim"],
|
|
109
|
+
[`applied ${ago(applied.at, now)}`, "text"],
|
|
110
|
+
[` · ${formatTokens(applied.tokensBefore)} → ~${formatTokens(applied.tokensAfter)} tokens`, "dim"],
|
|
111
|
+
]);
|
|
112
|
+
} else {
|
|
113
|
+
out.push([
|
|
114
|
+
["Summary".padEnd(LABEL), "dim"],
|
|
115
|
+
["none", "dim"],
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
const usage = job?.usage ?? details.applied?.usage;
|
|
119
|
+
if (usage) {
|
|
120
|
+
const cost = usage.cost.total > 0 ? ` · $${usage.cost.total.toFixed(3)}` : "";
|
|
121
|
+
out.push([
|
|
122
|
+
["Request".padEnd(LABEL), "dim"],
|
|
123
|
+
[
|
|
124
|
+
`${formatTokens(usage.input + usage.cacheRead + usage.cacheWrite)} in · ${formatTokens(usage.output)} out${cost}`,
|
|
125
|
+
"text",
|
|
126
|
+
],
|
|
127
|
+
]);
|
|
128
|
+
}
|
|
129
|
+
out.push(
|
|
130
|
+
[
|
|
131
|
+
["Failures".padEnd(LABEL), "dim"],
|
|
132
|
+
[`${details.failures}/2`, "text"],
|
|
133
|
+
],
|
|
134
|
+
[],
|
|
135
|
+
);
|
|
136
|
+
out.push([
|
|
137
|
+
["r", "accent"],
|
|
138
|
+
[" reset ", "dim"],
|
|
139
|
+
["o", "accent"],
|
|
140
|
+
[details.enabled ? " off " : " on ", "dim"],
|
|
141
|
+
["esc", "accent"],
|
|
142
|
+
[" close", "dim"],
|
|
143
|
+
]);
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function paint(theme: Theme, segments: Segment[]): string {
|
|
148
|
+
return segments.map(([text, color]) => (color ? theme.fg(color, text) : text)).join("");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Render the card. Exported for tests; `now` keeps them deterministic. */
|
|
152
|
+
export function renderCard(details: StatusDetails, theme: Theme, width: number, now = Date.now()): string[] {
|
|
153
|
+
const outer = Math.min(MAX_WIDTH, width);
|
|
154
|
+
const inner = outer - 4;
|
|
155
|
+
if (inner < 8) return rows(details, now).map((row) => truncateToWidth(paint(theme, row), width, ""));
|
|
156
|
+
const title = "─ Precompact ";
|
|
157
|
+
const border = (text: string) => theme.fg("border", text);
|
|
158
|
+
const top = border(
|
|
159
|
+
`╭${truncateToWidth(title, outer - 2, "")}${"─".repeat(Math.max(0, outer - 2 - title.length))}╮`,
|
|
160
|
+
);
|
|
161
|
+
const body = rows(details, now).map((row) => {
|
|
162
|
+
const content = truncateToWidth(paint(theme, row), inner, theme.fg("dim", "…"));
|
|
163
|
+
return `${border("│ ")}${content}${" ".repeat(Math.max(0, inner - visibleWidth(content)))}${border(" │")}`;
|
|
164
|
+
});
|
|
165
|
+
return [top, ...body, border(`╰${"─".repeat(outer - 2)}╯`)];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The status card for an explicit `/precompact status`. Resolves when closed. */
|
|
169
|
+
export function statusCard(
|
|
170
|
+
actions: CardActions,
|
|
171
|
+
tui: Pick<TUI, "requestRender">,
|
|
172
|
+
theme: Theme,
|
|
173
|
+
keybindings: Pick<KeybindingsManager, "matches">,
|
|
174
|
+
done: () => void,
|
|
175
|
+
): Component & { dispose(): void } {
|
|
176
|
+
// Elapsed times and state changes appear without input. Stop with the card.
|
|
177
|
+
const timer = setInterval(() => tui.requestRender(), 1000);
|
|
178
|
+
timer.unref();
|
|
179
|
+
return {
|
|
180
|
+
render: (width) => renderCard(actions.details(), theme, width),
|
|
181
|
+
invalidate() {},
|
|
182
|
+
handleInput(data) {
|
|
183
|
+
if (keybindings.matches(data, "tui.select.cancel") || keybindings.matches(data, "tui.select.confirm")) {
|
|
184
|
+
done();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (data === "r") actions.reset();
|
|
188
|
+
else if (data === "o") actions.setEnabled(!actions.details().enabled);
|
|
189
|
+
else return;
|
|
190
|
+
tui.requestRender();
|
|
191
|
+
},
|
|
192
|
+
dispose: () => clearInterval(timer),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSessionProjection,
|
|
3
|
+
type CompactionResult,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionContext,
|
|
6
|
+
type SessionBeforeCompactEvent,
|
|
7
|
+
type SessionBeforeCompactResult,
|
|
8
|
+
type SessionCompactEvent,
|
|
9
|
+
SettingsManager,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { inheritOwnFiles, isFileDetails } from "./files.ts";
|
|
12
|
+
import { STATUS_KEY } from "./footer.ts";
|
|
13
|
+
import { formatDuration, formatTokens } from "./format.ts";
|
|
14
|
+
import type { NativeCompaction } from "./native.ts";
|
|
15
|
+
import { fingerprint, type Snapshot, snapshotValid, validateResult } from "./validation.ts";
|
|
16
|
+
|
|
17
|
+
export interface Options {
|
|
18
|
+
startRatio: number;
|
|
19
|
+
minimumTokens: number;
|
|
20
|
+
timeoutMs: number;
|
|
21
|
+
}
|
|
22
|
+
export const DEFAULT_OPTIONS: Readonly<Options> = {
|
|
23
|
+
startRatio: 0.85,
|
|
24
|
+
minimumTokens: 64_000,
|
|
25
|
+
timeoutMs: 120_000,
|
|
26
|
+
};
|
|
27
|
+
type State = "idle" | "running" | "ready" | "paused" | "disabled";
|
|
28
|
+
/** The state shown in the footer and the status card. */
|
|
29
|
+
export type Label = "idle" | "running" | "ready" | "paused" | "off" | "unavailable";
|
|
30
|
+
type Usage = NonNullable<CompactionResult["usage"]>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What the footer draws. Shares are fractions of the model's context window.
|
|
34
|
+
* The plain `precompact: <state>` status text stays the fallback for other footers.
|
|
35
|
+
*/
|
|
36
|
+
export interface FooterState {
|
|
37
|
+
state: Label;
|
|
38
|
+
/** The last compaction used a background summary, and nothing happened since. */
|
|
39
|
+
applied: boolean;
|
|
40
|
+
/** Where background preparation starts. */
|
|
41
|
+
start?: number;
|
|
42
|
+
/** Where Pi compacts natively. */
|
|
43
|
+
threshold?: number;
|
|
44
|
+
/** Estimated context after Pi adopts the held summary (ready only). */
|
|
45
|
+
after?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Applied {
|
|
49
|
+
tokensBefore: number;
|
|
50
|
+
tokensAfter: number;
|
|
51
|
+
preparedMs: number;
|
|
52
|
+
waitedMs: number;
|
|
53
|
+
at: number;
|
|
54
|
+
usage?: Usage;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Everything the status card shows. */
|
|
58
|
+
export interface StatusDetails {
|
|
59
|
+
label: Label;
|
|
60
|
+
reason: string;
|
|
61
|
+
failures: number;
|
|
62
|
+
enabled: boolean;
|
|
63
|
+
context?: { tokens: number | null; window: number; percent: number | null };
|
|
64
|
+
start?: number;
|
|
65
|
+
threshold?: number;
|
|
66
|
+
job?: { phase: "running" | "ready"; startedAt: number; readyAt?: number; estimate?: number; usage?: Usage };
|
|
67
|
+
applied?: Applied;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface Job {
|
|
71
|
+
snapshot: Snapshot;
|
|
72
|
+
controller: AbortController;
|
|
73
|
+
promise: Promise<CompactionResult | undefined>;
|
|
74
|
+
result?: CompactionResult;
|
|
75
|
+
startedAt: number;
|
|
76
|
+
readyAt?: number;
|
|
77
|
+
/** Estimated context tokens after adoption, refreshed while the result is held. */
|
|
78
|
+
estimate?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
82
|
+
return typeof value === "object" && value !== null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The single-line notice after Pi adopted a background summary. */
|
|
86
|
+
export function appliedNotice(applied: Applied): string {
|
|
87
|
+
const wait = applied.waitedMs < 1000 ? "no wait" : `waited ${formatDuration(applied.waitedMs)}`;
|
|
88
|
+
return `✓ Background summary applied · ${wait} · ${formatTokens(applied.tokensBefore)} → ~${formatTokens(applied.tokensAfter)} tokens · prepared in ${formatDuration(applied.preparedMs)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A bounded wait even if a provider does not settle its promise on abort. */
|
|
92
|
+
export async function untilAborted<T>(promise: Promise<T>, signal: AbortSignal): Promise<T | undefined> {
|
|
93
|
+
if (signal.aborted) return undefined;
|
|
94
|
+
let listener = () => {};
|
|
95
|
+
const abort = new Promise<undefined>((resolve) => {
|
|
96
|
+
listener = () => resolve(undefined);
|
|
97
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
return await Promise.race([promise, abort]);
|
|
101
|
+
} finally {
|
|
102
|
+
signal.removeEventListener("abort", listener);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class BackgroundCompaction {
|
|
107
|
+
private job?: Job;
|
|
108
|
+
private failures = 0;
|
|
109
|
+
private enabled = true;
|
|
110
|
+
private last = "Waiting for enough context";
|
|
111
|
+
private state: State = "idle";
|
|
112
|
+
private pendingApplied?: Applied;
|
|
113
|
+
private applied?: Applied;
|
|
114
|
+
private showApplied = false;
|
|
115
|
+
private footer?: FooterState;
|
|
116
|
+
|
|
117
|
+
constructor(
|
|
118
|
+
private readonly pi: Pick<ExtensionAPI, "getSettings" | "appendEntry">,
|
|
119
|
+
private readonly native: NativeCompaction,
|
|
120
|
+
private readonly options: Options = DEFAULT_OPTIONS,
|
|
121
|
+
) {
|
|
122
|
+
if (
|
|
123
|
+
!(options.startRatio > 0 && options.startRatio < 1) ||
|
|
124
|
+
!Number.isFinite(options.minimumTokens) ||
|
|
125
|
+
options.minimumTokens < 0 ||
|
|
126
|
+
!Number.isSafeInteger(options.timeoutMs) ||
|
|
127
|
+
options.timeoutMs < 1
|
|
128
|
+
)
|
|
129
|
+
throw new Error("Invalid background compaction options");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
status(): string {
|
|
133
|
+
return `${this.enabled ? this.state : "disabled"}: ${this.last} (${this.failures}/2 failures)`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
setEnabled(enabled: boolean, ctx: ExtensionContext): void {
|
|
137
|
+
this.enabled = enabled;
|
|
138
|
+
this.reset(ctx, enabled ? "Enabled for this session" : "Disabled for this session");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
reset(ctx: ExtensionContext, reason = "Session changed"): void {
|
|
142
|
+
this.pendingApplied = undefined;
|
|
143
|
+
this.discard(ctx, reason);
|
|
144
|
+
this.failures = 0;
|
|
145
|
+
this.show(ctx, this.enabled ? "idle" : "disabled", reason);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Called on `session_compact`. Returns the notice when Pi adopted this plugin's summary.
|
|
150
|
+
* The footer keeps the applied mark until the next state change.
|
|
151
|
+
*/
|
|
152
|
+
compacted(event: SessionCompactEvent, ctx: ExtensionContext): string | undefined {
|
|
153
|
+
const applied = this.pendingApplied;
|
|
154
|
+
this.reset(ctx, "Compaction completed");
|
|
155
|
+
const details = event.compactionEntry.details;
|
|
156
|
+
if (!applied || !isRecord(details) || details.precompact !== 1) return undefined;
|
|
157
|
+
this.applied = applied;
|
|
158
|
+
this.showApplied = true;
|
|
159
|
+
this.last = "Prepared summary applied";
|
|
160
|
+
this.refreshStatus(ctx);
|
|
161
|
+
return appliedNotice(applied);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private show(ctx: ExtensionContext, state: State, reason: string): void {
|
|
165
|
+
this.state = state;
|
|
166
|
+
this.last = reason;
|
|
167
|
+
this.showApplied = false;
|
|
168
|
+
this.refreshStatus(ctx);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private label(ctx: ExtensionContext): Label {
|
|
172
|
+
const settings = SettingsManager.inMemory(this.pi.getSettings()).getCompactionSettings(ctx.model);
|
|
173
|
+
if (!this.enabled || !settings.enabled) return "off";
|
|
174
|
+
if (!ctx.model || ctx.model.api === "pi-virtual") return "unavailable";
|
|
175
|
+
return this.state === "disabled" ? "off" : this.state;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Start and native threshold as shares of the context window, when known. */
|
|
179
|
+
private shares(ctx: ExtensionContext, label: Label): { start?: number; threshold?: number } {
|
|
180
|
+
const model = ctx.model;
|
|
181
|
+
if (!model || model.api === "pi-virtual" || !(model.contextWindow > 0)) return {};
|
|
182
|
+
const settings = SettingsManager.inMemory(this.pi.getSettings()).getCompactionSettings(model);
|
|
183
|
+
const ceiling = model.contextWindow - settings.reserveTokens;
|
|
184
|
+
if (!settings.enabled || !(ceiling > 0)) return {};
|
|
185
|
+
const threshold = ceiling / model.contextWindow;
|
|
186
|
+
return label === "off" ? { threshold } : { start: threshold * this.options.startRatio, threshold };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
refreshStatus(ctx: ExtensionContext): void {
|
|
190
|
+
if (ctx.mode !== "tui") return;
|
|
191
|
+
const label = this.label(ctx);
|
|
192
|
+
const footer: FooterState = {
|
|
193
|
+
state: label,
|
|
194
|
+
applied: label === "idle" && this.showApplied,
|
|
195
|
+
...this.shares(ctx, label),
|
|
196
|
+
};
|
|
197
|
+
const window = ctx.model?.contextWindow ?? 0;
|
|
198
|
+
if (label === "ready" && this.job?.estimate !== undefined && window > 0)
|
|
199
|
+
footer.after = this.job.estimate / window;
|
|
200
|
+
// Store the footer data first: setStatus triggers the render that reads it.
|
|
201
|
+
this.footer = footer;
|
|
202
|
+
ctx.ui.setStatus(STATUS_KEY, `precompact: ${label}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The last footer data; undefined outside the TUI. */
|
|
206
|
+
footerState(): FooterState | undefined {
|
|
207
|
+
return this.footer;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
details(ctx: ExtensionContext): StatusDetails {
|
|
211
|
+
const label = this.label(ctx);
|
|
212
|
+
const usage = ctx.getContextUsage();
|
|
213
|
+
const job = this.job;
|
|
214
|
+
return {
|
|
215
|
+
label,
|
|
216
|
+
reason: this.last,
|
|
217
|
+
failures: this.failures,
|
|
218
|
+
enabled: this.enabled,
|
|
219
|
+
...(usage
|
|
220
|
+
? { context: { tokens: usage.tokens, window: usage.contextWindow, percent: usage.percent } }
|
|
221
|
+
: {}),
|
|
222
|
+
...this.shares(ctx, label),
|
|
223
|
+
...(job
|
|
224
|
+
? {
|
|
225
|
+
job: {
|
|
226
|
+
phase: job.result ? "ready" : "running",
|
|
227
|
+
startedAt: job.startedAt,
|
|
228
|
+
readyAt: job.readyAt,
|
|
229
|
+
estimate: job.estimate,
|
|
230
|
+
usage: job.result?.usage,
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
: {}),
|
|
234
|
+
...(this.showApplied && this.applied ? { applied: this.applied } : {}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Preview the held result against the current branch. Undefined means Pi would reject it. */
|
|
239
|
+
private estimate(job: Job, result: CompactionResult, ctx: ExtensionContext): number | undefined {
|
|
240
|
+
try {
|
|
241
|
+
const tokens = ctx.getContextUsage()?.tokens ?? job.snapshot.preparation.tokensBefore;
|
|
242
|
+
return validateResult(
|
|
243
|
+
job.snapshot,
|
|
244
|
+
result,
|
|
245
|
+
ctx.sessionManager.getBranch(),
|
|
246
|
+
ctx.model?.contextWindow ?? 0,
|
|
247
|
+
tokens,
|
|
248
|
+
)?.estimatedTokensAfter;
|
|
249
|
+
} catch {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private identity(ctx: ExtensionContext): string {
|
|
255
|
+
const settings = this.pi.getSettings();
|
|
256
|
+
return fingerprint({
|
|
257
|
+
model: ctx.model,
|
|
258
|
+
compaction: settings.compaction,
|
|
259
|
+
retry: settings.retry,
|
|
260
|
+
thinking: ctx.thinkingLevel,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private discard(ctx: ExtensionContext, reason: string): void {
|
|
265
|
+
const job = this.job;
|
|
266
|
+
this.job = undefined;
|
|
267
|
+
job?.controller.abort();
|
|
268
|
+
// Pi's public extension API has no usage-entry writer. Keep known discarded
|
|
269
|
+
// usage auditable without putting it into the conversation or double counting.
|
|
270
|
+
if (job?.result?.usage && job.snapshot.sessionId === ctx.sessionManager.getSessionId()) {
|
|
271
|
+
this.pi.appendEntry("precompact.discarded", { reason, usage: job.result.usage });
|
|
272
|
+
}
|
|
273
|
+
this.show(ctx, this.failures >= 2 ? "paused" : "idle", reason);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private valid(job: Job, ctx: ExtensionContext): boolean {
|
|
277
|
+
return snapshotValid(
|
|
278
|
+
job.snapshot,
|
|
279
|
+
ctx.sessionManager.getSessionId(),
|
|
280
|
+
this.identity(ctx),
|
|
281
|
+
ctx.sessionManager.getBranch(),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Called from a persisted turn boundary. This method never awaits the provider. */
|
|
286
|
+
consider(ctx: ExtensionContext): void {
|
|
287
|
+
if (!this.enabled) return;
|
|
288
|
+
const settingsManager = SettingsManager.inMemory(this.pi.getSettings());
|
|
289
|
+
const model = ctx.model;
|
|
290
|
+
const settings = settingsManager.getCompactionSettings(model);
|
|
291
|
+
if (!settings.enabled || !model || model.api === "pi-virtual") {
|
|
292
|
+
this.discard(ctx, "Native compaction disabled or model unsupported");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (this.job && !this.valid(this.job, ctx)) this.discard(ctx, "Snapshot invalidated");
|
|
296
|
+
const held = this.job;
|
|
297
|
+
if (held?.result) {
|
|
298
|
+
// Newer history stays in the retained tail, so the expected result grows.
|
|
299
|
+
const estimate = this.estimate(held, held.result, ctx);
|
|
300
|
+
if (estimate !== held.estimate) {
|
|
301
|
+
held.estimate = estimate;
|
|
302
|
+
this.refreshStatus(ctx);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (this.job || this.failures >= 2) return;
|
|
306
|
+
const usage = ctx.getContextUsage();
|
|
307
|
+
const ceiling = model.contextWindow - settings.reserveTokens;
|
|
308
|
+
if (
|
|
309
|
+
!usage ||
|
|
310
|
+
usage.tokens === null ||
|
|
311
|
+
!Number.isFinite(usage.tokens) ||
|
|
312
|
+
usage.tokens < ceiling * this.options.startRatio ||
|
|
313
|
+
usage.tokens >= ceiling
|
|
314
|
+
)
|
|
315
|
+
return;
|
|
316
|
+
const branch = ctx.sessionManager.getBranch();
|
|
317
|
+
const measured = buildSessionProjection(branch).messages.findLast(
|
|
318
|
+
(message) =>
|
|
319
|
+
message.role === "assistant" && message.stopReason !== "error" && message.stopReason !== "aborted",
|
|
320
|
+
);
|
|
321
|
+
if (
|
|
322
|
+
measured?.role !== "assistant" ||
|
|
323
|
+
measured.usage.input + measured.usage.cacheRead + measured.usage.cacheWrite < this.options.minimumTokens
|
|
324
|
+
)
|
|
325
|
+
return;
|
|
326
|
+
const preparation = this.native.prepareCompaction(branch, settings);
|
|
327
|
+
if (!preparation) return;
|
|
328
|
+
inheritOwnFiles(preparation, branch);
|
|
329
|
+
const snapshot: Snapshot = {
|
|
330
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
331
|
+
identity: this.identity(ctx),
|
|
332
|
+
entryCount: branch.length,
|
|
333
|
+
branchHash: fingerprint(branch),
|
|
334
|
+
preparation: structuredClone(preparation),
|
|
335
|
+
};
|
|
336
|
+
const controller = new AbortController();
|
|
337
|
+
const job: Job = { snapshot, controller, promise: Promise.resolve(undefined), startedAt: Date.now() };
|
|
338
|
+
this.job = job;
|
|
339
|
+
this.show(ctx, "running", "Preparing a native summary");
|
|
340
|
+
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs);
|
|
341
|
+
timeout.unref();
|
|
342
|
+
// Defer invocation so a synchronous provider error is handled identically.
|
|
343
|
+
const work = Promise.resolve().then(() => {
|
|
344
|
+
controller.signal.throwIfAborted();
|
|
345
|
+
return this.native.compact(
|
|
346
|
+
snapshot.preparation,
|
|
347
|
+
model,
|
|
348
|
+
undefined,
|
|
349
|
+
undefined,
|
|
350
|
+
undefined,
|
|
351
|
+
controller.signal,
|
|
352
|
+
ctx.thinkingLevel,
|
|
353
|
+
(requestModel, context, options) => ctx.modelRegistry.streamSimple(requestModel, context, options),
|
|
354
|
+
undefined,
|
|
355
|
+
settingsManager.getRetrySettings(),
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
job.promise = untilAborted(work, controller.signal)
|
|
359
|
+
.catch(() => undefined) // Never copy provider errors (possibly sensitive) into status or session data.
|
|
360
|
+
.then((result) => {
|
|
361
|
+
if (this.job !== job) return undefined;
|
|
362
|
+
if (!result) {
|
|
363
|
+
this.job = undefined;
|
|
364
|
+
this.failures++;
|
|
365
|
+
this.show(
|
|
366
|
+
ctx,
|
|
367
|
+
this.failures >= 2 ? "paused" : "idle",
|
|
368
|
+
"Background request failed or timed out; native fallback remains active",
|
|
369
|
+
);
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
job.result = result;
|
|
373
|
+
job.readyAt = Date.now();
|
|
374
|
+
job.estimate = this.estimate(job, result, ctx);
|
|
375
|
+
this.show(ctx, "ready", "Summary held until native threshold compaction");
|
|
376
|
+
return result;
|
|
377
|
+
})
|
|
378
|
+
.finally(() => clearTimeout(timeout));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async beforeCompact(
|
|
382
|
+
event: SessionBeforeCompactEvent,
|
|
383
|
+
ctx: ExtensionContext,
|
|
384
|
+
): Promise<SessionBeforeCompactResult | undefined> {
|
|
385
|
+
const job = this.job;
|
|
386
|
+
if (!job) return undefined;
|
|
387
|
+
if (
|
|
388
|
+
!this.enabled ||
|
|
389
|
+
event.reason !== "threshold" ||
|
|
390
|
+
event.customInstructions !== undefined ||
|
|
391
|
+
fingerprint(event.preparation.settings) !== fingerprint(job.snapshot.preparation.settings) ||
|
|
392
|
+
!this.valid(job, ctx)
|
|
393
|
+
) {
|
|
394
|
+
this.discard(ctx, "Using native compaction for this request");
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
const abort = () => this.discard(ctx, "Foreground compaction cancelled");
|
|
398
|
+
if (event.signal.aborted) {
|
|
399
|
+
abort();
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
event.signal.addEventListener("abort", abort, { once: true });
|
|
403
|
+
const waitStart = Date.now();
|
|
404
|
+
try {
|
|
405
|
+
const result = await job.promise;
|
|
406
|
+
const waitedMs = Date.now() - waitStart;
|
|
407
|
+
// Revalidate after awaiting: edits, switches, and model changes may race it.
|
|
408
|
+
if (event.signal.aborted || this.job !== job || !result) return undefined;
|
|
409
|
+
if (!this.valid(job, ctx)) {
|
|
410
|
+
this.discard(ctx, "Snapshot changed while waiting");
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
const accepted = validateResult(
|
|
414
|
+
job.snapshot,
|
|
415
|
+
result,
|
|
416
|
+
ctx.sessionManager.getBranch(),
|
|
417
|
+
ctx.model?.contextWindow ?? 0,
|
|
418
|
+
event.preparation.tokensBefore,
|
|
419
|
+
);
|
|
420
|
+
if (!accepted) {
|
|
421
|
+
this.discard(ctx, "Summary or retained tail exceeds the safe budget");
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
this.job = undefined;
|
|
425
|
+
this.show(ctx, "idle", "Prepared summary supplied to Pi");
|
|
426
|
+
this.pendingApplied = {
|
|
427
|
+
tokensBefore: event.preparation.tokensBefore,
|
|
428
|
+
tokensAfter: accepted.estimatedTokensAfter ?? event.preparation.tokensBefore,
|
|
429
|
+
preparedMs: (job.readyAt ?? Date.now()) - job.startedAt,
|
|
430
|
+
waitedMs,
|
|
431
|
+
at: Date.now(),
|
|
432
|
+
usage: accepted.usage,
|
|
433
|
+
};
|
|
434
|
+
return {
|
|
435
|
+
compaction: {
|
|
436
|
+
...accepted,
|
|
437
|
+
details: isFileDetails(accepted.details)
|
|
438
|
+
? { ...accepted.details, precompact: 1 }
|
|
439
|
+
: accepted.details,
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
} finally {
|
|
443
|
+
event.signal.removeEventListener("abort", abort);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getLatestCompactionEntry, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Preparation } from "./native.ts";
|
|
3
|
+
|
|
4
|
+
export function isFileDetails(value: unknown): value is { readFiles: string[]; modifiedFiles: string[] } {
|
|
5
|
+
return (
|
|
6
|
+
typeof value === "object" &&
|
|
7
|
+
value !== null &&
|
|
8
|
+
"readFiles" in value &&
|
|
9
|
+
Array.isArray(value.readFiles) &&
|
|
10
|
+
value.readFiles.every((file) => typeof file === "string") &&
|
|
11
|
+
"modifiedFiles" in value &&
|
|
12
|
+
Array.isArray(value.modifiedFiles) &&
|
|
13
|
+
value.modifiedFiles.every((file) => typeof file === "string")
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Pi 1.4.0 does not inherit file details from fromHook entries, even native-shaped ones. */
|
|
18
|
+
export function inheritOwnFiles(preparation: Preparation, branch: SessionEntry[]): void {
|
|
19
|
+
const previous = getLatestCompactionEntry(branch);
|
|
20
|
+
const details: unknown = previous?.details;
|
|
21
|
+
if (!isFileDetails(details) || !("precompact" in details) || details.precompact !== 1) return;
|
|
22
|
+
for (const file of details.readFiles) preparation.fileOps.read.add(file);
|
|
23
|
+
for (const file of details.modifiedFiles) preparation.fileOps.edited.add(file);
|
|
24
|
+
}
|