pi-codex-compaction 0.1.4 → 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 +29 -0
- package/CONTRIBUTING.md +42 -40
- package/README.md +178 -105
- package/SECURITY.md +68 -40
- package/extensions/index.ts +3 -123
- package/package.json +9 -9
- package/src/automatic-compaction.ts +390 -0
- package/src/index.ts +1 -2
- package/src/codex-wire.ts +0 -513
- package/src/remote-compaction.ts +0 -623
package/extensions/index.ts
CHANGED
|
@@ -1,128 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ExtensionAPI,
|
|
3
|
-
ExtensionHandler,
|
|
4
|
-
SessionBeforeCompactEvent,
|
|
5
|
-
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { BETA_FEATURE, getCodexAccountFingerprint } from "../src/codex-wire.js";
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
2
|
import { reportInstallTelemetry } from "../src/install-telemetry.js";
|
|
8
|
-
import {
|
|
9
|
-
applyRemoteCompactionMarker,
|
|
10
|
-
COMPACTION_FALLBACK_ENTRY,
|
|
11
|
-
type CompactionFallback,
|
|
12
|
-
createRemoteCompaction,
|
|
13
|
-
findActiveRemoteCompaction,
|
|
14
|
-
getCodexAuthKind,
|
|
15
|
-
isRemoteCompactionCompatible,
|
|
16
|
-
supportsRemoteCompaction,
|
|
17
|
-
} from "../src/remote-compaction.js";
|
|
18
|
-
|
|
19
|
-
const FALLBACK_MESSAGES: Record<CompactionFallback["reason"], string> = {
|
|
20
|
-
"custom-instructions": "custom compaction instructions require the standard compactor",
|
|
21
|
-
"auth-unavailable": "Codex authentication is unavailable",
|
|
22
|
-
"request-unavailable": "the Codex request could not be prepared",
|
|
23
|
-
"context-window-unavailable": "the active model's context limit is unavailable",
|
|
24
|
-
"context-limit": "the estimated input exceeds the active model's token budget",
|
|
25
|
-
"request-size-limit": "the request exceeds the 16 MiB byte limit",
|
|
26
|
-
"remote-failed": "the remote request failed",
|
|
27
|
-
};
|
|
3
|
+
import { registerAutomaticCompaction } from "../src/automatic-compaction.js";
|
|
28
4
|
|
|
29
5
|
export default function piCodexCompaction(pi: ExtensionAPI): void {
|
|
30
6
|
reportInstallTelemetry();
|
|
31
|
-
|
|
32
|
-
const onBeforeCompact: ExtensionHandler<SessionBeforeCompactEvent, { compaction?: NonNullable<Awaited<ReturnType<typeof createRemoteCompaction>>> }> = async (event, ctx) => {
|
|
33
|
-
if (!supportsRemoteCompaction(ctx.model)) return undefined;
|
|
34
|
-
|
|
35
|
-
let reported = false;
|
|
36
|
-
const reportFallback = (diagnostic: CompactionFallback) => {
|
|
37
|
-
if (reported || event.signal.aborted) return;
|
|
38
|
-
reported = true;
|
|
39
|
-
// No text, model/account identifiers, headers, or provider errors belong
|
|
40
|
-
// in diagnostics. Custom entries do not enter the model's context.
|
|
41
|
-
const data: Record<string, unknown> = { version: 1, reason: diagnostic.reason };
|
|
42
|
-
for (const key of ["estimatedTokens", "tokenBudget", "requestBytes", "byteLimit", "trimmedToolOutputs"] as const) {
|
|
43
|
-
const value = diagnostic[key];
|
|
44
|
-
if (typeof value === "number" && Number.isFinite(value) && value >= 0) data[key] = value;
|
|
45
|
-
}
|
|
46
|
-
try {
|
|
47
|
-
pi.appendEntry(COMPACTION_FALLBACK_ENTRY, data);
|
|
48
|
-
} catch {
|
|
49
|
-
// Diagnostics must not prevent the standard compactor from running.
|
|
50
|
-
}
|
|
51
|
-
if (ctx.mode === "tui" && ctx.hasUI) {
|
|
52
|
-
try {
|
|
53
|
-
ctx.ui.notify(`Codex remote compaction skipped: ${FALLBACK_MESSAGES[diagnostic.reason]}. Using standard Pi compaction.`, "warning");
|
|
54
|
-
} catch {
|
|
55
|
-
// Notification failure must not change compaction behavior either.
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
try {
|
|
60
|
-
const compaction = await createRemoteCompaction(event, ctx, () => {
|
|
61
|
-
const active = new Set(pi.getActiveTools());
|
|
62
|
-
const data = {
|
|
63
|
-
model: ctx.model,
|
|
64
|
-
tools: pi.getAllTools().filter((tool) => active.has(tool.name)),
|
|
65
|
-
};
|
|
66
|
-
// Pi's public ToolInfo omits constrainedSampling. Tool owners can supply
|
|
67
|
-
// it here without exposing executors or inspecting private registries.
|
|
68
|
-
pi.events?.emit("pi-codex-compaction:tools:v1", data);
|
|
69
|
-
return data.tools;
|
|
70
|
-
}, pi.getThinkingLevel?.(), (payload, messages) => {
|
|
71
|
-
const data = { payload, messages, ctx };
|
|
72
|
-
// Synchronous bus contract: apply pure request transforms before bounds
|
|
73
|
-
// and before network I/O. Never include auth in this event.
|
|
74
|
-
pi.events?.emit("pi-codex-compaction:request:v1", data);
|
|
75
|
-
return data.payload;
|
|
76
|
-
}, reportFallback);
|
|
77
|
-
return compaction ? { compaction } : undefined;
|
|
78
|
-
} catch {
|
|
79
|
-
// Transport failures already have a reason; other exceptions mean the
|
|
80
|
-
// compaction request could not be prepared or processed.
|
|
81
|
-
reportFallback({ reason: "request-unavailable" });
|
|
82
|
-
return undefined;
|
|
83
|
-
}
|
|
84
|
-
};
|
|
85
|
-
pi.on("session_before_compact", onBeforeCompact);
|
|
86
|
-
|
|
87
|
-
pi.on("session_compact", (event, ctx) => {
|
|
88
|
-
if (ctx.mode !== "tui" || !ctx.hasUI || !event.fromExtension) return;
|
|
89
|
-
if (!findActiveRemoteCompaction([event.compactionEntry])) return;
|
|
90
|
-
try {
|
|
91
|
-
ctx.ui.notify("[compaction (codex)] Checkpoint saved.", "info");
|
|
92
|
-
} catch {
|
|
93
|
-
// Display failures must not affect a saved checkpoint or continuation.
|
|
94
|
-
}
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
pi.on("before_provider_headers", (event, ctx) => {
|
|
98
|
-
if (!supportsRemoteCompaction(ctx.model)) return;
|
|
99
|
-
const existing = event.headers["x-codex-beta-features"];
|
|
100
|
-
const features = existing?.split(",").map((feature) => feature.trim()).filter(Boolean) ?? [];
|
|
101
|
-
if (!features.includes(BETA_FEATURE)) features.push(BETA_FEATURE);
|
|
102
|
-
event.headers["x-codex-beta-features"] = features.join(",");
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
pi.on("before_provider_request", (event, ctx) => {
|
|
106
|
-
if (!supportsRemoteCompaction(ctx.model)) return;
|
|
107
|
-
const details = findActiveRemoteCompaction(ctx.sessionManager.buildContextEntries());
|
|
108
|
-
if (!details) return;
|
|
109
|
-
|
|
110
|
-
return (async () => {
|
|
111
|
-
const model = ctx.model;
|
|
112
|
-
if (!model) return undefined;
|
|
113
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
114
|
-
if (!auth.ok || !auth.apiKey) return undefined;
|
|
115
|
-
|
|
116
|
-
let accountFingerprint: string;
|
|
117
|
-
try {
|
|
118
|
-
accountFingerprint = getCodexAccountFingerprint(auth.apiKey);
|
|
119
|
-
} catch {
|
|
120
|
-
return undefined;
|
|
121
|
-
}
|
|
122
|
-
if (!isRemoteCompactionCompatible(details, model, accountFingerprint, getCodexAuthKind(ctx.modelRegistry, model))) {
|
|
123
|
-
return undefined;
|
|
124
|
-
}
|
|
125
|
-
return applyRemoteCompactionMarker(event.payload, details);
|
|
126
|
-
})();
|
|
127
|
-
});
|
|
7
|
+
registerAutomaticCompaction(pi);
|
|
128
8
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-codex-compaction",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Keep Pi sessions flowing with automatic server-side compaction on public OpenAI Responses.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Jose Mocito",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"pi-extension",
|
|
20
20
|
"pi",
|
|
21
21
|
"compaction",
|
|
22
|
-
"openai
|
|
23
|
-
"
|
|
22
|
+
"openai",
|
|
23
|
+
"server-compaction"
|
|
24
24
|
],
|
|
25
25
|
"exports": {
|
|
26
26
|
".": "./src/index.ts"
|
|
@@ -52,17 +52,17 @@
|
|
|
52
52
|
"@earendil-works/pi-coding-agent": "*"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@earendil-works/pi-ai": "
|
|
56
|
-
"@earendil-works/pi-coding-agent": "
|
|
57
|
-
"@types/node": "^26.
|
|
58
|
-
"tsx": "^4.23.
|
|
55
|
+
"@earendil-works/pi-ai": "1.1.0",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "1.1.0",
|
|
57
|
+
"@types/node": "^26.6.3",
|
|
58
|
+
"tsx": "^4.23.15",
|
|
59
59
|
"typescript": "^7.0.2"
|
|
60
60
|
},
|
|
61
61
|
"publishConfig": {
|
|
62
62
|
"access": "public"
|
|
63
63
|
},
|
|
64
64
|
"engines": {
|
|
65
|
-
"node": ">=
|
|
65
|
+
"node": ">=22.19.0"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@mocito/install-telemetry": "0.1.1"
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { createHash, createHmac, randomUUID } from "node:crypto";
|
|
2
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import { convertResponsesMessages } from "@earendil-works/pi-ai/api/openai-responses-shared";
|
|
4
|
+
import { createGrammarToolInputProperties } from "@earendil-works/pi-ai/api/constrained-sampling";
|
|
5
|
+
import { getCurrentSystemMessage, normalizeContext } from "@earendil-works/pi-ai/utils/transcript";
|
|
6
|
+
import {
|
|
7
|
+
convertToLlm,
|
|
8
|
+
serializeConversation,
|
|
9
|
+
type ExtensionAPI,
|
|
10
|
+
type ExtensionContext,
|
|
11
|
+
type SessionEntry,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
const KIND = "pi-codex-compaction:automatic:v1";
|
|
15
|
+
const CONFIG = `${KIND}:config`;
|
|
16
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
17
|
+
const MAX_ITEMS = 1024;
|
|
18
|
+
const ENDPOINT = "https://api.openai.com/v1";
|
|
19
|
+
type Item = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
interface Checkpoint {
|
|
22
|
+
kind: typeof KIND;
|
|
23
|
+
model: string;
|
|
24
|
+
identity: string;
|
|
25
|
+
summary: string;
|
|
26
|
+
assistantEntryId: string;
|
|
27
|
+
assistantHash: string;
|
|
28
|
+
/** Fingerprints of every normalized output item, including those before the checkpoint. */
|
|
29
|
+
outputHashes: string[];
|
|
30
|
+
/** Exact provider checkpoint followed by the exact output suffix. */
|
|
31
|
+
replay: Item[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Attempt {
|
|
35
|
+
sessionId: string;
|
|
36
|
+
model: string;
|
|
37
|
+
identity: string;
|
|
38
|
+
leafId: string | null;
|
|
39
|
+
responseId?: string;
|
|
40
|
+
items: Map<number, Item>;
|
|
41
|
+
itemBytes: number;
|
|
42
|
+
output?: Item[];
|
|
43
|
+
invalid: boolean;
|
|
44
|
+
completed: boolean;
|
|
45
|
+
started: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function record(value: unknown): value is Item {
|
|
49
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function bytes(value: unknown): number {
|
|
53
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hash(value: unknown): string {
|
|
57
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Canonical object ordering, for argument JSON and content comparison. */
|
|
61
|
+
function sorted(value: unknown): unknown {
|
|
62
|
+
if (Array.isArray(value)) return value.map(sorted);
|
|
63
|
+
if (!record(value)) return value;
|
|
64
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compare provider output with Pi's replay serialization. Ignore transport-only
|
|
69
|
+
* status/annotations, but never ignore text, tool arguments, identity or reasoning.
|
|
70
|
+
* Unknown output shapes fail closed instead of inventing a replay mapping.
|
|
71
|
+
*/
|
|
72
|
+
function outputHash(item: unknown): string | undefined {
|
|
73
|
+
if (!record(item)) return undefined;
|
|
74
|
+
if (item.type === "message" && item.role === "assistant" && typeof item.id === "string" &&
|
|
75
|
+
Array.isArray(item.content) && item.content.length === 1 &&
|
|
76
|
+
record(item.content[0]) && item.content[0].type === "output_text" &&
|
|
77
|
+
typeof item.content[0].text === "string") {
|
|
78
|
+
return hash([item.type, item.id, item.phase ?? null, item.content[0].text]);
|
|
79
|
+
}
|
|
80
|
+
if (item.type === "reasoning" && typeof item.id === "string") return hash(sorted(item));
|
|
81
|
+
if ((item.type === "function_call" || item.type === "custom_tool_call") &&
|
|
82
|
+
typeof item.call_id === "string" && typeof item.name === "string") {
|
|
83
|
+
try {
|
|
84
|
+
const args = item.type === "function_call" && typeof item.arguments === "string"
|
|
85
|
+
? sorted(JSON.parse(item.arguments))
|
|
86
|
+
: item.type === "custom_tool_call" && typeof item.input === "string" ? item.input : undefined;
|
|
87
|
+
if (args === undefined) return undefined;
|
|
88
|
+
return hash([item.type, item.id ?? null, item.call_id, item.namespace ?? null, item.name, args]);
|
|
89
|
+
} catch { return undefined; }
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validOutput(output: unknown): output is Item[] {
|
|
95
|
+
if (!Array.isArray(output) || output.length > MAX_ITEMS || bytes(output) > MAX_BYTES) return false;
|
|
96
|
+
return output.every((item) => record(item) && (item.type === "compaction"
|
|
97
|
+
? typeof item.id === "string" && typeof item.encrypted_content === "string" &&
|
|
98
|
+
item.encrypted_content.length > 0 && item.encrypted_content.length <= 2_000_000
|
|
99
|
+
: outputHash(item) !== undefined));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function activeCheckpoint(ctx: ExtensionContext): Checkpoint | undefined {
|
|
103
|
+
const entry = ctx.sessionManager.buildSessionProjection().entries
|
|
104
|
+
.find((item) => item.sourceEntry.type === "compaction" && item.messages.length > 0)?.sourceEntry;
|
|
105
|
+
if (entry?.type !== "compaction" || !record(entry.details)) return undefined;
|
|
106
|
+
const d = entry.details;
|
|
107
|
+
if (d.kind !== KIND || typeof d.model !== "string" || typeof d.identity !== "string" ||
|
|
108
|
+
typeof d.summary !== "string" || d.summary !== entry.summary ||
|
|
109
|
+
typeof d.assistantEntryId !== "string" || entry.firstKeptEntryId !== d.assistantEntryId ||
|
|
110
|
+
typeof d.assistantHash !== "string" || !Array.isArray(d.outputHashes) ||
|
|
111
|
+
d.outputHashes.length > MAX_ITEMS || !d.outputHashes.every((v) => typeof v === "string") ||
|
|
112
|
+
!validOutput(d.replay) || d.replay[0]?.type !== "compaction" ||
|
|
113
|
+
d.replay.slice(1).some((item) => item.type === "compaction")) return undefined;
|
|
114
|
+
return d as unknown as Checkpoint;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function supports(model: Model<any> | undefined): model is Model<"openai-responses"> {
|
|
118
|
+
return model?.provider === "openai" && model.api === "openai-responses" &&
|
|
119
|
+
model.baseUrl === ENDPOINT && /^gpt-(5|6)([.-]|$)/.test(model.id);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function identity(ctx: ExtensionContext): Promise<string | undefined> {
|
|
123
|
+
if (!supports(ctx.model)) return undefined;
|
|
124
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
|
125
|
+
if (!auth.ok || !auth.apiKey || (auth.baseUrl !== undefined && auth.baseUrl !== ENDPOINT)) return undefined;
|
|
126
|
+
// These are provider-issued, high-entropy bearer credentials, not passwords.
|
|
127
|
+
// Use credential material (including potentially secret headers) as the HMAC
|
|
128
|
+
// key, and only non-secret routing/auth-mode metadata as its message.
|
|
129
|
+
// Rotation invalidates replay, including OAuth refresh. Never persist the key
|
|
130
|
+
// or infer account identity from unverified JWT claims.
|
|
131
|
+
const key = JSON.stringify([auth.apiKey, sorted(auth.headers ?? {}), sorted(ctx.model.headers ?? {})]);
|
|
132
|
+
const authMode = ctx.modelRegistry.isUsingOAuth(ctx.model) ? "oauth" : "api-key";
|
|
133
|
+
return createHmac("sha256", key)
|
|
134
|
+
.update(JSON.stringify([KIND, ctx.model.id, ENDPOINT, authMode])).digest("hex");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function summaryText(item: unknown): string | undefined {
|
|
138
|
+
if (!record(item) || item.role !== "user" || !Array.isArray(item.content) || item.content.length !== 1) return;
|
|
139
|
+
const part = item.content[0];
|
|
140
|
+
return record(part) && part.type === "input_text" && typeof part.text === "string" ? part.text : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function replay(payload: Item, checkpoint: Checkpoint, ctx: ExtensionContext): Item | undefined {
|
|
144
|
+
if (!Array.isArray(payload.input)) return undefined;
|
|
145
|
+
const projected = ctx.sessionManager.buildSessionProjection().entries
|
|
146
|
+
.find((entry) => entry.sourceEntry.id === checkpoint.assistantEntryId);
|
|
147
|
+
if (projected?.messages.length !== 1 || hash(projected.messages[0]) !== checkpoint.assistantHash) return undefined;
|
|
148
|
+
// Match the exact Pi-generated summary, not arbitrary user text containing a marker.
|
|
149
|
+
const wrapped = `The conversation history before this point was compacted into the following summary:\n\n<summary>\n${checkpoint.summary}\n</summary>`;
|
|
150
|
+
const indices = payload.input.flatMap((item, index) => summaryText(item) === wrapped ? [index] : []);
|
|
151
|
+
if (indices.length !== 1) return undefined;
|
|
152
|
+
const index = indices[0]!;
|
|
153
|
+
const start = index + 1;
|
|
154
|
+
const serialized = payload.input.slice(start, start + checkpoint.outputHashes.length);
|
|
155
|
+
if (serialized.length !== checkpoint.outputHashes.length ||
|
|
156
|
+
serialized.some((item, i) => outputHash(item) !== checkpoint.outputHashes[i])) return undefined;
|
|
157
|
+
// Keep current system/tool declarations before the marker and all later tool
|
|
158
|
+
// results/messages. Never search-and-delete arbitrary matching output IDs.
|
|
159
|
+
return { ...payload, input: [
|
|
160
|
+
...payload.input.slice(0, index),
|
|
161
|
+
...structuredClone(checkpoint.replay),
|
|
162
|
+
...payload.input.slice(start + serialized.length),
|
|
163
|
+
] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function incompatible(payload: Item): boolean {
|
|
167
|
+
return payload.previous_response_id != null || payload.conversation != null ||
|
|
168
|
+
payload.context_management != null || payload.truncation != null ||
|
|
169
|
+
payload.background === true || payload.generate === false ||
|
|
170
|
+
(record(payload.multi_agent) && payload.multi_agent.enabled === true) ||
|
|
171
|
+
!Array.isArray(payload.input) ||
|
|
172
|
+
payload.input.some((item) => record(item) &&
|
|
173
|
+
(item.type === "configuration_update" || item.type === "compaction_trigger"));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function fallbackSummary(entries: SessionEntry[], ctx: ExtensionContext): string {
|
|
177
|
+
const projection = ctx.sessionManager.buildSessionProjection();
|
|
178
|
+
const ids = new Set(entries.map((entry) => entry.id));
|
|
179
|
+
const messages = projection.entries.filter((entry) => ids.has(entry.sourceEntry.id))
|
|
180
|
+
.flatMap((entry) => entry.messages).filter((message) => message.role !== "system");
|
|
181
|
+
const text = serializeConversation(convertToLlm(messages));
|
|
182
|
+
const excerpt = text.length > 12_000 ? `${text.slice(0, 4000)}\n[Excerpt omitted]\n${text.slice(-8000)}` : text;
|
|
183
|
+
return `[${KIND}:${randomUUID()}]\nProvider checkpoint; the following is only a bounded readable fallback, not a full summary.\n${excerpt}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Public Pi hooks only. No provider replacement, synthetic model turn, or private registry access. */
|
|
187
|
+
export function registerAutomaticCompaction(pi: ExtensionAPI): void {
|
|
188
|
+
pi.registerFlag("server-compaction", { type: "boolean", default: true,
|
|
189
|
+
description: "Server-side compaction on eligible public OpenAI Responses requests (default: on)" });
|
|
190
|
+
pi.registerFlag("server-compaction-threshold", { type: "string",
|
|
191
|
+
description: "Server compaction threshold in tokens (>=1000; default: 60% of Pi's trigger)" });
|
|
192
|
+
let override: { enabled: boolean; threshold?: number } | undefined;
|
|
193
|
+
let inTurn = false;
|
|
194
|
+
let compacting = false;
|
|
195
|
+
let attempt: Attempt | undefined;
|
|
196
|
+
let blocked = false;
|
|
197
|
+
let warmingModel: string | undefined;
|
|
198
|
+
const modelKey = (model: Model<any>) => JSON.stringify([model.provider, model.api, model.id, model.baseUrl]);
|
|
199
|
+
|
|
200
|
+
const enabled = () => override?.enabled ?? pi.getFlag("server-compaction") === true;
|
|
201
|
+
const reset = (_event: unknown, ctx: ExtensionContext) => {
|
|
202
|
+
inTurn = false;
|
|
203
|
+
compacting = false;
|
|
204
|
+
attempt = undefined;
|
|
205
|
+
blocked = false;
|
|
206
|
+
warmingModel = undefined;
|
|
207
|
+
override = undefined;
|
|
208
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
209
|
+
if (entry.type === "custom" && entry.customType === CONFIG && record(entry.data) &&
|
|
210
|
+
typeof entry.data.enabled === "boolean") {
|
|
211
|
+
override = { enabled: entry.data.enabled,
|
|
212
|
+
...(Number.isSafeInteger(entry.data.threshold) && Number(entry.data.threshold) >= 1000
|
|
213
|
+
? { threshold: Number(entry.data.threshold) } : {}) };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
pi.on("session_start", reset);
|
|
218
|
+
pi.on("session_tree", reset);
|
|
219
|
+
pi.on("session_shutdown", () => { attempt = undefined; inTurn = false; warmingModel = undefined; });
|
|
220
|
+
pi.on("turn_start", () => { inTurn = true; attempt = undefined; });
|
|
221
|
+
pi.on("session_before_compact", () => { compacting = true; attempt = undefined; });
|
|
222
|
+
pi.on("session_compact", () => { compacting = false; });
|
|
223
|
+
pi.on("session_compact_failed", () => { compacting = false; });
|
|
224
|
+
pi.on("agent_end", () => { inTurn = false; attempt = undefined; });
|
|
225
|
+
// Warming replays the previous request. Stop only if that actual request used
|
|
226
|
+
// automatic compaction, even if the user subsequently switched the toggle off.
|
|
227
|
+
// Other providers and requests skipped by the compatibility guard are unchanged.
|
|
228
|
+
pi.on("cache_warming_decision", (_event, ctx) =>
|
|
229
|
+
ctx.model && warmingModel === modelKey(ctx.model) ? { action: "stop" } : undefined);
|
|
230
|
+
|
|
231
|
+
pi.registerCommand("server-compaction", {
|
|
232
|
+
description: "Automatic compaction: on [token threshold], off, or status (default: on)",
|
|
233
|
+
handler: async (args, ctx) => {
|
|
234
|
+
const [action = "status", raw, ...extra] = args.trim() ? args.trim().split(/\s+/) : [];
|
|
235
|
+
if (!["on", "off", "status"].includes(action) || extra.length > 0 ||
|
|
236
|
+
(raw !== undefined && (action !== "on" || !/^\d+$/.test(raw) ||
|
|
237
|
+
!Number.isSafeInteger(Number(raw)) || Number(raw) < 1000))) {
|
|
238
|
+
if (ctx.hasUI) ctx.ui.notify("Use /server-compaction on [tokens >=1000], off, or status", "warning");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (action !== "status") {
|
|
242
|
+
await ctx.waitForIdle();
|
|
243
|
+
override = { enabled: action === "on", ...(raw ? { threshold: Number(raw) } : {}) };
|
|
244
|
+
pi.appendEntry(CONFIG, override);
|
|
245
|
+
blocked = false;
|
|
246
|
+
}
|
|
247
|
+
const note = !supports(ctx.model) ? " (unavailable for the current provider/model)"
|
|
248
|
+
: blocked ? " (paused after an unsuccessful request)" : "";
|
|
249
|
+
if (ctx.hasUI) ctx.ui.notify(`Server compaction: ${enabled() ? "on" : "off"}${note}. Public OpenAI Responses only; Pi compaction remains the safety net.`, "info");
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
pi.on("before_provider_request", async (event, ctx) => {
|
|
254
|
+
if (inTurn && !compacting) warmingModel = undefined;
|
|
255
|
+
if (!supports(ctx.model) || !record(event.payload) || event.payload.model !== ctx.model.id) return;
|
|
256
|
+
let payload = event.payload;
|
|
257
|
+
if (payload.previous_response_id != null || payload.conversation != null) return;
|
|
258
|
+
const checkpoint = activeCheckpoint(ctx);
|
|
259
|
+
// Disabled mode with no saved checkpoint must not cause extra auth resolution.
|
|
260
|
+
if (!checkpoint && (!enabled() || blocked || !inTurn || compacting || incompatible(payload))) return;
|
|
261
|
+
const credential = await identity(ctx);
|
|
262
|
+
if (!credential) return;
|
|
263
|
+
if (checkpoint && checkpoint.identity === credential && checkpoint.model === ctx.model.id) {
|
|
264
|
+
payload = replay(payload, checkpoint, ctx) ?? payload;
|
|
265
|
+
}
|
|
266
|
+
// Do not configure standalone summarization, cache warming, or foreign nested calls.
|
|
267
|
+
if (!enabled() || blocked || !inTurn || compacting || incompatible(payload)) return payload;
|
|
268
|
+
const settings = pi.getSettings().compaction;
|
|
269
|
+
const perModel = settings?.modelOverrides?.[`${ctx.model.provider}/${ctx.model.id}`];
|
|
270
|
+
const reserve = perModel?.reserveTokens ?? settings?.reserveTokens ?? 16_384;
|
|
271
|
+
const trigger = ctx.model.contextWindow - reserve;
|
|
272
|
+
const raw = override?.threshold ?? pi.getFlag("server-compaction-threshold");
|
|
273
|
+
const threshold = raw === undefined ? Math.floor(trigger * 0.6) : Number(raw);
|
|
274
|
+
if (!Number.isSafeInteger(threshold) || threshold < 1000 || threshold >= trigger) return payload;
|
|
275
|
+
attempt = { sessionId: ctx.sessionManager.getSessionId(), model: ctx.model.id, identity: credential,
|
|
276
|
+
leafId: ctx.sessionManager.getLeafId(), invalid: false, completed: false,
|
|
277
|
+
items: new Map(), itemBytes: 0, started: performance.now() };
|
|
278
|
+
warmingModel = modelKey(ctx.model);
|
|
279
|
+
return { ...payload, store: false, stream: true,
|
|
280
|
+
context_management: [{ type: "compaction", compact_threshold: threshold }] };
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
pi.on("provider_stream_event", (event) => {
|
|
284
|
+
if (!attempt || event.provider !== "openai" || event.api !== "openai-responses" ||
|
|
285
|
+
event.model !== attempt.model || !record(event.data)) return;
|
|
286
|
+
const data = event.data;
|
|
287
|
+
if (attempt.invalid) return;
|
|
288
|
+
if (data.type === "response.created") {
|
|
289
|
+
if (!record(data.response) || typeof data.response.id !== "string" || attempt.responseId) {
|
|
290
|
+
attempt.invalid = true;
|
|
291
|
+
} else attempt.responseId = data.response.id;
|
|
292
|
+
}
|
|
293
|
+
if (["error", "response.failed", "response.incomplete"].includes(String(data.type))) attempt.invalid = true;
|
|
294
|
+
if (data.type === "response.output_item.done") {
|
|
295
|
+
const index = data.output_index;
|
|
296
|
+
if (!Number.isSafeInteger(index) || Number(index) < 0 || Number(index) >= MAX_ITEMS ||
|
|
297
|
+
attempt.items.has(Number(index)) || !validOutput([data.item]) || attempt.completed) {
|
|
298
|
+
attempt.invalid = true;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
attempt.itemBytes += bytes(data.item);
|
|
302
|
+
if (attempt.itemBytes > MAX_BYTES) {
|
|
303
|
+
attempt.invalid = true;
|
|
304
|
+
attempt.items.clear();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
attempt.items.set(Number(index), structuredClone(data.item as Item));
|
|
308
|
+
}
|
|
309
|
+
if (data.type !== "response.completed") return;
|
|
310
|
+
const response = data.response;
|
|
311
|
+
if (!record(response) || response.status !== "completed" || typeof response.id !== "string" ||
|
|
312
|
+
(attempt.responseId !== undefined && response.id !== attempt.responseId) || attempt.completed ||
|
|
313
|
+
!Array.isArray(response.output)) {
|
|
314
|
+
attempt.invalid = true;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// The live subscription route leaves response.completed.output empty. Done
|
|
318
|
+
// items carry the authoritative encrypted content; never use item.added.
|
|
319
|
+
const streamed = [...attempt.items].sort(([a], [b]) => a - b);
|
|
320
|
+
const output = streamed.length > 0 ? streamed.map(([, item]) => item) : response.output;
|
|
321
|
+
if (streamed.some(([index], position) => index !== position) || !validOutput(output) ||
|
|
322
|
+
(streamed.length > 0 && response.output.length > 0 &&
|
|
323
|
+
hash(sorted(response.output)) !== hash(sorted(output)))) {
|
|
324
|
+
attempt.invalid = true;
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
attempt.completed = true;
|
|
328
|
+
attempt.responseId = response.id;
|
|
329
|
+
attempt.output = structuredClone(output);
|
|
330
|
+
attempt.items.clear();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
pi.on("turn_end", (event, ctx) => {
|
|
334
|
+
const current = attempt;
|
|
335
|
+
attempt = undefined;
|
|
336
|
+
inTurn = false;
|
|
337
|
+
if (!current) return;
|
|
338
|
+
if (event.outcome !== "completed" || current.invalid || !current.completed || !current.output) {
|
|
339
|
+
blocked = true; // No automatic paid retry without context_management.
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (ctx.sessionManager.getSessionId() !== current.sessionId || !supports(ctx.model) ||
|
|
343
|
+
ctx.model.id !== current.model ||
|
|
344
|
+
event.message.role !== "assistant" || !["stop", "toolUse"].includes(event.message.stopReason)) return;
|
|
345
|
+
if (event.message.responseId && event.message.responseId !== current.responseId) return;
|
|
346
|
+
const output = current.output;
|
|
347
|
+
let last = -1;
|
|
348
|
+
for (let i = 0; i < output.length; i++) if (output[i]!.type === "compaction") last = i;
|
|
349
|
+
if (last < 0) return;
|
|
350
|
+
// Pending context changes by another turn-end extension must win over our snapshot.
|
|
351
|
+
if (event.entries.length > 0) return;
|
|
352
|
+
const branch = ctx.sessionManager.getBranch();
|
|
353
|
+
const anchor = branch.findIndex((entry) => entry.id === event.messageEntryId);
|
|
354
|
+
const requestLeaf = branch.findIndex((entry) => entry.id === current.leafId);
|
|
355
|
+
if (anchor < 0 || requestLeaf < 0 || requestLeaf >= anchor ||
|
|
356
|
+
branch.slice(requestLeaf + 1, anchor).some((entry) => entry.type !== "custom")) return;
|
|
357
|
+
const stored = branch[anchor];
|
|
358
|
+
if (stored?.type !== "message" || hash(stored.message) !== hash(event.message)) return;
|
|
359
|
+
const hashes = output.filter((item) => item.type !== "compaction").map(outputHash);
|
|
360
|
+
if (hashes.some((value) => value === undefined)) return;
|
|
361
|
+
// Prove Pi's persisted assistant still represents the observed provider
|
|
362
|
+
// output before discarding any history. In particular, message_end redaction
|
|
363
|
+
// must not later be undone by replaying an unredacted raw suffix.
|
|
364
|
+
try {
|
|
365
|
+
const system = getCurrentSystemMessage(ctx.sessionManager.buildSessionProjection().messages);
|
|
366
|
+
const normalized = convertResponsesMessages(ctx.model,
|
|
367
|
+
normalizeContext({ messages: [...(system ? [system] : []), event.message] }), new Set(["openai"]), {
|
|
368
|
+
includeSystemPrompt: false,
|
|
369
|
+
grammarToolInputProperties: createGrammarToolInputProperties(
|
|
370
|
+
system?.toolsAdded ?? [], ctx.model.compat?.supportsOpenAIGrammarTools ?? false),
|
|
371
|
+
});
|
|
372
|
+
const actualHashes = normalized.map(outputHash).filter((value) => value !== undefined);
|
|
373
|
+
if (hash(actualHashes) !== hash(hashes)) return;
|
|
374
|
+
} catch { return; }
|
|
375
|
+
const summary = fallbackSummary(branch.slice(0, anchor), ctx);
|
|
376
|
+
const details: Checkpoint = {
|
|
377
|
+
kind: KIND, model: current.model, identity: current.identity, summary,
|
|
378
|
+
assistantEntryId: event.messageEntryId, assistantHash: hash(event.message),
|
|
379
|
+
outputHashes: hashes as string[], replay: output.slice(last),
|
|
380
|
+
};
|
|
381
|
+
if (bytes(details) > MAX_BYTES) return;
|
|
382
|
+
return { entries: [{
|
|
383
|
+
type: "compaction", summary, firstKeptEntryId: event.messageEntryId, details,
|
|
384
|
+
// Usage is already recorded on this ordinary assistant response. Never add it twice.
|
|
385
|
+
}, { type: "custom", customType: `${KIND}:saved`, data: {
|
|
386
|
+
version: 1, durationMs: Math.round(performance.now() - current.started),
|
|
387
|
+
checkpointBytes: bytes(details.replay), outputItems: details.replay.length,
|
|
388
|
+
} }] };
|
|
389
|
+
});
|
|
390
|
+
}
|
package/src/index.ts
CHANGED