pi-openai-toolkit 0.11.1 → 0.13.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/NOTICE +28 -0
- package/README.md +57 -23
- package/README_zh.md +57 -23
- package/package.json +126 -114
- package/src/config.ts +39 -69
- package/src/context-management/codex-provider.ts +261 -0
- package/src/context-management/history-notes.ts +325 -0
- package/src/context-management/messages.ts +106 -0
- package/src/context-management/namespace-tools.ts +426 -0
- package/src/context-management/tool-contract.ts +23 -0
- package/src/context-management/tools.ts +313 -0
- package/src/context-management/types.ts +160 -0
- package/src/context-management/window-budget.ts +83 -0
- package/src/context-management/window-manager.ts +427 -0
- package/src/context-management/window-request.ts +125 -0
- package/src/debug.ts +3 -3
- package/src/extension-runtime.ts +305 -18
- package/src/payload-rewrite.ts +40 -10
- package/src/remote-v2-client.ts +340 -99
- package/src/request-context-cache.ts +36 -3
- package/src/serializer.ts +4 -12
- package/src/types.ts +16 -19
- package/tsconfig.check.json +0 -1
- package/src/inline-compaction.ts +0 -1264
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { StringEnum, Type } from "@earendil-works/pi-ai";
|
|
3
|
+
import { executeHistoryNotesTool, type CodexHistoryNotesDetails } from "./history-notes";
|
|
4
|
+
import { type CodexContextWindowManager } from "./window-manager";
|
|
5
|
+
import {
|
|
6
|
+
HISTORY_ACTION_FIELDS,
|
|
7
|
+
HISTORY_ENDPOINTS,
|
|
8
|
+
NOTES_ACTION_FIELDS,
|
|
9
|
+
NOTES_ENDPOINTS,
|
|
10
|
+
} from "./history-notes";
|
|
11
|
+
import type { HistoryAction, NotesAction } from "./types";
|
|
12
|
+
|
|
13
|
+
const EMPTY_PARAMETERS = Type.Object({}, { additionalProperties: false });
|
|
14
|
+
|
|
15
|
+
function arrayEquals(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean {
|
|
16
|
+
if (a === b) return true;
|
|
17
|
+
if (!a || !b || a.length !== b.length) return false;
|
|
18
|
+
for (let i = 0; i < a.length; i++) {
|
|
19
|
+
if (a[i] !== b[i]) return false;
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
const HISTORY_ACTIONS = Object.keys(HISTORY_ENDPOINTS) as [HistoryAction, ...HistoryAction[]];
|
|
24
|
+
const NOTES_ACTIONS = Object.keys(NOTES_ENDPOINTS) as [NotesAction, ...NotesAction[]];
|
|
25
|
+
|
|
26
|
+
export const HISTORY_PARAMETERS = Type.Object({
|
|
27
|
+
action: StringEnum(HISTORY_ACTIONS),
|
|
28
|
+
agent_name: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
29
|
+
item_id: Type.Optional(Type.String()),
|
|
30
|
+
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
31
|
+
limit_chars: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
32
|
+
max_chars_per_item: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
33
|
+
offset_chars: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
34
|
+
query: Type.Optional(Type.String()),
|
|
35
|
+
recent_first: Type.Optional(Type.Boolean()),
|
|
36
|
+
role: Type.Optional(Type.Union([StringEnum(["user", "assistant", "tool", "system", "developer"] as const), Type.Null()])),
|
|
37
|
+
tool_name: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
38
|
+
tool_namespace: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
39
|
+
window_id: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
40
|
+
}, { additionalProperties: false });
|
|
41
|
+
|
|
42
|
+
export const NOTES_PARAMETERS = Type.Object({
|
|
43
|
+
action: StringEnum(NOTES_ACTIONS),
|
|
44
|
+
file_order: Type.Optional(StringEnum(["ascending", "descending"] as const)),
|
|
45
|
+
file_order_by: Type.Optional(StringEnum(["name", "created_at", "updated_at"] as const)),
|
|
46
|
+
max_files: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
47
|
+
max_matches_per_file: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
48
|
+
max_results: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
49
|
+
path: Type.Optional(Type.String()),
|
|
50
|
+
path_prefix: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
51
|
+
prefix: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
52
|
+
query: Type.Optional(Type.String()),
|
|
53
|
+
recent_file_first: Type.Optional(Type.Boolean()),
|
|
54
|
+
start_line: Type.Optional(Type.Union([Type.Integer(), Type.Null()])),
|
|
55
|
+
stop_line: Type.Optional(Type.Union([Type.Integer(), Type.Null()])),
|
|
56
|
+
text: Type.Optional(Type.String()),
|
|
57
|
+
}, { additionalProperties: false });
|
|
58
|
+
|
|
59
|
+
export interface NewContextDetails { started: boolean; }
|
|
60
|
+
|
|
61
|
+
export const NEW_CONTEXT_PARAMETERS = Type.Object({
|
|
62
|
+
force: Type.Optional(Type.Boolean({
|
|
63
|
+
description: "Roll over even without a successful notes checkpoint in this window. Discards unsaved working state.",
|
|
64
|
+
})),
|
|
65
|
+
}, { additionalProperties: false });
|
|
66
|
+
|
|
67
|
+
export const NEW_CONTEXT_CHECKPOINT_REQUIRED_MESSAGE =
|
|
68
|
+
"new_context refused: no successful notes checkpoint in this window. "
|
|
69
|
+
+ "Save the active request, decisions, progress and next steps with notes append_to_file or write_file, then retry. "
|
|
70
|
+
+ "Pass force=true only when the user explicitly accepts losing unsaved working state.";
|
|
71
|
+
export interface ContextRemainingDetails {
|
|
72
|
+
remainingTokens?: number;
|
|
73
|
+
windowId?: string;
|
|
74
|
+
contextWindow: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type ContextManagementTools = {
|
|
78
|
+
newContext: ToolDefinition<typeof NEW_CONTEXT_PARAMETERS, NewContextDetails>;
|
|
79
|
+
getContextRemaining: ToolDefinition<typeof EMPTY_PARAMETERS, ContextRemainingDetails>;
|
|
80
|
+
history: ToolDefinition<typeof HISTORY_PARAMETERS, CodexHistoryNotesDetails>;
|
|
81
|
+
notes: ToolDefinition<typeof NOTES_PARAMETERS, CodexHistoryNotesDetails>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function createContextManagementTools(
|
|
85
|
+
pi: ExtensionAPI,
|
|
86
|
+
manager: CodexContextWindowManager,
|
|
87
|
+
isActive: (ctx: ExtensionContext) => Promise<boolean> | boolean,
|
|
88
|
+
gatewayAllowlist: readonly string[] | (() => readonly string[]) = [],
|
|
89
|
+
): ContextManagementTools {
|
|
90
|
+
const getGatewayAllowlist = (): readonly string[] =>
|
|
91
|
+
typeof gatewayAllowlist === "function" ? gatewayAllowlist() : gatewayAllowlist;
|
|
92
|
+
const assertActive = async (ctx: ExtensionContext): Promise<void> => {
|
|
93
|
+
if (!(await isActive(ctx))) throw new Error("remote-context-inactive");
|
|
94
|
+
};
|
|
95
|
+
const newContext: ToolDefinition<typeof NEW_CONTEXT_PARAMETERS, NewContextDetails> = {
|
|
96
|
+
name: "new_context",
|
|
97
|
+
label: "new_context",
|
|
98
|
+
description: "Start a new remote Codex context window without generating a conversation summary. Requires a successful notes checkpoint in the current window unless force is set.",
|
|
99
|
+
parameters: NEW_CONTEXT_PARAMETERS,
|
|
100
|
+
promptSnippet: "Start a new remote Codex context window without summarizing history.",
|
|
101
|
+
promptGuidelines: ["Checkpoint active work in notes before calling new_context; no conversation summary carries over. A successful notes append/write in this window is required unless the user explicitly accepts discarding unsaved state (force=true)."],
|
|
102
|
+
executionMode: "sequential",
|
|
103
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
104
|
+
await assertActive(ctx);
|
|
105
|
+
if (!params.force && !manager.hasNotesCheckpointSinceBoundary(ctx)) {
|
|
106
|
+
throw new Error(NEW_CONTEXT_CHECKPOINT_REQUIRED_MESSAGE);
|
|
107
|
+
}
|
|
108
|
+
const started = await manager.startNewWindow(pi, ctx, {
|
|
109
|
+
triggerTurn: true,
|
|
110
|
+
signal,
|
|
111
|
+
trimPreviousWindow: true,
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
content: [{ type: "text", text: started ? "A new context window will start without summarizing conversation history." : "A new context window is already scheduled." }],
|
|
115
|
+
details: { started },
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const getContextRemaining: ToolDefinition<typeof EMPTY_PARAMETERS, ContextRemainingDetails> = {
|
|
120
|
+
name: "get_context_remaining",
|
|
121
|
+
label: "get_context_remaining",
|
|
122
|
+
description: "Get the remaining tokens in the current remote Codex context window.",
|
|
123
|
+
parameters: EMPTY_PARAMETERS,
|
|
124
|
+
async execute(_id, _params, _signal, _update, ctx) {
|
|
125
|
+
await assertActive(ctx);
|
|
126
|
+
const remaining = manager.remaining(ctx);
|
|
127
|
+
return {
|
|
128
|
+
content: [{ type: "text", text: remaining.remainingTokens === undefined ? "You have unknown tokens left in this context window." : `You have ${remaining.remainingTokens} tokens left in this context window.` }],
|
|
129
|
+
details: remaining,
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
const history: ToolDefinition<typeof HISTORY_PARAMETERS, CodexHistoryNotesDetails> = {
|
|
134
|
+
name: "history",
|
|
135
|
+
label: "history",
|
|
136
|
+
description: "Search or read prior remote Codex context-window history. Pass IDs unchanged.",
|
|
137
|
+
parameters: HISTORY_PARAMETERS,
|
|
138
|
+
promptSnippet: "Search or read prior remote Codex context-window history.",
|
|
139
|
+
promptGuidelines: [
|
|
140
|
+
"When the user asks about decisions, code, or details from earlier in the conversation that are no longer in the current context window, use history first instead of relying on fragments that survived compaction.",
|
|
141
|
+
"Use read_item with the exact item_id returned by list_items or search_contents; never rewrite, truncate, or guess IDs.",
|
|
142
|
+
"Before starting a new context window, use history to verify which earlier work the user still expects to be honored, then checkpoint anything not yet durable in notes.",
|
|
143
|
+
"Prefer search_contents over list_items when you only need a known fact; list_items is for getting an overview of what windows and items exist.",
|
|
144
|
+
"History is read-only; make no edits through it. Edits and durable checkpoints belong to notes.",
|
|
145
|
+
],
|
|
146
|
+
execute: async (_id, params, _signal, _update, ctx) => {
|
|
147
|
+
await assertActive(ctx);
|
|
148
|
+
return executeHistoryNotesTool("history", params.action, params as Record<string, unknown>, ctx, _signal, getGatewayAllowlist());
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
const notes: ToolDefinition<typeof NOTES_PARAMETERS, CodexHistoryNotesDetails> = {
|
|
152
|
+
name: "notes",
|
|
153
|
+
label: "notes",
|
|
154
|
+
description: "Read and checkpoint remote Codex notes across context windows.",
|
|
155
|
+
parameters: NOTES_PARAMETERS,
|
|
156
|
+
promptSnippet: "Read and checkpoint remote Codex notes across context windows.",
|
|
157
|
+
promptGuidelines: [
|
|
158
|
+
"Before calling new_context, checkpoint the current turn's active work (unfinished tasks, decisions, open questions, references) into notes with append_to_file or write_file so it survives the window change.",
|
|
159
|
+
"When a large task spans multiple context windows, keep a running note per line of work and read it at the start of each new window; append new state instead of replacing it unless the note is stale.",
|
|
160
|
+
"Prefer read_file or search_contents for looking things up; reserve write_file for explicit rewrite/clear and append_to_file for incremental state.",
|
|
161
|
+
"Keep note text concise and self-contained: it may be read later without the rest of the conversation, so include identifiers and verbatim key decisions, not hearsay summaries.",
|
|
162
|
+
"Do not store live credentials, API keys, or full request bodies in notes; record the fact and where it lives instead.",
|
|
163
|
+
],
|
|
164
|
+
executionMode: "sequential",
|
|
165
|
+
execute: async (_id, params, _signal, _update, ctx) => {
|
|
166
|
+
await assertActive(ctx);
|
|
167
|
+
return executeHistoryNotesTool("notes", params.action, params as Record<string, unknown>, ctx, _signal, getGatewayAllowlist());
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
return { newContext, getContextRemaining, history, notes };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export class ContextManagementToolController {
|
|
174
|
+
private readonly registeredNames = new Set<string>();
|
|
175
|
+
private readonly definitions = new Map<string, {
|
|
176
|
+
description: string;
|
|
177
|
+
parameters: unknown;
|
|
178
|
+
promptGuidelines?: string[];
|
|
179
|
+
}>();
|
|
180
|
+
private readonly ownedNames = new Set<string>();
|
|
181
|
+
private readonly baselineNames = new Set<string>();
|
|
182
|
+
private registered = false;
|
|
183
|
+
private registrationChecked = false;
|
|
184
|
+
private registrationValid = false;
|
|
185
|
+
private baselineCaptured = false;
|
|
186
|
+
|
|
187
|
+
constructor(private readonly pi: ExtensionAPI) {}
|
|
188
|
+
|
|
189
|
+
register(tools: ContextManagementTools): boolean {
|
|
190
|
+
const definitions = [tools.newContext, tools.getContextRemaining, tools.history, tools.notes];
|
|
191
|
+
const api = this.pi as ExtensionAPI & { registerTool?: (tool: unknown) => void };
|
|
192
|
+
if (typeof api.registerTool !== "function") return false;
|
|
193
|
+
|
|
194
|
+
// registerTool() is a registration method and is valid while Pi is loading
|
|
195
|
+
// the extension. Do not call action methods such as getAllTools() here;
|
|
196
|
+
// those are bound only after extension loading completes.
|
|
197
|
+
try {
|
|
198
|
+
for (const definition of definitions) api.registerTool(definition);
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
this.registeredNames.clear();
|
|
204
|
+
this.definitions.clear();
|
|
205
|
+
for (const definition of definitions) {
|
|
206
|
+
this.registeredNames.add(definition.name);
|
|
207
|
+
this.definitions.set(definition.name, {
|
|
208
|
+
description: definition.description,
|
|
209
|
+
parameters: definition.parameters,
|
|
210
|
+
promptGuidelines: definition.promptGuidelines,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
this.registered = true;
|
|
214
|
+
this.registrationChecked = false;
|
|
215
|
+
this.registrationValid = false;
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private verifyRegistration(): boolean {
|
|
220
|
+
if (!this.registered) return false;
|
|
221
|
+
if (this.registrationChecked) return this.registrationValid;
|
|
222
|
+
this.registrationChecked = true;
|
|
223
|
+
try {
|
|
224
|
+
const api = this.pi as ExtensionAPI & { getAllTools?: () => Array<{
|
|
225
|
+
name: string;
|
|
226
|
+
description: string;
|
|
227
|
+
parameters: unknown;
|
|
228
|
+
promptGuidelines?: string[];
|
|
229
|
+
}> };
|
|
230
|
+
if (typeof api.getAllTools !== "function") return false;
|
|
231
|
+
const available = new Map(api.getAllTools().map((tool) => [tool.name, tool]));
|
|
232
|
+
this.registrationValid = [...this.registeredNames].every((name) => {
|
|
233
|
+
const actual = available.get(name);
|
|
234
|
+
const expected = this.definitions.get(name);
|
|
235
|
+
return actual !== undefined && expected !== undefined &&
|
|
236
|
+
actual.description === expected.description &&
|
|
237
|
+
actual.parameters === expected.parameters &&
|
|
238
|
+
actual.promptGuidelines === expected.promptGuidelines;
|
|
239
|
+
});
|
|
240
|
+
} catch {
|
|
241
|
+
this.registrationValid = false;
|
|
242
|
+
}
|
|
243
|
+
if (!this.registrationValid) this.ownedNames.clear();
|
|
244
|
+
return this.registrationValid;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private captureBaseline(): boolean {
|
|
248
|
+
if (this.baselineCaptured) return true;
|
|
249
|
+
try {
|
|
250
|
+
const api = this.pi as ExtensionAPI & { getActiveTools?: () => string[] };
|
|
251
|
+
if (typeof api.getActiveTools !== "function") return false;
|
|
252
|
+
this.baselineNames.clear();
|
|
253
|
+
for (const name of api.getActiveTools()) this.baselineNames.add(name);
|
|
254
|
+
this.baselineCaptured = true;
|
|
255
|
+
return true;
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
sync(active: boolean): boolean {
|
|
262
|
+
if (!this.verifyRegistration() || !this.captureBaseline()) return false;
|
|
263
|
+
const api = this.pi as ExtensionAPI & { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
|
|
264
|
+
if (typeof api.getActiveTools !== "function" || typeof api.setActiveTools !== "function") return false;
|
|
265
|
+
try {
|
|
266
|
+
const current = api.getActiveTools();
|
|
267
|
+
if (active) {
|
|
268
|
+
const next = [...current];
|
|
269
|
+
for (const name of this.registeredNames) {
|
|
270
|
+
if (!next.includes(name) && !this.baselineNames.has(name)) {
|
|
271
|
+
next.push(name);
|
|
272
|
+
this.ownedNames.add(name);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (next.length !== current.length) api.setActiveTools(next);
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
const next = current.filter((name) => !this.ownedNames.has(name));
|
|
279
|
+
if (next.length !== current.length) api.setActiveTools(next);
|
|
280
|
+
this.ownedNames.clear();
|
|
281
|
+
return true;
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
reset(): void {
|
|
288
|
+
this.sync(false);
|
|
289
|
+
this.baselineNames.clear();
|
|
290
|
+
this.baselineCaptured = false;
|
|
291
|
+
}
|
|
292
|
+
get isRegistered(): boolean { return this.registrationValid; }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function registerContextManagementTools(
|
|
296
|
+
pi: ExtensionAPI,
|
|
297
|
+
manager: CodexContextWindowManager,
|
|
298
|
+
isActive: (ctx: ExtensionContext) => Promise<boolean> | boolean,
|
|
299
|
+
gatewayAllowlist: readonly string[] | (() => readonly string[]) = [],
|
|
300
|
+
): ContextManagementToolController {
|
|
301
|
+
const controller = new ContextManagementToolController(pi);
|
|
302
|
+
controller.register(createContextManagementTools(pi, manager, isActive, gatewayAllowlist));
|
|
303
|
+
return controller;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export const _contextToolsTest = {
|
|
307
|
+
EMPTY_PARAMETERS,
|
|
308
|
+
NEW_CONTEXT_PARAMETERS,
|
|
309
|
+
HISTORY_PARAMETERS,
|
|
310
|
+
NOTES_PARAMETERS,
|
|
311
|
+
HISTORY_ACTION_FIELDS,
|
|
312
|
+
NOTES_ACTION_FIELDS,
|
|
313
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { ContextManagementMode } from "../types";
|
|
2
|
+
|
|
3
|
+
export type { ContextManagementMode };
|
|
4
|
+
|
|
5
|
+
export const CONTEXT_MANAGEMENT_PROTOCOL = 1 as const;
|
|
6
|
+
export const CODEX_CONTEXT_WINDOW_MESSAGE_TYPE = "codex-context-window" as const;
|
|
7
|
+
export const CONTEXT_WINDOW_COMPACTION_STRATEGY = "codex-context-window" as const;
|
|
8
|
+
export const CONTEXT_WINDOW_REMINDER_THRESHOLD = 6_144;
|
|
9
|
+
export const CONTEXT_WINDOW_FALLBACK_BUFFER = 16_384;
|
|
10
|
+
|
|
11
|
+
export type ContextManagementMessageKind = "window" | "reminder" | "fallback";
|
|
12
|
+
|
|
13
|
+
export type ContextWindowIdentity = {
|
|
14
|
+
firstWindowId: string;
|
|
15
|
+
currentWindowId: string;
|
|
16
|
+
previousWindowId?: string;
|
|
17
|
+
windowNumber: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type CodexContextManagementMessageDetails = {
|
|
21
|
+
protocol: typeof CONTEXT_MANAGEMENT_PROTOCOL;
|
|
22
|
+
id: string;
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
contextManagement: ContextWindowIdentity & {
|
|
25
|
+
protocol: typeof CONTEXT_MANAGEMENT_PROTOCOL;
|
|
26
|
+
kind: ContextManagementMessageKind;
|
|
27
|
+
trimPreviousWindow?: true;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ContextWindowCompactionDetails = {
|
|
32
|
+
protocol: typeof CONTEXT_MANAGEMENT_PROTOCOL;
|
|
33
|
+
strategy: typeof CONTEXT_WINDOW_COMPACTION_STRATEGY;
|
|
34
|
+
windowId?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type RemoteContextFailureReason =
|
|
38
|
+
| "context-management-inactive"
|
|
39
|
+
| "unsupported-model"
|
|
40
|
+
| "unsupported-api"
|
|
41
|
+
| "unsupported-backend"
|
|
42
|
+
| "auth-resolution-failed"
|
|
43
|
+
| "missing-token"
|
|
44
|
+
| "missing-api-key"
|
|
45
|
+
| "missing-account-id"
|
|
46
|
+
| "missing-base-url"
|
|
47
|
+
| "invalid-account-token"
|
|
48
|
+
| "backend-timeout"
|
|
49
|
+
| "aborted"
|
|
50
|
+
| "http-error"
|
|
51
|
+
| "invalid-json"
|
|
52
|
+
| "invalid-response"
|
|
53
|
+
| "protocol-error";
|
|
54
|
+
|
|
55
|
+
export type NativeCodexContextProvider = {
|
|
56
|
+
kind: "native-codex";
|
|
57
|
+
route: "openai-codex";
|
|
58
|
+
provider: "openai-codex";
|
|
59
|
+
api: "openai-codex-responses";
|
|
60
|
+
model: string;
|
|
61
|
+
baseUrl: string;
|
|
62
|
+
token: string;
|
|
63
|
+
accountId: string;
|
|
64
|
+
headers: Record<string, string>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type GatewayCodexContextProvider = {
|
|
68
|
+
kind: "codex-gateway";
|
|
69
|
+
route: "codex-gateway";
|
|
70
|
+
provider: "uwoacrimson";
|
|
71
|
+
api: "openai-responses";
|
|
72
|
+
model: string;
|
|
73
|
+
baseUrl: string;
|
|
74
|
+
apiKey: string;
|
|
75
|
+
headers: Record<string, string>;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type CodexContextProvider = NativeCodexContextProvider | GatewayCodexContextProvider;
|
|
79
|
+
|
|
80
|
+
export type CodexContextProviderResolution =
|
|
81
|
+
| { ok: true; provider: CodexContextProvider }
|
|
82
|
+
| {
|
|
83
|
+
ok: false;
|
|
84
|
+
reason: RemoteContextFailureReason;
|
|
85
|
+
provider?: string;
|
|
86
|
+
api?: string;
|
|
87
|
+
model?: string;
|
|
88
|
+
baseUrl?: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type HistoryAction =
|
|
92
|
+
| "list_windows"
|
|
93
|
+
| "list_items"
|
|
94
|
+
| "read_item"
|
|
95
|
+
| "search_contents";
|
|
96
|
+
|
|
97
|
+
export type NotesAction =
|
|
98
|
+
| "list_files_by_prefix"
|
|
99
|
+
| "read_file"
|
|
100
|
+
| "search_contents"
|
|
101
|
+
| "append_to_file"
|
|
102
|
+
| "write_file";
|
|
103
|
+
|
|
104
|
+
export type HistoryNotesResponse = Record<string, unknown>;
|
|
105
|
+
|
|
106
|
+
export type HistoryNotesResult =
|
|
107
|
+
| { ok: true; value: HistoryNotesResponse }
|
|
108
|
+
| {
|
|
109
|
+
ok: false;
|
|
110
|
+
reason: Exclude<RemoteContextFailureReason, "context-management-inactive" | "unsupported-model" | "unsupported-api">;
|
|
111
|
+
status?: number;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
115
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isNonEmptyString(value: unknown): value is string {
|
|
119
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function isContextWindowIdentity(value: unknown): value is ContextWindowIdentity {
|
|
123
|
+
if (!isRecord(value)) return false;
|
|
124
|
+
return (
|
|
125
|
+
isNonEmptyString(value.firstWindowId) &&
|
|
126
|
+
isNonEmptyString(value.currentWindowId) &&
|
|
127
|
+
(value.previousWindowId === undefined || isNonEmptyString(value.previousWindowId)) &&
|
|
128
|
+
Number.isInteger(value.windowNumber) &&
|
|
129
|
+
(value.windowNumber as number) >= 0
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function isCodexContextManagementMessageDetails(
|
|
134
|
+
value: unknown,
|
|
135
|
+
): value is CodexContextManagementMessageDetails {
|
|
136
|
+
if (!isRecord(value) || value.protocol !== CONTEXT_MANAGEMENT_PROTOCOL || !isNonEmptyString(value.id)) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
if (value.sessionId !== undefined && !isNonEmptyString(value.sessionId)) return false;
|
|
140
|
+
if (!isRecord(value.contextManagement) || value.contextManagement.protocol !== CONTEXT_MANAGEMENT_PROTOCOL) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
const context = value.contextManagement;
|
|
144
|
+
if (!isRecord(context)) return false;
|
|
145
|
+
const trimPreviousWindow = context.trimPreviousWindow;
|
|
146
|
+
return (
|
|
147
|
+
(context.kind === "window" || context.kind === "reminder" || context.kind === "fallback") &&
|
|
148
|
+
isContextWindowIdentity(context) &&
|
|
149
|
+
(trimPreviousWindow === undefined || trimPreviousWindow === true)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function isContextWindowCompactionDetails(value: unknown): value is ContextWindowCompactionDetails {
|
|
154
|
+
return (
|
|
155
|
+
isRecord(value) &&
|
|
156
|
+
value.protocol === CONTEXT_MANAGEMENT_PROTOCOL &&
|
|
157
|
+
value.strategy === CONTEXT_WINDOW_COMPACTION_STRATEGY &&
|
|
158
|
+
(value.windowId === undefined || isNonEmptyString(value.windowId))
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
CONTEXT_WINDOW_FALLBACK_BUFFER,
|
|
4
|
+
CONTEXT_WINDOW_FALLBACK_MESSAGE,
|
|
5
|
+
renderContextWindowReminder,
|
|
6
|
+
type ContextManagementMessageKind,
|
|
7
|
+
type ContextWindowIdentity,
|
|
8
|
+
} from "./messages";
|
|
9
|
+
|
|
10
|
+
export interface ContextRemaining {
|
|
11
|
+
remainingTokens: number | undefined;
|
|
12
|
+
windowId: string | undefined;
|
|
13
|
+
contextWindow: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ContextWindowBudget {
|
|
17
|
+
private readonly remindedWindows = new Set<string>();
|
|
18
|
+
private readonly exhaustedWindows = new Set<string>();
|
|
19
|
+
|
|
20
|
+
reset(): void {
|
|
21
|
+
this.remindedWindows.clear();
|
|
22
|
+
this.exhaustedWindows.clear();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
restore(kind: ContextManagementMessageKind, windowId: string): void {
|
|
26
|
+
if (kind === "reminder") this.remindedWindows.add(windowId);
|
|
27
|
+
if (kind === "fallback") this.exhaustedWindows.add(windowId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
record(
|
|
31
|
+
ctx: ExtensionContext,
|
|
32
|
+
identity: ContextWindowIdentity,
|
|
33
|
+
contextTokens: number | undefined,
|
|
34
|
+
contextReminderThresholdPercent: number,
|
|
35
|
+
): { content: string; kind: "fallback" | "reminder" } | undefined {
|
|
36
|
+
const remaining = this.remaining(ctx, identity, contextTokens);
|
|
37
|
+
if (remaining.remainingTokens === undefined) return undefined;
|
|
38
|
+
const windowId = identity.currentWindowId;
|
|
39
|
+
if (remaining.remainingTokens <= 0 && !this.exhaustedWindows.has(windowId)) {
|
|
40
|
+
this.exhaustedWindows.add(windowId);
|
|
41
|
+
return { content: CONTEXT_WINDOW_FALLBACK_MESSAGE, kind: "fallback" };
|
|
42
|
+
}
|
|
43
|
+
const reminderThreshold =
|
|
44
|
+
contextReminderThresholdPercent > 0
|
|
45
|
+
? Math.floor((remaining.contextWindow * contextReminderThresholdPercent) / 100)
|
|
46
|
+
: 0;
|
|
47
|
+
if (
|
|
48
|
+
contextReminderThresholdPercent > 0 &&
|
|
49
|
+
remaining.remainingTokens <= reminderThreshold &&
|
|
50
|
+
!this.remindedWindows.has(windowId)
|
|
51
|
+
) {
|
|
52
|
+
this.remindedWindows.add(windowId);
|
|
53
|
+
return { content: renderContextWindowReminder(remaining.remainingTokens), kind: "reminder" };
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
remaining(
|
|
59
|
+
ctx: ExtensionContext,
|
|
60
|
+
identity: ContextWindowIdentity | undefined,
|
|
61
|
+
contextTokens?: number,
|
|
62
|
+
): ContextRemaining {
|
|
63
|
+
const usage = ctx.getContextUsage();
|
|
64
|
+
const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
65
|
+
const limit = Math.max(0, contextWindow - CONTEXT_WINDOW_FALLBACK_BUFFER);
|
|
66
|
+
if (usage === undefined && contextTokens === undefined) {
|
|
67
|
+
return {
|
|
68
|
+
remainingTokens: undefined,
|
|
69
|
+
windowId: identity?.currentWindowId,
|
|
70
|
+
contextWindow: limit,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const used = contextTokens ?? usage?.tokens;
|
|
74
|
+
return {
|
|
75
|
+
remainingTokens:
|
|
76
|
+
used === null || used === undefined
|
|
77
|
+
? undefined
|
|
78
|
+
: Math.max(0, limit - used),
|
|
79
|
+
windowId: identity?.currentWindowId,
|
|
80
|
+
contextWindow: limit,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|