@criogaid/pi-codex-compaction 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/LICENSE +22 -0
- package/README.md +172 -0
- package/package.json +59 -0
- package/src/capability.ts +95 -0
- package/src/checkpoint.ts +261 -0
- package/src/context-window.ts +108 -0
- package/src/fallback-command.ts +189 -0
- package/src/fallback-settings.ts +181 -0
- package/src/fallback.ts +92 -0
- package/src/history-groups.ts +45 -0
- package/src/image-budget.ts +93 -0
- package/src/index.ts +370 -0
- package/src/protocol.ts +155 -0
- package/src/remote.ts +140 -0
- package/src/request-snapshot.ts +225 -0
- package/src/retention-input.ts +146 -0
- package/src/retention.ts +108 -0
- package/src/text-budget.ts +36 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Own Codex's pre-compaction tool-output trimming and its model-visible item estimate.
|
|
2
|
+
// Reference: openai/codex rust-v0.159.2, compact_remote_history.rs and context_manager/history.rs.
|
|
3
|
+
import { type ImageEstimates, RESIZED_IMAGE_BYTES_ESTIMATE } from "./image-budget.js";
|
|
4
|
+
import { isInputImage, isObject, type JsonObject } from "./protocol.js";
|
|
5
|
+
import { historyGroups, type HistoryGroup } from "./history-groups.js";
|
|
6
|
+
import { approximateTokenCount, approximateTokensFromBytes } from "./text-budget.js";
|
|
7
|
+
|
|
8
|
+
const EFFECTIVE_CONTEXT_WINDOW_PERCENT = 95;
|
|
9
|
+
const DEFAULT_FUNCTION_NAMESPACE = "functions";
|
|
10
|
+
const TRUNCATED_OUTPUT_MESSAGE = "Output exceeded the available model context and was truncated";
|
|
11
|
+
|
|
12
|
+
function textBytes(value: unknown): number {
|
|
13
|
+
return typeof value === "string" ? Buffer.byteLength(value, "utf8") : 0;
|
|
14
|
+
}
|
|
15
|
+
function jsonBytes(value: unknown): number {
|
|
16
|
+
const json = value === undefined || value === null ? undefined : JSON.stringify(value);
|
|
17
|
+
return json === undefined ? 0 : Buffer.byteLength(json, "utf8");
|
|
18
|
+
}
|
|
19
|
+
function reasoningBytes(value: unknown): number {
|
|
20
|
+
return typeof value === "string" ? Math.max(0, Math.floor(textBytes(value) * 3 / 4) - 650) : 0;
|
|
21
|
+
}
|
|
22
|
+
function partBytes(part: unknown, images: ImageEstimates): number {
|
|
23
|
+
if (!isObject(part)) return 0;
|
|
24
|
+
if (part.type === "input_text" || part.type === "output_text") return textBytes(part.text);
|
|
25
|
+
if (part.type === "encrypted_content") return Math.ceil(textBytes(part.encrypted_content) * 9 / 16);
|
|
26
|
+
// Pi sends no audio parts.
|
|
27
|
+
return isInputImage(part) ? images.bytes(part) : 0;
|
|
28
|
+
}
|
|
29
|
+
function contentBytes(content: unknown, images: ImageEstimates): number {
|
|
30
|
+
if (typeof content === "string") return textBytes(content);
|
|
31
|
+
return Array.isArray(content) ? content.reduce((sum: number, part: unknown) => sum + partBytes(part, images), 0) : 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function itemBytes(item: JsonObject, images: ImageEstimates): number {
|
|
35
|
+
switch (item.type ?? "message") {
|
|
36
|
+
case "message":
|
|
37
|
+
return contentBytes(item.content, images);
|
|
38
|
+
case "reasoning":
|
|
39
|
+
case "compaction":
|
|
40
|
+
case "compaction_summary":
|
|
41
|
+
case "context_compaction":
|
|
42
|
+
return reasoningBytes(item.encrypted_content);
|
|
43
|
+
case "function_call":
|
|
44
|
+
return textBytes(item.name) + textBytes(item.namespace ?? DEFAULT_FUNCTION_NAMESPACE) + textBytes(item.arguments);
|
|
45
|
+
case "custom_tool_call":
|
|
46
|
+
return textBytes(item.name) + textBytes(item.namespace ?? DEFAULT_FUNCTION_NAMESPACE) + textBytes(item.input);
|
|
47
|
+
case "function_call_output":
|
|
48
|
+
return contentBytes(item.output, images) + textBytes(item.call_id) + textBytes(item.name) + textBytes(item.namespace);
|
|
49
|
+
case "custom_tool_call_output":
|
|
50
|
+
return contentBytes(item.output, images) + textBytes(item.call_id) + textBytes(item.name);
|
|
51
|
+
case "additional_tools":
|
|
52
|
+
case "tool_search_output":
|
|
53
|
+
return jsonBytes(item.tools);
|
|
54
|
+
case "tool_search_call":
|
|
55
|
+
return jsonBytes(item.arguments);
|
|
56
|
+
case "local_shell_call":
|
|
57
|
+
case "web_search_call":
|
|
58
|
+
return jsonBytes(item.action);
|
|
59
|
+
case "image_generation_call":
|
|
60
|
+
return textBytes(item.revised_prompt) + (textBytes(item.result) > 0 ? RESIZED_IMAGE_BYTES_ESTIMATE : 0);
|
|
61
|
+
default:
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function itemTokenCount(item: JsonObject, images: ImageEstimates): number {
|
|
67
|
+
return approximateTokensFromBytes(itemBytes(item, images));
|
|
68
|
+
}
|
|
69
|
+
function groupTokenCount({ source, notice }: HistoryGroup, images: ImageEstimates): number {
|
|
70
|
+
return itemTokenCount(source, images) + (notice ? itemTokenCount(notice, images) : 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function rewrittenOutput(item: JsonObject): JsonObject | undefined {
|
|
74
|
+
if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
|
|
75
|
+
return { ...item, output: TRUNCATED_OUTPUT_MESSAGE };
|
|
76
|
+
}
|
|
77
|
+
return item.type === "tool_search_output" ? { ...item, tools: [] } : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Replace trailing tool outputs until the estimate fits Codex's usable window.
|
|
82
|
+
* A replaced output drops its attached notice; the first non-output group stops trimming.
|
|
83
|
+
*/
|
|
84
|
+
export function trimToolOutputsToContextWindow(
|
|
85
|
+
input: readonly JsonObject[],
|
|
86
|
+
instructions: unknown,
|
|
87
|
+
contextWindow: number,
|
|
88
|
+
images: ImageEstimates,
|
|
89
|
+
): JsonObject[] {
|
|
90
|
+
if (!Number.isFinite(contextWindow) || contextWindow <= 0) return [...input];
|
|
91
|
+
const usableWindow = Math.floor(contextWindow * EFFECTIVE_CONTEXT_WINDOW_PERCENT / 100);
|
|
92
|
+
const groups = historyGroups(input);
|
|
93
|
+
let estimated = groups.reduce(
|
|
94
|
+
(sum, group) => sum + groupTokenCount(group, images),
|
|
95
|
+
approximateTokenCount(typeof instructions === "string" ? instructions : ""),
|
|
96
|
+
);
|
|
97
|
+
let consumed = 0;
|
|
98
|
+
const rewritten: JsonObject[] = [];
|
|
99
|
+
for (let index = groups.length - 1; index >= 0 && estimated > usableWindow; index--) {
|
|
100
|
+
const group = groups[index];
|
|
101
|
+
const replacement = rewrittenOutput(group.source);
|
|
102
|
+
if (!replacement) break;
|
|
103
|
+
estimated += itemTokenCount(replacement, images) - groupTokenCount(group, images);
|
|
104
|
+
consumed += group.notice ? 2 : 1;
|
|
105
|
+
rewritten.push(replacement);
|
|
106
|
+
}
|
|
107
|
+
return [...input.slice(0, input.length - consumed), ...rewritten.reverse()];
|
|
108
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Own compaction settings interaction; use Pi widgets without changing the session's model or thinking level.
|
|
2
|
+
import { getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import { getSelectListTheme, getSettingsListTheme, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Container, fuzzyFilter, Input, SelectList, SettingsList, Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import { loadCompactionSettings, type FallbackConfiguration } from "./fallback-settings.js";
|
|
6
|
+
|
|
7
|
+
const COMMAND_NAME = "codex-compaction";
|
|
8
|
+
const SUMMARY_SWITCH_LABEL = "Use separate summary model";
|
|
9
|
+
const SUMMARY_MODEL_LABEL = "Summary model and thinking level";
|
|
10
|
+
const TEXT_SUMMARY_DESCRIPTION = "Text summaries are used when V2 is off, unsupported, or fails.";
|
|
11
|
+
const MAX_VISIBLE_MODELS = 10;
|
|
12
|
+
type SettingsAction = "model" | "toggle" | "remote";
|
|
13
|
+
|
|
14
|
+
/** A fallback that failed validation stays editable: V2 can still be toggled and a new model replaces it. */
|
|
15
|
+
type FallbackState =
|
|
16
|
+
| { readonly value: FallbackConfiguration | undefined; readonly error?: never }
|
|
17
|
+
| { readonly value?: never; readonly error: string };
|
|
18
|
+
|
|
19
|
+
function readFallback(read: () => FallbackConfiguration | undefined): FallbackState {
|
|
20
|
+
try {
|
|
21
|
+
return { value: read() };
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fallbackModelLabel(selection: FallbackConfiguration | undefined): string | undefined {
|
|
28
|
+
return selection?.model === undefined ? undefined : `${selection.provider}/${selection.model} (${selection.thinkingLevel})`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function describeFallback(selection: FallbackConfiguration | undefined): string {
|
|
32
|
+
const model = fallbackModelLabel(selection);
|
|
33
|
+
if (model === undefined) return "No separate summary model configured; inactive. Pi uses the chat model.";
|
|
34
|
+
return selection?.enabled ? `Text summaries will use ${model}. The chat model is unchanged.`
|
|
35
|
+
: `Separate summary model is Off. Pi will use the chat model for text compaction. Saved model: ${model}.`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function chooseAction(
|
|
39
|
+
ctx: ExtensionCommandContext,
|
|
40
|
+
remoteCompactionEnabled: boolean,
|
|
41
|
+
fallback: FallbackState,
|
|
42
|
+
selected: SettingsAction,
|
|
43
|
+
): Promise<SettingsAction | undefined> {
|
|
44
|
+
const current = fallback.value;
|
|
45
|
+
const invalid = fallback.error !== undefined;
|
|
46
|
+
const description = fallback.error ?? `${TEXT_SUMMARY_DESCRIPTION} ${describeFallback(current)}`;
|
|
47
|
+
const remoteValue = remoteCompactionEnabled ? "On" : "Off";
|
|
48
|
+
const switchValue = invalid ? "Invalid" : current?.enabled ? "On" : "Off";
|
|
49
|
+
if (ctx.mode !== "tui") {
|
|
50
|
+
const switchLabel = `${SUMMARY_SWITCH_LABEL}: ${switchValue}`;
|
|
51
|
+
const remoteLabel = `Remote Compaction V2: ${remoteValue}`;
|
|
52
|
+
const action = await ctx.ui.select(description, [remoteLabel, switchLabel, SUMMARY_MODEL_LABEL]);
|
|
53
|
+
if (action === undefined) return undefined;
|
|
54
|
+
if (action === SUMMARY_MODEL_LABEL) return "model";
|
|
55
|
+
if (action === switchLabel) return "toggle";
|
|
56
|
+
if (action === remoteLabel) return "remote";
|
|
57
|
+
throw new Error("Select a listed compaction setting");
|
|
58
|
+
}
|
|
59
|
+
return ctx.ui.custom<SettingsAction | undefined>((tui, _theme, _keys, done) => {
|
|
60
|
+
const list = new SettingsList([
|
|
61
|
+
{ id: "remote", label: "Remote Compaction V2", currentValue: remoteValue, values: ["Off", "On"], description: "When On, try V2 with the chat model. When Off, unsupported, or failed, use text summaries. The settings below choose their model. Turning V2 off does not affect previously compressed chat history." },
|
|
62
|
+
{ id: "toggle", label: SUMMARY_SWITCH_LABEL, currentValue: switchValue, values: invalid ? ["Invalid"] : ["Off", "On"], description },
|
|
63
|
+
{ id: "model", label: SUMMARY_MODEL_LABEL, currentValue: invalid ? "Invalid" : fallbackModelLabel(current) ?? "Not configured", values: ["Choose"], description: invalid ? "Choose a model to replace the invalid fallback settings." : `Choose the model and thinking level used when "${SUMMARY_SWITCH_LABEL}" is On. Choosing a model does not enable the switch or change the chat model. ${TEXT_SUMMARY_DESCRIPTION}` },
|
|
64
|
+
], 3, getSettingsListTheme(), (id) => done(id === "toggle" ? "toggle" : id === "remote" ? "remote" : "model"), () => done(undefined));
|
|
65
|
+
list.selectItem(selected);
|
|
66
|
+
return {
|
|
67
|
+
render: (width) => list.render(width),
|
|
68
|
+
invalidate: () => list.invalidate(),
|
|
69
|
+
handleInput: (data) => { list.handleInput(data); tui.requestRender(); },
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function modelLabel(model: Model<Api>): string {
|
|
75
|
+
return `${model.provider}/${model.id} (${model.name})`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function chooseModel(ctx: ExtensionCommandContext, models: readonly Model<Api>[]): Promise<Model<Api> | undefined> {
|
|
79
|
+
if (ctx.mode !== "tui") {
|
|
80
|
+
const query = await ctx.ui.input("Search summary models (provider, ID, or name)", "Leave empty to show all models");
|
|
81
|
+
if (query === undefined) return undefined;
|
|
82
|
+
const matches = fuzzyFilter([...models], query, modelLabel);
|
|
83
|
+
const chosen = await ctx.ui.select("Choose the model for text summaries", matches.map(modelLabel));
|
|
84
|
+
if (chosen === undefined) return undefined;
|
|
85
|
+
const model = matches.find((candidate) => modelLabel(candidate) === chosen);
|
|
86
|
+
if (!model) throw new Error("Select a listed fallback model");
|
|
87
|
+
return model;
|
|
88
|
+
}
|
|
89
|
+
return ctx.ui.custom<Model<Api> | undefined>((tui, _theme, keys, done) => {
|
|
90
|
+
const input = new Input();
|
|
91
|
+
const container = new Container();
|
|
92
|
+
let list: SelectList;
|
|
93
|
+
const update = () => {
|
|
94
|
+
const matches = fuzzyFilter([...models], input.getValue(), modelLabel);
|
|
95
|
+
list = new SelectList(matches.map((model, index) => ({ value: String(index), label: model.id, description: `${model.provider} · ${model.name}` })), MAX_VISIBLE_MODELS, getSelectListTheme());
|
|
96
|
+
list.onSelect = (item) => done(matches[Number(item.value)]);
|
|
97
|
+
list.onCancel = () => done(undefined);
|
|
98
|
+
container.clear();
|
|
99
|
+
container.addChild(new Text("Summary model: type to search by provider, ID, or name", 0, 0));
|
|
100
|
+
container.addChild(input);
|
|
101
|
+
container.addChild(list);
|
|
102
|
+
};
|
|
103
|
+
update();
|
|
104
|
+
return {
|
|
105
|
+
get focused() { return input.focused; },
|
|
106
|
+
set focused(value: boolean) { input.focused = value; },
|
|
107
|
+
render: (width) => container.render(width),
|
|
108
|
+
invalidate: () => container.invalidate(),
|
|
109
|
+
handleInput(data) {
|
|
110
|
+
if ((["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"] as const).some((action) => keys.matches(data, action))) {
|
|
111
|
+
list.handleInput(data);
|
|
112
|
+
} else {
|
|
113
|
+
input.handleInput(data);
|
|
114
|
+
update();
|
|
115
|
+
}
|
|
116
|
+
tui.requestRender();
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function configureCompaction(path: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
123
|
+
if (!ctx.hasUI) {
|
|
124
|
+
ctx.ui.notify(`/${COMMAND_NAME} requires interactive or RPC mode; edit ${path} directly.`, "warning");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
let selected: SettingsAction = "remote";
|
|
128
|
+
while (true) {
|
|
129
|
+
const settings = await loadCompactionSettings(path);
|
|
130
|
+
const configuration = settings.configuration;
|
|
131
|
+
const fallback = readFallback(() => configuration.fallback);
|
|
132
|
+
const current = fallback.value;
|
|
133
|
+
const action = await chooseAction(ctx, configuration.remoteCompactionEnabled, fallback, selected);
|
|
134
|
+
if (action === undefined) return;
|
|
135
|
+
selected = action;
|
|
136
|
+
if (action === "remote") {
|
|
137
|
+
const remoteCompactionEnabled = !configuration.remoteCompactionEnabled;
|
|
138
|
+
// Save a valid fallback in normalized form; omit an invalid one so the file keeps it as written.
|
|
139
|
+
await settings.save(fallback.error === undefined ? { remoteCompactionEnabled, fallback: current } : { remoteCompactionEnabled });
|
|
140
|
+
ctx.ui.notify(remoteCompactionEnabled
|
|
141
|
+
? "Remote Compaction V2 is On. Supported models try V2 before text fallback."
|
|
142
|
+
: "Remote Compaction V2 is Off. Text compaction uses the configured fallback when enabled; otherwise Pi uses the chat model.", "info");
|
|
143
|
+
} else if (action === "toggle" && fallback.error !== undefined) {
|
|
144
|
+
ctx.ui.notify(`${fallback.error}. Choose a model to replace it, or edit the file directly.`, "error");
|
|
145
|
+
} else if (action === "toggle") {
|
|
146
|
+
const selection = { ...current, enabled: !current?.enabled };
|
|
147
|
+
await settings.save({ remoteCompactionEnabled: configuration.remoteCompactionEnabled, fallback: selection });
|
|
148
|
+
ctx.ui.notify(describeFallback(selection), "info");
|
|
149
|
+
} else {
|
|
150
|
+
const models = [...ctx.modelRegistry.getAvailable()].sort((left, right) => {
|
|
151
|
+
const a = modelLabel(left);
|
|
152
|
+
const b = modelLabel(right);
|
|
153
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
154
|
+
});
|
|
155
|
+
if (models.length === 0) {
|
|
156
|
+
ctx.ui.notify("No authenticated models are available. Configure a model in Pi first.", "warning");
|
|
157
|
+
} else {
|
|
158
|
+
const model = await chooseModel(ctx, models);
|
|
159
|
+
if (model !== undefined) {
|
|
160
|
+
const levels = getSupportedThinkingLevels(model);
|
|
161
|
+
const chosenLevel = await ctx.ui.select(`Thinking level for ${model.provider}/${model.id}`, [...levels]);
|
|
162
|
+
if (chosenLevel !== undefined) {
|
|
163
|
+
const thinkingLevel = levels.find((level) => level === chosenLevel);
|
|
164
|
+
if (thinkingLevel === undefined) throw new Error("Select a supported fallback thinking level");
|
|
165
|
+
const selection = { enabled: current?.enabled ?? false, provider: model.provider, model: model.id, thinkingLevel };
|
|
166
|
+
await settings.save({ remoteCompactionEnabled: configuration.remoteCompactionEnabled, fallback: selection });
|
|
167
|
+
ctx.ui.notify(describeFallback(selection), "info");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// TUI keeps the same row selected so repeated Enter presses toggle in place.
|
|
173
|
+
if (ctx.mode !== "tui") return;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function registerCompactionCommand(pi: ExtensionAPI, path: string): void {
|
|
178
|
+
pi.registerCommand(COMMAND_NAME, {
|
|
179
|
+
description: "Configure V2 and fallback compaction settings",
|
|
180
|
+
handler: async (args, ctx) => {
|
|
181
|
+
try {
|
|
182
|
+
if (args.trim()) throw new Error(`Usage: /${COMMAND_NAME}`);
|
|
183
|
+
await configureCompaction(path, ctx);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
ctx.ui.notify(error instanceof Error ? error.message : "Could not configure compaction settings", "error");
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Own compaction settings, lazy fallback validation, and revision-bound file updates.
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { lstat, mkdir, open, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { isObject } from "./protocol.js";
|
|
7
|
+
|
|
8
|
+
export const COMPACTION_SETTINGS_RELATIVE_PATH = "extensions/pi-codex-compaction/config.json";
|
|
9
|
+
const LEGACY_FALLBACK_ENABLED = true;
|
|
10
|
+
export const DEFAULT_REMOTE_COMPACTION_ENABLED = true;
|
|
11
|
+
const SETTINGS_VERSION = 1;
|
|
12
|
+
const MAX_SETTINGS_BYTES = 16 * 1024;
|
|
13
|
+
const DEFAULT_FILE_MODE = 0o600;
|
|
14
|
+
const FILE_PERMISSION_MASK = 0o777;
|
|
15
|
+
|
|
16
|
+
export type FallbackConfiguration = { readonly enabled: boolean } & (
|
|
17
|
+
| { readonly provider: string; readonly model: string; readonly thinkingLevel: string }
|
|
18
|
+
| { readonly provider?: never; readonly model?: never; readonly thinkingLevel?: never }
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export interface CompactionConfiguration {
|
|
22
|
+
readonly remoteCompactionEnabled: boolean;
|
|
23
|
+
readonly fallback: FallbackConfiguration | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SettingsFile {
|
|
27
|
+
readonly bytes: Buffer | undefined;
|
|
28
|
+
readonly mode: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readSettingsFile(path: string): Promise<SettingsFile> {
|
|
32
|
+
let file;
|
|
33
|
+
try {
|
|
34
|
+
file = await open(path, "r");
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (isObject(error) && error.code === "ENOENT") return { bytes: undefined, mode: DEFAULT_FILE_MODE };
|
|
37
|
+
throw new Error(`Could not open compaction settings at ${path}`, { cause: error });
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const stat = await file.stat();
|
|
41
|
+
if (!stat.isFile()) throw new Error("Compaction settings must be a regular file");
|
|
42
|
+
const bytes = Buffer.alloc(MAX_SETTINGS_BYTES + 1);
|
|
43
|
+
let length = 0;
|
|
44
|
+
while (length < bytes.length) {
|
|
45
|
+
const { bytesRead } = await file.read(bytes, length, bytes.length - length, length);
|
|
46
|
+
if (bytesRead === 0) break;
|
|
47
|
+
length += bytesRead;
|
|
48
|
+
}
|
|
49
|
+
if (length > MAX_SETTINGS_BYTES) throw new Error(`Compaction settings must not exceed ${MAX_SETTINGS_BYTES} bytes`);
|
|
50
|
+
return { bytes: bytes.subarray(0, length), mode: stat.mode & FILE_PERMISSION_MASK };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new Error(`Could not read compaction settings at ${path}; use a UTF-8 JSON object within ${MAX_SETTINGS_BYTES} bytes`, { cause: error });
|
|
53
|
+
} finally {
|
|
54
|
+
await file.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function settingsError(path: string, error: unknown): Error {
|
|
59
|
+
const reason = error instanceof SyntaxError || error instanceof TypeError ? "use a UTF-8 JSON object"
|
|
60
|
+
: error instanceof Error ? error.message : "invalid configuration";
|
|
61
|
+
return new Error(`Could not read compaction settings at ${path}; ${reason}`, { cause: error });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ParsedSettings {
|
|
65
|
+
readonly configuration: CompactionConfiguration;
|
|
66
|
+
/** The unvalidated fallback field, kept so saves that do not replace it preserve it as written. */
|
|
67
|
+
readonly rawFallback: { readonly value: unknown } | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseSettings(bytes: Buffer | undefined, path: string): ParsedSettings {
|
|
71
|
+
if (bytes === undefined) {
|
|
72
|
+
return { configuration: { remoteCompactionEnabled: DEFAULT_REMOTE_COMPACTION_ENABLED, fallback: undefined }, rawFallback: undefined };
|
|
73
|
+
}
|
|
74
|
+
// Accept a UTF-8 BOM, reject invalid bytes, and never echo file contents in errors.
|
|
75
|
+
const settings: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
76
|
+
if (!isObject(settings) || Object.keys(settings).some((key) => !["version", "remoteCompaction", "fallback"].includes(key))) {
|
|
77
|
+
throw new Error("Compaction settings must be an object with version and optional remoteCompaction and fallback fields");
|
|
78
|
+
}
|
|
79
|
+
if (("fallback" in settings || "remoteCompaction" in settings) && settings.version !== SETTINGS_VERSION) {
|
|
80
|
+
throw new Error(`version must be ${SETTINGS_VERSION} when compaction settings are configured`);
|
|
81
|
+
}
|
|
82
|
+
let remoteCompactionEnabled = DEFAULT_REMOTE_COMPACTION_ENABLED;
|
|
83
|
+
if ("remoteCompaction" in settings) {
|
|
84
|
+
const remote = settings.remoteCompaction;
|
|
85
|
+
if (!isObject(remote) || Object.keys(remote).some((key) => key !== "enabled") || typeof remote.enabled !== "boolean") {
|
|
86
|
+
throw new Error("remoteCompaction must be an object with a boolean enabled field");
|
|
87
|
+
}
|
|
88
|
+
remoteCompactionEnabled = remote.enabled;
|
|
89
|
+
}
|
|
90
|
+
let resolvedFallback: { readonly value: FallbackConfiguration | undefined } | undefined;
|
|
91
|
+
return {
|
|
92
|
+
configuration: {
|
|
93
|
+
remoteCompactionEnabled,
|
|
94
|
+
// Successful V2 requests do not need to resolve or validate fallback settings.
|
|
95
|
+
get fallback() {
|
|
96
|
+
try {
|
|
97
|
+
resolvedFallback ??= { value: "fallback" in settings ? parseFallback(settings.fallback) : undefined };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw settingsError(path, error);
|
|
100
|
+
}
|
|
101
|
+
return resolvedFallback.value;
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
rawFallback: "fallback" in settings ? { value: settings.fallback } : undefined,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseFallback(fallback: unknown): FallbackConfiguration {
|
|
109
|
+
if (!isObject(fallback) || Object.keys(fallback).some((key) => !["enabled", "provider", "model", "thinkingLevel"].includes(key))) {
|
|
110
|
+
throw new Error("fallback must be an object with only enabled, provider, model, and thinkingLevel fields");
|
|
111
|
+
}
|
|
112
|
+
if ("enabled" in fallback && typeof fallback.enabled !== "boolean") {
|
|
113
|
+
throw new Error("fallback.enabled must be a boolean");
|
|
114
|
+
}
|
|
115
|
+
const enabled = typeof fallback.enabled === "boolean" ? fallback.enabled : LEGACY_FALLBACK_ENABLED;
|
|
116
|
+
if (!("provider" in fallback) && !("model" in fallback) && !("thinkingLevel" in fallback)) {
|
|
117
|
+
return { enabled };
|
|
118
|
+
}
|
|
119
|
+
if (typeof fallback.provider !== "string" || !fallback.provider.trim() || fallback.provider !== fallback.provider.trim()) {
|
|
120
|
+
throw new Error("fallback.provider must be a non-empty provider ID without surrounding whitespace");
|
|
121
|
+
}
|
|
122
|
+
if (typeof fallback.model !== "string" || !fallback.model.trim() || fallback.model !== fallback.model.trim()) {
|
|
123
|
+
throw new Error("fallback.model must be a non-empty model ID without surrounding whitespace");
|
|
124
|
+
}
|
|
125
|
+
if (typeof fallback.thinkingLevel !== "string" || !fallback.thinkingLevel.trim()) {
|
|
126
|
+
throw new Error("fallback.thinkingLevel must be a non-empty thinking level");
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
enabled,
|
|
130
|
+
provider: fallback.provider, model: fallback.model, thinkingLevel: fallback.thinkingLevel,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Capture one revision; reading configuration.fallback validates it on demand and can throw.
|
|
136
|
+
* Saving detects edits before atomic replacement; a selection without a fallback field keeps the file's fallback as written.
|
|
137
|
+
*/
|
|
138
|
+
export async function loadCompactionSettings(path: string) {
|
|
139
|
+
const original = await readSettingsFile(path);
|
|
140
|
+
let parsed: ParsedSettings;
|
|
141
|
+
try {
|
|
142
|
+
parsed = parseSettings(original.bytes, path);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw settingsError(path, error);
|
|
145
|
+
}
|
|
146
|
+
const { configuration, rawFallback } = parsed;
|
|
147
|
+
return {
|
|
148
|
+
configuration,
|
|
149
|
+
async save(selection: { readonly remoteCompactionEnabled: boolean; readonly fallback?: FallbackConfiguration | undefined }): Promise<void> {
|
|
150
|
+
const fallback = "fallback" in selection ? selection.fallback : rawFallback?.value;
|
|
151
|
+
await withFileMutationQueue(path, async () => {
|
|
152
|
+
await mkdir(dirname(path), { recursive: true });
|
|
153
|
+
const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
154
|
+
const bytes = Buffer.from(`${JSON.stringify({
|
|
155
|
+
version: SETTINGS_VERSION,
|
|
156
|
+
remoteCompaction: { enabled: selection.remoteCompactionEnabled },
|
|
157
|
+
...(fallback === undefined ? {} : { fallback }),
|
|
158
|
+
}, null, 2)}\n`);
|
|
159
|
+
if (bytes.length > MAX_SETTINGS_BYTES) throw new Error(`Compaction settings must not exceed ${MAX_SETTINGS_BYTES} bytes`);
|
|
160
|
+
try {
|
|
161
|
+
await writeFile(temporary, bytes, { flag: "wx", mode: original.mode });
|
|
162
|
+
const current = await readSettingsFile(path);
|
|
163
|
+
if (original.bytes === undefined ? current.bytes !== undefined : !current.bytes?.equals(original.bytes)) {
|
|
164
|
+
throw new Error("Compaction settings changed while the menu was open; reopen /codex-compaction and try again");
|
|
165
|
+
}
|
|
166
|
+
// Reading follows existing symlinks; an atomic replacement would remove the link itself.
|
|
167
|
+
const entry = await lstat(path).catch((error: unknown) => {
|
|
168
|
+
if (isObject(error) && error.code === "ENOENT") return undefined;
|
|
169
|
+
throw error;
|
|
170
|
+
});
|
|
171
|
+
if (entry?.isSymbolicLink()) {
|
|
172
|
+
throw new Error(`Compaction settings are a symbolic link; edit its target directly at ${path}`);
|
|
173
|
+
}
|
|
174
|
+
await rename(temporary, path);
|
|
175
|
+
} finally {
|
|
176
|
+
await rm(temporary, { force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
package/src/fallback.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Own configured fallback selection and Pi's native text summarization through the model registry.
|
|
2
|
+
import { getSupportedThinkingLevels, type Api, type AssistantMessage, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
|
+
import { compact, SettingsManager, type ExtensionAPI, type ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { FallbackConfiguration } from "./fallback-settings.js";
|
|
5
|
+
|
|
6
|
+
const REQUEST_TIMEOUT_MS = 300_000;
|
|
7
|
+
|
|
8
|
+
interface FallbackModel {
|
|
9
|
+
readonly model: Model<Api>;
|
|
10
|
+
readonly thinkingLevel: ModelThinkingLevel;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FallbackCompactionRequest {
|
|
14
|
+
readonly configuration: FallbackConfiguration | undefined;
|
|
15
|
+
readonly modelRegistry: ModelRegistry;
|
|
16
|
+
readonly preparation: Parameters<typeof compact>[0];
|
|
17
|
+
readonly settings: ReturnType<ExtensionAPI["getSettings"]>;
|
|
18
|
+
readonly customInstructions?: string;
|
|
19
|
+
readonly signal: AbortSignal;
|
|
20
|
+
readonly sessionId: string;
|
|
21
|
+
/** Check session ownership before preparation and each provider attempt. */
|
|
22
|
+
readonly onPrepared: (selection: FallbackModel) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function selectFallback(fallback: FallbackConfiguration | undefined, registry: ModelRegistry): FallbackModel | undefined {
|
|
26
|
+
if (!fallback?.enabled || fallback.model === undefined) return undefined;
|
|
27
|
+
const model = registry.find(fallback.provider, fallback.model);
|
|
28
|
+
if (!model) throw new Error(`fallback.model must name a configured model: ${fallback.provider}/${fallback.model}`);
|
|
29
|
+
const supportedLevels = getSupportedThinkingLevels(model);
|
|
30
|
+
const thinkingLevel = supportedLevels.find((level) => level === fallback.thinkingLevel);
|
|
31
|
+
if (thinkingLevel === undefined) {
|
|
32
|
+
throw new Error(`fallback.thinkingLevel must be supported by ${model.provider}/${model.id}: ${supportedLevels.join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
return { model, thinkingLevel };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Return undefined when fallback is disabled or unconfigured. Failures must stop native compaction on the chat model. */
|
|
38
|
+
export async function requestFallbackCompaction(request: FallbackCompactionRequest) {
|
|
39
|
+
request.signal.throwIfAborted();
|
|
40
|
+
const selection = selectFallback(request.configuration, request.modelRegistry);
|
|
41
|
+
request.signal.throwIfAborted();
|
|
42
|
+
if (!selection) return undefined;
|
|
43
|
+
request.onPrepared(selection);
|
|
44
|
+
const settings = SettingsManager.inMemory(request.settings);
|
|
45
|
+
const providerRetry = settings.getProviderRetrySettings();
|
|
46
|
+
// pi-virtual limits are display hints. Zero lets native compact use its reserve budget;
|
|
47
|
+
// streamSimple routes the original model and caps each request to the physical model's limit.
|
|
48
|
+
const budgetModel = selection.model.api === "pi-virtual" ? { ...selection.model, maxTokens: 0 } : selection.model;
|
|
49
|
+
// Pi 0.99.1 can append file lists to empty or provider-aborted summaries; inspect the actual completions.
|
|
50
|
+
const responses: Promise<AssistantMessage>[] = [];
|
|
51
|
+
const result = await compact(
|
|
52
|
+
request.preparation,
|
|
53
|
+
budgetModel,
|
|
54
|
+
undefined,
|
|
55
|
+
undefined,
|
|
56
|
+
request.customInstructions,
|
|
57
|
+
request.signal,
|
|
58
|
+
selection.thinkingLevel,
|
|
59
|
+
(_model, context, options) => {
|
|
60
|
+
const stream = request.modelRegistry.streamSimple(selection.model, context, {
|
|
61
|
+
...options,
|
|
62
|
+
transport: settings.getTransport(),
|
|
63
|
+
thinkingBudgets: settings.getThinkingBudgets(),
|
|
64
|
+
websocketConnectTimeoutMs: settings.getWebSocketConnectTimeoutMs(),
|
|
65
|
+
timeoutMs: providerRetry.timeoutMs ?? REQUEST_TIMEOUT_MS,
|
|
66
|
+
maxRetries: providerRetry.maxRetries,
|
|
67
|
+
maxRetryDelayMs: providerRetry.maxRetryDelayMs,
|
|
68
|
+
onPayload: (payload) => {
|
|
69
|
+
request.onPrepared(selection);
|
|
70
|
+
return payload;
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
responses.push(stream.result());
|
|
74
|
+
return stream;
|
|
75
|
+
},
|
|
76
|
+
undefined,
|
|
77
|
+
settings.getRetrySettings(),
|
|
78
|
+
undefined,
|
|
79
|
+
request.sessionId,
|
|
80
|
+
);
|
|
81
|
+
request.signal.throwIfAborted();
|
|
82
|
+
request.onPrepared(selection);
|
|
83
|
+
for (const response of responses) {
|
|
84
|
+
const message = await response;
|
|
85
|
+
if (message.stopReason === "aborted") throw new Error("Fallback compaction was aborted by the provider");
|
|
86
|
+
// Pi owns retrying error responses; validate only completions it accepted.
|
|
87
|
+
if (message.stopReason !== "error" && !message.content.some((block) => block.type === "text" && block.text.trim())) {
|
|
88
|
+
throw new Error("Fallback compaction returned an empty summary");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { compaction: result };
|
|
92
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Own Codex's history item groups and the marked-text matching that contextual fragments share.
|
|
2
|
+
// Reference: openai/codex rust-v0.159.2, compact_remote_history.rs and context-fragments/src/fragment.rs.
|
|
3
|
+
import type { JsonObject } from "./protocol.js";
|
|
4
|
+
|
|
5
|
+
const RESIZE_NOTICE_MARKERS = ["<image_resize_notice>", "</image_resize_notice>"] as const;
|
|
6
|
+
|
|
7
|
+
export interface HistoryGroup {
|
|
8
|
+
readonly source: JsonObject;
|
|
9
|
+
readonly notice?: JsonObject;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Rust str::trim uses Unicode White_Space; JS trim additionally removes the BOM. */
|
|
13
|
+
export function trimWhiteSpace(text: string): string {
|
|
14
|
+
return text.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Match Codex's ASCII case-insensitive start and end markers on already trimmed text. */
|
|
18
|
+
export function matchesMarkedText(text: string, [open, close]: readonly [string, string]): boolean {
|
|
19
|
+
const asciiLower = (value: string) => value.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
|
|
20
|
+
return asciiLower(text.slice(0, open.length)) === asciiLower(open) &&
|
|
21
|
+
asciiLower(text.slice(-close.length)) === asciiLower(close);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Trimmed text of an input_text part, or undefined for any other part. */
|
|
25
|
+
export function inputText(value: unknown): string | undefined {
|
|
26
|
+
return typeof value === "object" && value !== null && "type" in value && value.type === "input_text" &&
|
|
27
|
+
"text" in value && typeof value.text === "string" ? trimWhiteSpace(value.text) : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isResizeNotice(item: JsonObject): boolean {
|
|
31
|
+
if ((item.type !== undefined && item.type !== "message") || item.role !== "developer" ||
|
|
32
|
+
!Array.isArray(item.content) || item.content.length !== 1) return false;
|
|
33
|
+
const text = inputText(item.content[0]);
|
|
34
|
+
return text !== undefined && matchesMarkedText(text, RESIZE_NOTICE_MARKERS);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Group each item with an immediately following resize notice, as Codex's history_item_groups does. */
|
|
38
|
+
export function historyGroups(input: readonly JsonObject[]): HistoryGroup[] {
|
|
39
|
+
const groups: HistoryGroup[] = [];
|
|
40
|
+
for (let index = 0; index < input.length; index++) {
|
|
41
|
+
const next = input[index + 1];
|
|
42
|
+
groups.push({ source: input[index], notice: next && isResizeNotice(next) ? input[++index] : undefined });
|
|
43
|
+
}
|
|
44
|
+
return groups;
|
|
45
|
+
}
|