@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
package/src/remote.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Own V2 request adaptation; Pi's model registry owns authentication and provider dispatch.
|
|
2
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import type { Context, Model, ProviderHeaders, ThinkingBudgets, Usage } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { capableModel, deriveEndpoint, normalizeUrl, sameBackend, sameModel, type ProviderIdentity, type RemoteCompactionApi } from "./capability.js";
|
|
6
|
+
import { trimToolOutputsToContextWindow } from "./context-window.js";
|
|
7
|
+
import { estimateImages, type ImageEstimates } from "./image-budget.js";
|
|
8
|
+
import { contextUserItems, type UserItemOrigin } from "./retention-input.js";
|
|
9
|
+
import { CodexCompactionProtocolError, createCompactionCollector, isObject, type JsonObject, prepareRemoteCompactionPayload } from "./protocol.js";
|
|
10
|
+
|
|
11
|
+
const REMOTE_COMPACTION_FEATURE = "remote_compaction_v2";
|
|
12
|
+
const REQUEST_TIMEOUT_MS = 300_000;
|
|
13
|
+
const MAX_RETRIES = 2;
|
|
14
|
+
const MISSING_PAYLOAD_MESSAGE = "Provider did not expose a request payload";
|
|
15
|
+
|
|
16
|
+
export interface RemoteCompactionRequest {
|
|
17
|
+
modelRegistry: Pick<ModelRegistry, "streamSimple">;
|
|
18
|
+
model: Model<RemoteCompactionApi>;
|
|
19
|
+
context: Context;
|
|
20
|
+
reasoning: ThinkingLevel;
|
|
21
|
+
sessionId: string;
|
|
22
|
+
thinkingBudgets?: ThinkingBudgets;
|
|
23
|
+
/** Pi's provider retry setting; Codex caps compaction retries below it. */
|
|
24
|
+
maxRetries?: number;
|
|
25
|
+
maxRetryDelayMs?: number;
|
|
26
|
+
signal: AbortSignal;
|
|
27
|
+
/** Pi origins of the context's user messages, used to align provider user items with Pi roles. */
|
|
28
|
+
userItemOrigins?: readonly UserItemOrigin[];
|
|
29
|
+
priorCheckpoint?: { identity: ProviderIdentity; marker: string; replacementHistory: readonly JsonObject[] };
|
|
30
|
+
onPrepared?: () => void;
|
|
31
|
+
fetch?: typeof globalThis.fetch;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RemoteCompactionResponse {
|
|
35
|
+
item: JsonObject;
|
|
36
|
+
promptInput: JsonObject[];
|
|
37
|
+
identity: ProviderIdentity;
|
|
38
|
+
usage: Usage;
|
|
39
|
+
images: ImageEstimates;
|
|
40
|
+
/** Whether each promptInput item came from Pi context that Codex would not retain. */
|
|
41
|
+
contextual: boolean[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function mergeRemoteCompactionHeader(headers: ProviderHeaders): ProviderHeaders {
|
|
45
|
+
const merged = { ...headers };
|
|
46
|
+
const existingKey = Object.keys(merged).find((key) => key.toLowerCase() === "x-codex-beta-features");
|
|
47
|
+
const features = new Set(((existingKey ? merged[existingKey] : "") ?? "")
|
|
48
|
+
.split(",").map((feature) => feature.trim()).filter(Boolean));
|
|
49
|
+
features.add(REMOTE_COMPACTION_FEATURE);
|
|
50
|
+
if (existingKey && existingKey !== "x-codex-beta-features") delete merged[existingKey];
|
|
51
|
+
merged["x-codex-beta-features"] = [...features].join(",");
|
|
52
|
+
return merged;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function objectItems(input: unknown): JsonObject[] {
|
|
56
|
+
if (!Array.isArray(input) || !input.every(isObject)) {
|
|
57
|
+
throw new CodexCompactionProtocolError("Prepared compaction payload has invalid input items");
|
|
58
|
+
}
|
|
59
|
+
return input;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function requestRemoteCompaction(request: RemoteCompactionRequest): Promise<RemoteCompactionResponse> {
|
|
63
|
+
request.signal.throwIfAborted();
|
|
64
|
+
const configured = capableModel(request.model);
|
|
65
|
+
if (!configured) throw new CodexCompactionProtocolError("Model is not configured for remote compaction");
|
|
66
|
+
const collector = createCompactionCollector();
|
|
67
|
+
let sentInput: JsonObject[] | undefined;
|
|
68
|
+
let identity: ProviderIdentity | undefined;
|
|
69
|
+
let usage: Usage | undefined;
|
|
70
|
+
let images: ImageEstimates | undefined;
|
|
71
|
+
let contextual: boolean[] | undefined;
|
|
72
|
+
const baseFetch = request.fetch ?? globalThis.fetch;
|
|
73
|
+
const routedFetch: typeof globalThis.fetch = async (input, init) => {
|
|
74
|
+
if (!identity) throw new CodexCompactionProtocolError(MISSING_PAYLOAD_MESSAGE);
|
|
75
|
+
const actual = normalizeUrl(input instanceof Request ? input.url : String(input));
|
|
76
|
+
const defaultEndpoint = deriveEndpoint(identity.baseUrl, identity.api);
|
|
77
|
+
if (actual !== defaultEndpoint && actual !== identity.endpoint) {
|
|
78
|
+
throw new CodexCompactionProtocolError(`Provider requested unexpected compaction endpoint ${actual}`);
|
|
79
|
+
}
|
|
80
|
+
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
81
|
+
if (method.toUpperCase() !== "POST") throw new CodexCompactionProtocolError("Remote compaction request must use POST");
|
|
82
|
+
if (actual === identity.endpoint) return baseFetch(input, init);
|
|
83
|
+
// Endpoint overrides are same-origin HTTP routes; authentication remains assembled by Pi.
|
|
84
|
+
return input instanceof Request
|
|
85
|
+
? baseFetch(new Request(identity.endpoint, new Request(input, init)))
|
|
86
|
+
: baseFetch(identity.endpoint, init);
|
|
87
|
+
};
|
|
88
|
+
const stream = request.modelRegistry.streamSimple(request.model, request.context, {
|
|
89
|
+
signal: request.signal,
|
|
90
|
+
// Pi pools WebSockets without comparing handshake headers; V2 needs its feature header on every request.
|
|
91
|
+
transport: "sse",
|
|
92
|
+
reasoning: request.reasoning === "off" ? undefined : request.reasoning,
|
|
93
|
+
thinkingBudgets: request.thinkingBudgets,
|
|
94
|
+
sessionId: request.sessionId,
|
|
95
|
+
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
96
|
+
maxRetries: Math.min(request.maxRetries ?? MAX_RETRIES, MAX_RETRIES),
|
|
97
|
+
maxRetryDelayMs: request.maxRetryDelayMs,
|
|
98
|
+
transformHeaders: mergeRemoteCompactionHeader,
|
|
99
|
+
fetch: routedFetch,
|
|
100
|
+
onPayload: async (payload, preparedModel) => {
|
|
101
|
+
if (!sameModel(configured.identity, preparedModel)) {
|
|
102
|
+
throw new CodexCompactionProtocolError("Provider resolved an unexpected compaction model");
|
|
103
|
+
}
|
|
104
|
+
const resolved = capableModel(request.model, preparedModel.baseUrl);
|
|
105
|
+
if (!resolved) throw new CodexCompactionProtocolError("Resolved provider endpoint is incompatible with remote compaction");
|
|
106
|
+
identity = resolved.identity;
|
|
107
|
+
const prior = request.priorCheckpoint?.identity;
|
|
108
|
+
if (prior && !sameBackend(prior, identity)) {
|
|
109
|
+
throw new CodexCompactionProtocolError("The active opaque checkpoint belongs to a different resolved provider backend");
|
|
110
|
+
}
|
|
111
|
+
const contextItems = contextUserItems(isObject(payload) ? payload.input : undefined, request.userItemOrigins);
|
|
112
|
+
const payloadItems = isObject(payload) && Array.isArray(payload.input) ? payload.input.filter(isObject) : [];
|
|
113
|
+
const estimates = await estimateImages([...payloadItems, ...request.priorCheckpoint?.replacementHistory ?? []], request.signal);
|
|
114
|
+
const prepared = prepareRemoteCompactionPayload(payload, request.priorCheckpoint, (history) => ({
|
|
115
|
+
...history,
|
|
116
|
+
input: trimToolOutputsToContextWindow(objectItems(history.input), history.instructions, request.model.contextWindow, estimates),
|
|
117
|
+
}));
|
|
118
|
+
if (prepared.model !== request.model.id) throw new CodexCompactionProtocolError("Provider payload used an unexpected model");
|
|
119
|
+
const sent = objectItems(prepared.input).slice(0, -1);
|
|
120
|
+
contextual = sent.map((item) => contextItems.has(item));
|
|
121
|
+
sentInput = structuredClone(sent);
|
|
122
|
+
images = estimates;
|
|
123
|
+
request.onPrepared?.();
|
|
124
|
+
return prepared;
|
|
125
|
+
},
|
|
126
|
+
onProviderStreamEvent: (event) => {
|
|
127
|
+
request.signal.throwIfAborted();
|
|
128
|
+
collector.observe(event);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
for await (const event of stream) {
|
|
132
|
+
request.signal.throwIfAborted();
|
|
133
|
+
if (event.type === "error") throw new Error(event.error.errorMessage ?? "Codex compaction request failed");
|
|
134
|
+
if (event.type === "done") usage = event.message.usage;
|
|
135
|
+
}
|
|
136
|
+
request.signal.throwIfAborted();
|
|
137
|
+
if (!sentInput || !identity || !images || !contextual) throw new CodexCompactionProtocolError(MISSING_PAYLOAD_MESSAGE);
|
|
138
|
+
if (!usage) throw new CodexCompactionProtocolError("Provider stream ended without a completed message");
|
|
139
|
+
return { item: collector.finish(), promptInput: sentInput, identity, usage, images, contextual };
|
|
140
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Own ordinary-request prompt and context snapshots that let compaction reuse Pi's projected request prefix,
|
|
2
|
+
// and build compaction's provider context the way Pi 0.99 builds an ordinary request.
|
|
3
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
4
|
+
import { getCurrentSystemMessage, getSystemMessageText, type Context, type Message, type Tool } from "@earendil-works/pi-ai";
|
|
5
|
+
import { convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { sameBackend, sameModel, type CapableModel, type ProviderIdentity } from "./capability.js";
|
|
7
|
+
import { type CodexCheckpointDetails, fingerprintMessage, projectCheckpointRequest, withoutSystemMessages } from "./checkpoint.js";
|
|
8
|
+
|
|
9
|
+
const BLOCKED_IMAGE_TEXT = "Image reading is disabled.";
|
|
10
|
+
|
|
11
|
+
interface SnapshotScope {
|
|
12
|
+
readonly sessionId: string;
|
|
13
|
+
readonly identity: ProviderIdentity;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PromptOverride extends SnapshotScope {
|
|
17
|
+
readonly sourceFingerprint: string;
|
|
18
|
+
readonly text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ContextSnapshot extends SnapshotScope {
|
|
22
|
+
readonly sourceFingerprints: readonly string[];
|
|
23
|
+
readonly messages: readonly AgentMessage[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Snapshots apply only to the session, model, and backend that produced them. */
|
|
27
|
+
export function snapshotFor<T extends SnapshotScope>(
|
|
28
|
+
snapshot: T | undefined,
|
|
29
|
+
sessionId: string,
|
|
30
|
+
target: CapableModel,
|
|
31
|
+
): T | undefined {
|
|
32
|
+
return snapshot?.sessionId === sessionId && sameModel(snapshot.identity, target.model) &&
|
|
33
|
+
sameBackend(snapshot.identity, target.identity) ? snapshot : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sourceHead(messages: readonly AgentMessage[]) {
|
|
37
|
+
return getCurrentSystemMessage(convertToLlm([...messages]));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Collapse source system state only for matching; the snapshot keeps Pi's actual projected transcript for sending.
|
|
41
|
+
function requestSource(messages: readonly AgentMessage[]): AgentMessage[] {
|
|
42
|
+
const head = sourceHead(messages);
|
|
43
|
+
const conversation = withoutSystemMessages(messages);
|
|
44
|
+
return head ? [head, ...conversation] : conversation;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Context handlers see only conversation messages; a snapshot needs them to equal the persisted ones. */
|
|
48
|
+
export function matchesConversation(fingerprints: readonly string[], canonical: readonly AgentMessage[]): boolean {
|
|
49
|
+
const conversation = withoutSystemMessages(canonical);
|
|
50
|
+
return fingerprints.length === conversation.length &&
|
|
51
|
+
fingerprints.every((fingerprint, index) => fingerprintMessage(conversation[index]) === fingerprint);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pi does not expose a run's forced prompt, so a difference between `ctx.getSystemPrompt()` and the
|
|
56
|
+
* persisted head counts as one. A stale head only drops the override later, because compaction
|
|
57
|
+
* requires the head fingerprint to match again.
|
|
58
|
+
*/
|
|
59
|
+
export function capturePromptOverride(
|
|
60
|
+
canonical: readonly AgentMessage[],
|
|
61
|
+
sessionId: string,
|
|
62
|
+
target: CapableModel,
|
|
63
|
+
text: string,
|
|
64
|
+
): PromptOverride | undefined {
|
|
65
|
+
const head = sourceHead(canonical);
|
|
66
|
+
if (!head || text === getSystemMessageText(head)) return undefined;
|
|
67
|
+
return { sessionId, identity: target.identity, sourceFingerprint: fingerprintMessage(head), text };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function promptOverrideFor(
|
|
71
|
+
override: PromptOverride | undefined,
|
|
72
|
+
sessionId: string,
|
|
73
|
+
target: CapableModel,
|
|
74
|
+
messages: readonly AgentMessage[],
|
|
75
|
+
): PromptOverride | undefined {
|
|
76
|
+
const scoped = snapshotFor(override, sessionId, target);
|
|
77
|
+
const head = scoped && sourceHead(messages);
|
|
78
|
+
return head && fingerprintMessage(head) === scoped.sourceFingerprint ? scoped : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function captureContextSnapshot(
|
|
82
|
+
sessionId: string,
|
|
83
|
+
target: CapableModel,
|
|
84
|
+
source: readonly AgentMessage[],
|
|
85
|
+
projected: readonly AgentMessage[],
|
|
86
|
+
): ContextSnapshot {
|
|
87
|
+
return {
|
|
88
|
+
sessionId,
|
|
89
|
+
identity: target.identity,
|
|
90
|
+
sourceFingerprints: requestSource(source).map(fingerprintMessage),
|
|
91
|
+
messages: structuredClone(projected),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Reuse the projected request while its source is an unchanged prefix, then append newer messages. */
|
|
96
|
+
export function reuseContextSnapshot(messages: AgentMessage[], snapshot: ContextSnapshot | undefined): AgentMessage[] {
|
|
97
|
+
if (!snapshot) return messages;
|
|
98
|
+
const source = requestSource(messages);
|
|
99
|
+
if (source.length < snapshot.sourceFingerprints.length || snapshot.sourceFingerprints.some(
|
|
100
|
+
(fingerprint, index) => fingerprintMessage(source[index]) !== fingerprint,
|
|
101
|
+
)) return messages;
|
|
102
|
+
return [...structuredClone(snapshot.messages), ...source.slice(snapshot.sourceFingerprints.length)];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Pi 0.99 applies per-run prompt overrides after context hooks by collapsing system messages into one head. */
|
|
106
|
+
export function applyPromptOverride(messages: Message[], override: PromptOverride): Message[] | undefined {
|
|
107
|
+
const head = getCurrentSystemMessage(messages);
|
|
108
|
+
if (!head) return undefined;
|
|
109
|
+
const { sections: _sections, ...declarations } = head;
|
|
110
|
+
return [{ ...declarations, content: override.text }, ...withoutSystemMessages(messages)];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The snapshots that the latest ordinary request left for compaction. */
|
|
114
|
+
export interface RequestSnapshots {
|
|
115
|
+
readonly promptOverride?: PromptOverride;
|
|
116
|
+
readonly context?: ContextSnapshot;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface TrackedSnapshots {
|
|
120
|
+
promptOverride?: PromptOverride;
|
|
121
|
+
pendingSource?: { readonly sessionId: string; readonly fingerprints: readonly string[] };
|
|
122
|
+
pendingContext?: ContextSnapshot;
|
|
123
|
+
context?: ContextSnapshot;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Follow one ordinary request through Pi's hooks: `context`, then `context_with_system`, then
|
|
128
|
+
* `before_provider_request` for each provider attempt. State lives only as long as the Pi process.
|
|
129
|
+
*/
|
|
130
|
+
export class RequestSnapshotTracker {
|
|
131
|
+
private state: TrackedSnapshots = {};
|
|
132
|
+
|
|
133
|
+
/** Forget every snapshot; a compaction that already started keeps the state it captured. */
|
|
134
|
+
reset(): void {
|
|
135
|
+
this.state = {};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The live snapshots a compaction reads; later provider requests may still update them. */
|
|
139
|
+
current(): RequestSnapshots {
|
|
140
|
+
return this.state;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `context`: record the conversation handlers received. Pi clones these messages per request, so hash only for V2. */
|
|
144
|
+
recordContext(sessionId: string, target: CapableModel | undefined, messages: readonly AgentMessage[]): void {
|
|
145
|
+
this.state.pendingSource = target ? { sessionId, fingerprints: messages.map(fingerprintMessage) } : undefined;
|
|
146
|
+
this.state.pendingContext = undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `context_with_system`: capture Pi's projected request when its source equals the persisted conversation. */
|
|
150
|
+
recordProjectedRequest(
|
|
151
|
+
sessionId: string,
|
|
152
|
+
target: CapableModel | undefined,
|
|
153
|
+
canonical: () => AgentMessage[],
|
|
154
|
+
checkpoint: () => CodexCheckpointDetails | undefined,
|
|
155
|
+
projected: readonly AgentMessage[],
|
|
156
|
+
): void {
|
|
157
|
+
const pending = this.state.pendingSource;
|
|
158
|
+
this.state.pendingSource = undefined;
|
|
159
|
+
this.state.pendingContext = undefined;
|
|
160
|
+
if (!target || pending?.sessionId !== sessionId) return;
|
|
161
|
+
const messages = canonical();
|
|
162
|
+
// Request-local, unpersisted messages cannot be aligned safely with the session's future suffix.
|
|
163
|
+
if (!matchesConversation(pending.fingerprints, messages)) return;
|
|
164
|
+
const prior = checkpoint();
|
|
165
|
+
const source = prior ? projectCheckpointRequest(messages, prior) : messages;
|
|
166
|
+
if (source) this.state.pendingContext = captureContextSnapshot(sessionId, target, source, projected);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** `before_provider_request`: record the effective prompt and publish the pending context. */
|
|
170
|
+
recordProviderRequest(
|
|
171
|
+
sessionId: string,
|
|
172
|
+
target: CapableModel | undefined,
|
|
173
|
+
canonical: () => AgentMessage[],
|
|
174
|
+
systemPrompt: () => string,
|
|
175
|
+
): void {
|
|
176
|
+
this.state.promptOverride = target && capturePromptOverride(canonical(), sessionId, target, systemPrompt());
|
|
177
|
+
// Keep the pending snapshot for retries that prepare a payload without running context hooks again.
|
|
178
|
+
this.state.context = this.state.pendingContext;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** What Pi would declare for an ordinary request whose transcript carries no system messages. */
|
|
183
|
+
export interface RequestDeclarations {
|
|
184
|
+
readonly blockImages: boolean;
|
|
185
|
+
readonly systemPrompt: () => string;
|
|
186
|
+
readonly tools: () => Tool[];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Mirror Pi 0.99's request-time image blocking, including its deduplicated placeholders.
|
|
190
|
+
function withoutImages(messages: Message[]): Message[] {
|
|
191
|
+
return messages.map((message) => {
|
|
192
|
+
if ((message.role !== "user" && message.role !== "toolResult") || !Array.isArray(message.content) ||
|
|
193
|
+
!message.content.some((part) => part.type === "image")) return message;
|
|
194
|
+
const content = message.content
|
|
195
|
+
.map((part) => part.type === "image" ? { type: "text" as const, text: BLOCKED_IMAGE_TEXT } : part)
|
|
196
|
+
.filter((part, index, parts) => !(part.type === "text" && part.text === BLOCKED_IMAGE_TEXT && index > 0 &&
|
|
197
|
+
parts[index - 1].type === "text" && (parts[index - 1] as { text: string }).text === BLOCKED_IMAGE_TEXT));
|
|
198
|
+
return { ...message, content } as Message;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build compaction's provider context from the current transcript and the snapshots bound to this
|
|
204
|
+
* session, model, and backend. Pi 0.99 transcripts declare the prompt and tools through system
|
|
205
|
+
* messages, as ordinary requests do; `messages` is the transcript actually sent.
|
|
206
|
+
*/
|
|
207
|
+
export function compactionRequest(
|
|
208
|
+
snapshots: RequestSnapshots,
|
|
209
|
+
sessionId: string,
|
|
210
|
+
target: CapableModel,
|
|
211
|
+
current: AgentMessage[],
|
|
212
|
+
declarations: RequestDeclarations,
|
|
213
|
+
): { readonly context: Context; readonly messages: AgentMessage[] } {
|
|
214
|
+
const override = promptOverrideFor(snapshots.promptOverride, sessionId, target, current);
|
|
215
|
+
const messages = reuseContextSnapshot(current, snapshotFor(snapshots.context, sessionId, target));
|
|
216
|
+
const converted = convertToLlm(messages);
|
|
217
|
+
const llmMessages = declarations.blockImages ? withoutImages(converted) : converted;
|
|
218
|
+
const overridden = override && applyPromptOverride(llmMessages, override);
|
|
219
|
+
if (overridden) return { context: { messages: overridden }, messages };
|
|
220
|
+
if (llmMessages.some((message) => message.role === "system")) return { context: { messages: llmMessages }, messages };
|
|
221
|
+
return {
|
|
222
|
+
context: { systemPrompt: declarations.systemPrompt(), messages: llmMessages, tools: declarations.tools() },
|
|
223
|
+
messages,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Adapt Responses messages to Codex rust-v0.159.2's metadata-free retained groups.
|
|
2
|
+
// Context markers mirror core/src/context; XML is parsed at this boundary, not by the budget core.
|
|
3
|
+
// Pi message origins stand in for Codex's separate contextual messages where Pi merges them into user items.
|
|
4
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
5
|
+
import { convertToLlm, parseSkillBlock } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { SaxesParser } from "saxes";
|
|
7
|
+
import { historyGroups, inputText, matchesMarkedText, trimWhiteSpace } from "./history-groups.js";
|
|
8
|
+
import { estimateImages, type ImageEstimates } from "./image-budget.js";
|
|
9
|
+
import { isObject, type JsonObject } from "./protocol.js";
|
|
10
|
+
import type { RetentionInput } from "./retention.js";
|
|
11
|
+
|
|
12
|
+
const CONTEXT_MARKERS: readonly (readonly [string, string])[] = [
|
|
13
|
+
["# AGENTS.md instructions", "</INSTRUCTIONS>"],
|
|
14
|
+
["<environment_context>", "</environment_context>"],
|
|
15
|
+
["<agent_message_board_notification>", "</agent_message_board_notification>"],
|
|
16
|
+
["<skill>", "</skill>"],
|
|
17
|
+
["<user_shell_command>", "</user_shell_command>"],
|
|
18
|
+
["<turn_aborted>", "</turn_aborted>"],
|
|
19
|
+
["<subagent_notification>", "</subagent_notification>"],
|
|
20
|
+
["<recommended_plugins>", "</recommended_plugins>"],
|
|
21
|
+
];
|
|
22
|
+
const EXTERNAL_PREFIX = "<external_";
|
|
23
|
+
|
|
24
|
+
function contextual(text: string): boolean {
|
|
25
|
+
if (CONTEXT_MARKERS.some((markers) => matchesMarkedText(text, markers))) return true;
|
|
26
|
+
if (text.startsWith(EXTERNAL_PREFIX)) {
|
|
27
|
+
const delimiter = text.indexOf(">", EXTERNAL_PREFIX.length);
|
|
28
|
+
if (delimiter >= 0 && text.endsWith(`</external_${text.slice(EXTERNAL_PREFIX.length, delimiter)}>`)) return true;
|
|
29
|
+
}
|
|
30
|
+
if (text.startsWith("<goal_context>") && text.endsWith("</goal_context>")) return true;
|
|
31
|
+
if (/^<codex_internal_context source="[a-z][a-z0-9_]*">[\s\S]*<\/codex_internal_context>$/.test(text)) return true;
|
|
32
|
+
return (text.startsWith("Warning: apply_patch was requested via ") &&
|
|
33
|
+
text.endsWith("Use the apply_patch tool instead of exec_command.")) ||
|
|
34
|
+
text.startsWith("Warning: The maximum number of unified exec processes you can keep open is") ||
|
|
35
|
+
text.startsWith("Warning: Your account was flagged for potentially high-risk cyber activity");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// quick-xml's struct deserializer accepts any root name and reads only the first root.
|
|
39
|
+
// A nonempty hook_run_id and exactly one direct text field are required; nested fields are ignored.
|
|
40
|
+
// Known deviation: saxes rejects some input quick-xml 0.41 accepts (a leading XML declaration or
|
|
41
|
+
// DOCTYPE, prefixed attributes, a raw "<" in attribute values, "]]>" in text, invalid names, and
|
|
42
|
+
// control characters). Such text is classified as ordinary user text, never the reverse; retention
|
|
43
|
+
// differs only when it shares a message with other parts.
|
|
44
|
+
function hookPrompt(text: string): boolean {
|
|
45
|
+
if (!text.includes("hook_run_id")) return false;
|
|
46
|
+
const parser = new SaxesParser({ fragment: true });
|
|
47
|
+
let depth = 0;
|
|
48
|
+
let finished = false;
|
|
49
|
+
let rootEnd = Infinity;
|
|
50
|
+
let invalid = false;
|
|
51
|
+
let hookRunId = "";
|
|
52
|
+
let textFields = 0;
|
|
53
|
+
let previousChild = false;
|
|
54
|
+
parser.on("error", () => { if (parser.position <= rootEnd) invalid = true; });
|
|
55
|
+
parser.on("opentag", (tag) => {
|
|
56
|
+
if (finished) return;
|
|
57
|
+
if (depth === 0) hookRunId = typeof tag.attributes.hook_run_id === "string" ? tag.attributes.hook_run_id : "";
|
|
58
|
+
if (depth === 1) previousChild = true;
|
|
59
|
+
depth++;
|
|
60
|
+
});
|
|
61
|
+
const recordText = (value: string) => {
|
|
62
|
+
if (finished) return;
|
|
63
|
+
if (depth === 0 && value.trim()) invalid = true;
|
|
64
|
+
if (depth === 1 && value.replace(/^[\x20\t\r\n]+|[\x20\t\r\n]+$/g, "")) {
|
|
65
|
+
if (textFields === 0 || previousChild) textFields++;
|
|
66
|
+
previousChild = false;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
parser.on("text", recordText);
|
|
70
|
+
parser.on("cdata", recordText);
|
|
71
|
+
parser.on("closetag", () => {
|
|
72
|
+
if (!finished && --depth === 0) { finished = true; rootEnd = parser.position; }
|
|
73
|
+
});
|
|
74
|
+
parser.write(text).close();
|
|
75
|
+
return finished && !invalid && trimWhiteSpace(hookRunId).length > 0 && textFields === 1;
|
|
76
|
+
}
|
|
77
|
+
function isUserMessage(item: JsonObject): boolean {
|
|
78
|
+
if ((item.type !== undefined && item.type !== "message") || item.role !== "user" || !Array.isArray(item.content)) return false;
|
|
79
|
+
let hasContext = false;
|
|
80
|
+
let hasHook = false;
|
|
81
|
+
let allHookOrContext = true;
|
|
82
|
+
for (const part of item.content) {
|
|
83
|
+
const text = inputText(part);
|
|
84
|
+
if (text === undefined) { allHookOrContext = false; continue; }
|
|
85
|
+
const hook = hookPrompt(text);
|
|
86
|
+
const context = contextual(text);
|
|
87
|
+
hasContext ||= context || hook;
|
|
88
|
+
hasHook ||= hook;
|
|
89
|
+
allHookOrContext &&= hook || context;
|
|
90
|
+
}
|
|
91
|
+
return (hasHook && allHookOrContext) || !hasContext;
|
|
92
|
+
}
|
|
93
|
+
/** Pi sends user shell commands and hidden extension messages as user items; Codex treats them as context. */
|
|
94
|
+
export type UserItemOrigin = "user" | "context";
|
|
95
|
+
|
|
96
|
+
function origin(message: AgentMessage): UserItemOrigin {
|
|
97
|
+
return message.role === "bashExecution" || (message.role === "custom" && !message.display) ? "context" : "user";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Origins of the provider's user items, in order; Pi's Responses adapter skips empty content arrays. */
|
|
101
|
+
export function userItemOrigins(messages: readonly AgentMessage[]): UserItemOrigin[] {
|
|
102
|
+
return messages.flatMap((message) => convertToLlm([message])
|
|
103
|
+
.filter((llm) => llm.role === "user" && (typeof llm.content === "string" || llm.content.length > 0))
|
|
104
|
+
.map(() => origin(message)));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isUserItem(item: JsonObject): boolean {
|
|
108
|
+
return (item.type === undefined || item.type === "message") && item.role === "user";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Match provider user items to Pi origins; a changed user sequence falls back to text classification. */
|
|
112
|
+
export function contextUserItems(input: unknown, origins: readonly UserItemOrigin[] | undefined): ReadonlySet<JsonObject> {
|
|
113
|
+
const users = Array.isArray(input) ? input.filter(isObject).filter(isUserItem) : [];
|
|
114
|
+
if (!origins || users.length !== origins.length) return new Set();
|
|
115
|
+
return new Set(users.filter((_, index) => origins[index] === "context"));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Pi prepends an expanded skill to the user's text; Codex keeps the skill as a separate contextual message.
|
|
119
|
+
function withoutSkillBlocks(item: JsonObject): JsonObject | undefined {
|
|
120
|
+
if (!Array.isArray(item.content)) return item;
|
|
121
|
+
let changed = false;
|
|
122
|
+
const content = item.content.flatMap((part: unknown) => {
|
|
123
|
+
if (!isObject(part) || part.type !== "input_text" || typeof part.text !== "string") return [part];
|
|
124
|
+
const skill = parseSkillBlock(part.text);
|
|
125
|
+
if (!skill) return [part];
|
|
126
|
+
changed = true;
|
|
127
|
+
return skill.userMessage ? [{ ...part, text: skill.userMessage }] : [];
|
|
128
|
+
});
|
|
129
|
+
if (!changed) return item;
|
|
130
|
+
return content.length ? { ...item, content } : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function prepareRetention(
|
|
134
|
+
input: readonly JsonObject[],
|
|
135
|
+
signal: AbortSignal,
|
|
136
|
+
options: { images?: ImageEstimates; contextual?: readonly boolean[] } = {},
|
|
137
|
+
): Promise<RetentionInput> {
|
|
138
|
+
const contextual = new Set(input.filter((_, index) => options.contextual?.[index]));
|
|
139
|
+
const groups = historyGroups(input).flatMap((group) => {
|
|
140
|
+
signal.throwIfAborted();
|
|
141
|
+
if (contextual.has(group.source) || !isUserMessage(group.source)) return [];
|
|
142
|
+
const source = withoutSkillBlocks(group.source);
|
|
143
|
+
return source ? [{ ...group, source }] : [];
|
|
144
|
+
});
|
|
145
|
+
return { groups, images: options.images ?? await estimateImages(groups.map((group) => group.source), signal) };
|
|
146
|
+
}
|
package/src/retention.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Own Codex V2 retention for Pi message items, including its enabled image-budget policy.
|
|
2
|
+
// Reference: openai/codex rust-v0.159.2, compact_remote_v2.rs and compact_remote_v2_images.rs.
|
|
3
|
+
import type { HistoryGroup } from "./history-groups.js";
|
|
4
|
+
import type { ImageEstimates } from "./image-budget.js";
|
|
5
|
+
import { isInputImage, type JsonObject } from "./protocol.js";
|
|
6
|
+
import { approximateTokenCount, approximateTokensFromBytes, truncateTextToTokenBudget } from "./text-budget.js";
|
|
7
|
+
|
|
8
|
+
export const RETAINED_MESSAGE_TOKEN_BUDGET = 64_000;
|
|
9
|
+
const IMAGE_OPEN_TAG = "<image>";
|
|
10
|
+
const IMAGE_CLOSE_TAG = "</image>";
|
|
11
|
+
const LOCAL_IMAGE_OPEN_PREFIX = "<image name=";
|
|
12
|
+
|
|
13
|
+
type TextPart = JsonObject & { type: "input_text" | "output_text"; text: string };
|
|
14
|
+
export interface RetentionInput {
|
|
15
|
+
readonly groups: readonly HistoryGroup[];
|
|
16
|
+
readonly images: ImageEstimates;
|
|
17
|
+
}
|
|
18
|
+
function isTextPart(part: unknown): part is TextPart {
|
|
19
|
+
return typeof part === "object" && part !== null && "type" in part &&
|
|
20
|
+
(part.type === "input_text" || part.type === "output_text") &&
|
|
21
|
+
"text" in part && typeof part.text === "string";
|
|
22
|
+
}
|
|
23
|
+
function isTag(part: unknown, tag: string): boolean {
|
|
24
|
+
return isTextPart(part) && part.type === "input_text" && part.text === tag;
|
|
25
|
+
}
|
|
26
|
+
function isImageOpenTag(part: unknown): boolean {
|
|
27
|
+
return isTag(part, IMAGE_OPEN_TAG) || (isTextPart(part) && part.type === "input_text" &&
|
|
28
|
+
part.text.startsWith(LOCAL_IMAGE_OPEN_PREFIX) && part.text.endsWith(">"));
|
|
29
|
+
}
|
|
30
|
+
function partTokenCount(part: unknown, images: ImageEstimates): number {
|
|
31
|
+
if (isTextPart(part)) return approximateTokenCount(part.text);
|
|
32
|
+
return isInputImage(part) ? approximateTokensFromBytes(images.bytes(part)) : 0;
|
|
33
|
+
}
|
|
34
|
+
function textTokenCount(item: JsonObject): number {
|
|
35
|
+
return Array.isArray(item.content)
|
|
36
|
+
? item.content.reduce((tokens: number, part: unknown) => tokens + (isTextPart(part) ? approximateTokenCount(part.text) : 0), 0)
|
|
37
|
+
: 0;
|
|
38
|
+
}
|
|
39
|
+
function truncateTextMessage(item: JsonObject, maxTokens: number): JsonObject | undefined {
|
|
40
|
+
if (!Array.isArray(item.content)) return undefined;
|
|
41
|
+
let remaining = maxTokens;
|
|
42
|
+
const content: unknown[] = [];
|
|
43
|
+
for (const part of item.content) {
|
|
44
|
+
if (!isTextPart(part)) { content.push(part); continue; }
|
|
45
|
+
if (remaining === 0) continue;
|
|
46
|
+
const tokenCount = approximateTokenCount(part.text);
|
|
47
|
+
const text = tokenCount <= remaining ? part.text : truncateTextToTokenBudget(part.text, remaining);
|
|
48
|
+
remaining = Math.max(0, remaining - tokenCount);
|
|
49
|
+
if (text) content.push({ ...part, text });
|
|
50
|
+
}
|
|
51
|
+
return content.length ? { ...item, content } : undefined;
|
|
52
|
+
}
|
|
53
|
+
function truncateImageMessage(item: JsonObject, maxTokens: number, images: ImageEstimates): JsonObject | undefined {
|
|
54
|
+
if (!Array.isArray(item.content)) return undefined;
|
|
55
|
+
const pending: unknown[] = [...item.content];
|
|
56
|
+
const reversed: unknown[] = [];
|
|
57
|
+
let remaining = maxTokens;
|
|
58
|
+
while (pending.length) {
|
|
59
|
+
const last = pending.length - 1;
|
|
60
|
+
const imageIndex = isInputImage(pending[last]) ? last
|
|
61
|
+
: isTag(pending[last], IMAGE_CLOSE_TAG) && last > 0 && isInputImage(pending[last - 1]) ? last - 1 : undefined;
|
|
62
|
+
if (imageIndex !== undefined) {
|
|
63
|
+
const start = imageIndex > 0 && isImageOpenTag(pending[imageIndex - 1]) ? imageIndex - 1 : imageIndex;
|
|
64
|
+
const tokens = pending.slice(start).reduce((sum: number, part: unknown) => sum + partTokenCount(part, images), 0);
|
|
65
|
+
const fits = tokens <= remaining;
|
|
66
|
+
remaining = fits ? remaining - tokens : 0;
|
|
67
|
+
if (fits) reversed.push(...pending.slice(start).reverse());
|
|
68
|
+
pending.length = start;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const part = pending.pop();
|
|
72
|
+
if (!isTextPart(part)) { reversed.push(part); continue; }
|
|
73
|
+
if (!remaining) continue;
|
|
74
|
+
const tokens = approximateTokenCount(part.text);
|
|
75
|
+
const text = tokens <= remaining ? part.text : truncateTextToTokenBudget(part.text, remaining);
|
|
76
|
+
remaining = Math.max(0, remaining - tokens);
|
|
77
|
+
if (text) reversed.push({ ...part, text });
|
|
78
|
+
}
|
|
79
|
+
return reversed.length ? { ...item, content: reversed.reverse() } : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Keep newest user groups within Codex's fixed budget, preserving image/label groups atomically. */
|
|
83
|
+
export function buildReplacementHistory({ groups, images }: RetentionInput, compactionItem: JsonObject): JsonObject[] {
|
|
84
|
+
let remaining = RETAINED_MESSAGE_TOKEN_BUDGET;
|
|
85
|
+
const reversed: JsonObject[] = [];
|
|
86
|
+
for (let index = groups.length - 1; index >= 0 && remaining > 0; index--) {
|
|
87
|
+
const { source, notice } = groups[index];
|
|
88
|
+
const noticeTokens = notice ? Math.max(1, textTokenCount(notice)) : 0;
|
|
89
|
+
const sourceTokens = Math.max(1, Array.isArray(source.content)
|
|
90
|
+
? source.content.reduce((sum: number, part: unknown) => sum + partTokenCount(part, images), 0) : 0);
|
|
91
|
+
const hasImages = Array.isArray(source.content) && source.content.some(isInputImage);
|
|
92
|
+
if (sourceTokens + noticeTokens <= remaining) {
|
|
93
|
+
if (notice) reversed.push(notice);
|
|
94
|
+
reversed.push(source);
|
|
95
|
+
remaining -= sourceTokens + noticeTokens;
|
|
96
|
+
} else if (remaining > noticeTokens) {
|
|
97
|
+
const budget = remaining - noticeTokens;
|
|
98
|
+
if (hasImages) remaining = 0;
|
|
99
|
+
const truncated = hasImages ? truncateImageMessage(source, budget, images) : truncateTextMessage(source, budget);
|
|
100
|
+
if (!truncated) continue;
|
|
101
|
+
if (notice) reversed.push(notice);
|
|
102
|
+
reversed.push(truncated);
|
|
103
|
+
remaining = 0;
|
|
104
|
+
} else if (hasImages) remaining = 0;
|
|
105
|
+
}
|
|
106
|
+
// The collector validated the item; checkpoint parsing validates it again on persistence.
|
|
107
|
+
return structuredClone([...reversed.reverse(), compactionItem]);
|
|
108
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Own Codex's approximate token counting and UTF-8 middle truncation.
|
|
2
|
+
const APPROX_BYTES_PER_TOKEN = 4;
|
|
3
|
+
|
|
4
|
+
export function approximateTokensFromBytes(bytes: number): number {
|
|
5
|
+
return bytes <= 0 ? 0 : Math.ceil(bytes / APPROX_BYTES_PER_TOKEN);
|
|
6
|
+
}
|
|
7
|
+
export function approximateBytesForTokens(tokens: number): number {
|
|
8
|
+
return tokens * APPROX_BYTES_PER_TOKEN;
|
|
9
|
+
}
|
|
10
|
+
export function approximateTokenCount(text: string): number {
|
|
11
|
+
return approximateTokensFromBytes(Buffer.byteLength(text, "utf8"));
|
|
12
|
+
}
|
|
13
|
+
export function truncateTextToTokenBudget(text: string, maxTokens: number): string {
|
|
14
|
+
const budgetBytes = maxTokens * APPROX_BYTES_PER_TOKEN;
|
|
15
|
+
const totalBytes = Buffer.byteLength(text, "utf8");
|
|
16
|
+
if (totalBytes <= budgetBytes) return text;
|
|
17
|
+
const prefixBudgetBytes = Math.floor(budgetBytes / 2);
|
|
18
|
+
const suffixStartBytes = totalBytes - (budgetBytes - prefixBudgetBytes);
|
|
19
|
+
// Track UTF-16 indices of the character boundaries so both ends are sliced once.
|
|
20
|
+
let offsetBytes = 0;
|
|
21
|
+
let index = 0;
|
|
22
|
+
let prefixEnd = 0;
|
|
23
|
+
let suffixStart = text.length;
|
|
24
|
+
for (const character of text) {
|
|
25
|
+
const endBytes = offsetBytes + Buffer.byteLength(character, "utf8");
|
|
26
|
+
if (endBytes <= prefixBudgetBytes) prefixEnd = index + character.length;
|
|
27
|
+
else if (offsetBytes >= suffixStartBytes) {
|
|
28
|
+
suffixStart = index;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
offsetBytes = endBytes;
|
|
32
|
+
index += character.length;
|
|
33
|
+
}
|
|
34
|
+
const marker = `…${approximateTokensFromBytes(totalBytes - budgetBytes)} tokens truncated…`;
|
|
35
|
+
return `${text.slice(0, prefixEnd)}${marker}${text.slice(suffixStart)}`;
|
|
36
|
+
}
|