pi-openai-toolkit 0.23.1 → 0.24.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/LICENSE +21 -21
- package/NOTICE +83 -83
- package/README.md +4 -11
- package/README.zh.md +4 -11
- package/config.schema.json +264 -278
- package/docs/configuration.md +304 -298
- package/docs/internals.md +98 -181
- package/package.json +123 -135
- package/src/compact-client.ts +412 -412
- package/src/compaction-projection.ts +35 -35
- package/src/config/legacy.ts +12 -62
- package/src/config/migration.ts +115 -103
- package/src/config/notifications.ts +10 -4
- package/src/config/policy.ts +9 -10
- package/src/config/v2.ts +12 -51
- package/src/config.ts +320 -310
- package/src/debug.ts +238 -238
- package/src/deferred-tool-carryover.ts +282 -282
- package/src/details-store.ts +151 -151
- package/src/extension-runtime.ts +1095 -1986
- package/src/image-generation/extension.ts +189 -189
- package/src/image-generation/protocol.ts +579 -579
- package/src/image-generation/types.ts +300 -300
- package/src/native-fallback.ts +14 -23
- package/src/payload-rewrite.ts +515 -515
- package/src/pi-context-hook.ts +117 -117
- package/src/remote-v2-client.ts +811 -811
- package/src/request-context-cache.ts +155 -155
- package/src/responses-headers.ts +180 -180
- package/src/retired-remote-windows.ts +35 -0
- package/src/runtime.ts +875 -865
- package/src/serializer.ts +882 -882
- package/src/supported-environment.ts +27 -27
- package/src/types.ts +541 -565
- package/src/web-search/alpha-client.ts +876 -876
- package/src/web-search/extension.ts +530 -530
- package/src/web-search/payload.ts +299 -299
- package/src/web-search/prompt.ts +60 -60
- package/src/web-search/types.ts +166 -166
- package/tsconfig.check.json +50 -50
- package/src/context-management/codex-provider.ts +0 -270
- package/src/context-management/history-notes.ts +0 -357
- package/src/context-management/manual-compact.ts +0 -599
- package/src/context-management/messages.ts +0 -238
- package/src/context-management/namespace-tools.ts +0 -426
- package/src/context-management/responses-tools.ts +0 -238
- package/src/context-management/tool-contract.ts +0 -62
- package/src/context-management/tools.ts +0 -371
- package/src/context-management/types.ts +0 -168
- package/src/context-management/window-budget.ts +0 -83
- package/src/context-management/window-bulk.ts +0 -155
- package/src/context-management/window-manager.ts +0 -905
- package/src/context-management/window-request.ts +0 -125
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Convert Pi tool declarations into Responses-API wire tools.
|
|
3
|
-
*
|
|
4
|
-
* Pi 1.3 exports `convertResponsesTools` only from
|
|
5
|
-
* `@earendil-works/pi-ai/api/openai-responses-shared`, but Pi's extension module
|
|
6
|
-
* aliases map a fixed allowlist of specifiers and do not include that subpath.
|
|
7
|
-
* Importing it from an extension therefore fails at load time with
|
|
8
|
-
* "Cannot find module", which silently disables every hook the extension owns.
|
|
9
|
-
*
|
|
10
|
-
* This module reproduces that conversion without the subpath import. The strict
|
|
11
|
-
* JSON-schema rewrite is a faithful port of Pi's `makeStrictJsonSchema`: it must
|
|
12
|
-
* stay byte-identical, because a compaction request has to declare the same tool
|
|
13
|
-
* schema as the live request it continues.
|
|
14
|
-
*/
|
|
15
|
-
import type { Tool } from "@earendil-works/pi-ai";
|
|
16
|
-
|
|
17
|
-
export type ResponsesWireTool =
|
|
18
|
-
| { type: "function"; name: string; description: string; parameters: unknown; strict?: boolean | null }
|
|
19
|
-
| { type: "custom"; name: string; description: string; format: { type: string; syntax: string; definition: string } };
|
|
20
|
-
|
|
21
|
-
export type ConvertResponsesToolsOptions = {
|
|
22
|
-
/** Value applied when a tool does not opt in explicitly. `null` means no default. */
|
|
23
|
-
strict?: boolean | null;
|
|
24
|
-
supportsStrictMode?: boolean;
|
|
25
|
-
supportsOpenAIGrammarTools?: boolean;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
type JsonSchema = Record<string, unknown>;
|
|
29
|
-
|
|
30
|
-
type ConstrainedSampling = {
|
|
31
|
-
type: "json_schema" | "grammar";
|
|
32
|
-
strict?: "require" | "prefer" | "ignore";
|
|
33
|
-
variants?: { openai_lark?: string; openai_regex?: string };
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
/** Keywords the strict subset cannot express. Kept in sync with Pi's implementation. */
|
|
37
|
-
const UNSUPPORTED_STRICT_SCHEMA_KEYS = [
|
|
38
|
-
"$ref",
|
|
39
|
-
"$defs",
|
|
40
|
-
"definitions",
|
|
41
|
-
"allOf",
|
|
42
|
-
"oneOf",
|
|
43
|
-
"patternProperties",
|
|
44
|
-
"dependentSchemas",
|
|
45
|
-
"dependencies",
|
|
46
|
-
"unevaluatedProperties",
|
|
47
|
-
"propertyNames",
|
|
48
|
-
"contains",
|
|
49
|
-
"prefixItems",
|
|
50
|
-
"not",
|
|
51
|
-
"if",
|
|
52
|
-
"then",
|
|
53
|
-
"else",
|
|
54
|
-
];
|
|
55
|
-
|
|
56
|
-
class UnsupportedStrictJsonSchemaError extends Error {}
|
|
57
|
-
|
|
58
|
-
function isJsonSchemaObject(value: unknown): value is JsonSchema {
|
|
59
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function isStructuredSchema(schema: unknown): boolean {
|
|
63
|
-
if (!isJsonSchemaObject(schema)) return false;
|
|
64
|
-
const types = typeof schema["type"] === "string"
|
|
65
|
-
? [schema["type"]]
|
|
66
|
-
: Array.isArray(schema["type"]) ? schema["type"] : [];
|
|
67
|
-
return types.includes("object")
|
|
68
|
-
|| types.includes("array")
|
|
69
|
-
|| schema["properties"] !== undefined
|
|
70
|
-
|| schema["items"] !== undefined;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function schemaAllowsNull(schema: unknown): boolean {
|
|
74
|
-
if (!isJsonSchemaObject(schema)) return false;
|
|
75
|
-
if (schema["type"] === "null" || (Array.isArray(schema["type"]) && schema["type"].includes("null"))) return true;
|
|
76
|
-
if (schema["const"] === null || (Array.isArray(schema["enum"]) && schema["enum"].includes(null))) return true;
|
|
77
|
-
return Array.isArray(schema["anyOf"]) && schema["anyOf"].some((variant) => schemaAllowsNull(variant));
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function makeJsonSchemaNodeStrict(schema: unknown): void { if (!isJsonSchemaObject(schema)) throw new UnsupportedStrictJsonSchemaError("boolean schemas are unsupported");
|
|
81
|
-
for (const key of UNSUPPORTED_STRICT_SCHEMA_KEYS) {
|
|
82
|
-
if (schema[key] !== undefined) throw new UnsupportedStrictJsonSchemaError(`${key} schemas are unsupported`);
|
|
83
|
-
}
|
|
84
|
-
if (schema["anyOf"] !== undefined) {
|
|
85
|
-
if (!Array.isArray(schema["anyOf"]) || schema["anyOf"].length === 0) {
|
|
86
|
-
throw new UnsupportedStrictJsonSchemaError("anyOf must contain at least one schema");
|
|
87
|
-
}
|
|
88
|
-
for (const variant of schema["anyOf"]) {
|
|
89
|
-
if (isStructuredSchema(variant)) throw new UnsupportedStrictJsonSchemaError("object and array unions are unsupported");
|
|
90
|
-
makeJsonSchemaNodeStrict(variant);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
if (schema["items"] !== undefined) {
|
|
94
|
-
if (Array.isArray(schema["items"])) throw new UnsupportedStrictJsonSchemaError("tuple schemas are unsupported");
|
|
95
|
-
makeJsonSchemaNodeStrict(schema["items"]);
|
|
96
|
-
}
|
|
97
|
-
const isObjectSchema = schema["type"] === "object";
|
|
98
|
-
if (schema["properties"] !== undefined && !isObjectSchema) {
|
|
99
|
-
throw new UnsupportedStrictJsonSchemaError("properties require type object");
|
|
100
|
-
}
|
|
101
|
-
if (!isObjectSchema) return;
|
|
102
|
-
if (schema["additionalProperties"] !== undefined && schema["additionalProperties"] !== false) {
|
|
103
|
-
throw new UnsupportedStrictJsonSchemaError("schema-valued or true additionalProperties is unsupported");
|
|
104
|
-
}
|
|
105
|
-
if (schema["properties"] !== undefined && !isJsonSchemaObject(schema["properties"])) {
|
|
106
|
-
throw new UnsupportedStrictJsonSchemaError("object properties must be a schema map");
|
|
107
|
-
}
|
|
108
|
-
if (schema["required"] !== undefined
|
|
109
|
-
&& (!Array.isArray(schema["required"]) || schema["required"].some((key) => typeof key !== "string"))) {
|
|
110
|
-
throw new UnsupportedStrictJsonSchemaError("object required must be a string array");
|
|
111
|
-
}
|
|
112
|
-
const properties = isJsonSchemaObject(schema["properties"]) ? schema["properties"] : {};
|
|
113
|
-
const propertyNames = Object.keys(properties);
|
|
114
|
-
const required = new Set(Array.isArray(schema["required"]) ? schema["required"] as string[] : []);
|
|
115
|
-
if ([...required].some((key) => !propertyNames.includes(key))) {
|
|
116
|
-
throw new UnsupportedStrictJsonSchemaError("required contains an unknown property");
|
|
117
|
-
}
|
|
118
|
-
for (const [key, property] of Object.entries(properties)) {
|
|
119
|
-
makeJsonSchemaNodeStrict(property);
|
|
120
|
-
if (!required.has(key) && !schemaAllowsNull(property)) {
|
|
121
|
-
properties[key] = { anyOf: [property, { type: "null" }] };
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
schema["required"] = propertyNames;
|
|
125
|
-
schema["additionalProperties"] = false;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Convert a tool schema to the strict subset expected by provider constrained sampling. */
|
|
129
|
-
function makeStrictJsonSchema(schema: unknown): unknown {
|
|
130
|
-
const cloned = structuredClone(schema);
|
|
131
|
-
if (!isJsonSchemaObject(cloned)) throw new UnsupportedStrictJsonSchemaError("root schema must have type object");
|
|
132
|
-
makeJsonSchemaNodeStrict(cloned);
|
|
133
|
-
if (cloned["type"] !== "object") throw new UnsupportedStrictJsonSchemaError("root schema must have type object");
|
|
134
|
-
return cloned;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function constrainedSamplingOf(tool: Tool): ConstrainedSampling | undefined { const value = (tool as { constrainedSampling?: unknown }).constrainedSampling;
|
|
138
|
-
if (!value || typeof value !== "object") return undefined;
|
|
139
|
-
const type = (value as { type?: unknown }).type;
|
|
140
|
-
if (type !== "json_schema" && type !== "grammar") return undefined;
|
|
141
|
-
return value as ConstrainedSampling;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Only `openai_lark` / `openai_regex` variants can be expressed on the Responses wire. */
|
|
145
|
-
function grammarOf(tool: Tool): { syntax: string; definition: string } | undefined {
|
|
146
|
-
const sampling = constrainedSamplingOf(tool);
|
|
147
|
-
if (sampling?.type !== "grammar") return undefined;
|
|
148
|
-
const lark = sampling.variants?.openai_lark;
|
|
149
|
-
const regex = sampling.variants?.openai_regex;
|
|
150
|
-
const hasLark = typeof lark === "string" && lark.trim().length > 0;
|
|
151
|
-
const hasRegex = typeof regex === "string" && regex.trim().length > 0;
|
|
152
|
-
if (!hasLark && !hasRegex) {
|
|
153
|
-
throw new Error(`Tool "${tool.name}" cannot use grammar constrained sampling: no supported grammar variant was provided.`);
|
|
154
|
-
}
|
|
155
|
-
return hasLark ? { syntax: "lark", definition: lark! } : { syntax: "regex", definition: regex! };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Whether the schema can be expressed in the strict subset at all. */
|
|
159
|
-
function isStrictExpressible(parameters: unknown): boolean {
|
|
160
|
-
try {
|
|
161
|
-
makeStrictJsonSchema(parameters);
|
|
162
|
-
return true;
|
|
163
|
-
} catch (error) {
|
|
164
|
-
if (error instanceof UnsupportedStrictJsonSchemaError) return false;
|
|
165
|
-
throw error;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** The reason `makeStrictJsonSchema` refused, for fail-closed error text. */
|
|
170
|
-
function strictUnsupportedReason(parameters: unknown): string {
|
|
171
|
-
try {
|
|
172
|
-
makeStrictJsonSchema(parameters);
|
|
173
|
-
return "the schema is unsupported";
|
|
174
|
-
} catch (error) {
|
|
175
|
-
return error instanceof Error ? error.message : String(error);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Resolve the effective strict flag for one tool, mirroring Pi's
|
|
181
|
-
* `resolveJsonSchemaStrictSampling` + `defaultStrict` composition.
|
|
182
|
-
*
|
|
183
|
-
* For any `json_schema` constrained sampling, Pi ignores the declared
|
|
184
|
-
* `strict` mode when the schema is expressible: it simply returns `true`.
|
|
185
|
-
* The mode only matters on the failure path, where `require` throws and
|
|
186
|
-
* everything else falls back to the caller's default. Pi's `defaultStrict`
|
|
187
|
-
* is `options.strict === undefined ? false : options.strict`, so an explicit
|
|
188
|
-
* `null` is forwarded verbatim onto the wire.
|
|
189
|
-
*/
|
|
190
|
-
function strictFor(tool: Tool, options: ConvertResponsesToolsOptions): boolean | null {
|
|
191
|
-
const supportsStrictMode = options.supportsStrictMode ?? true;
|
|
192
|
-
const sampling = constrainedSamplingOf(tool);
|
|
193
|
-
const fallback = options.strict === undefined ? false : options.strict;
|
|
194
|
-
if (sampling?.type !== "json_schema") return fallback;
|
|
195
|
-
if (supportsStrictMode) {
|
|
196
|
-
if (isStrictExpressible(tool.parameters)) return true;
|
|
197
|
-
if (sampling.strict === "require") {
|
|
198
|
-
throw new Error(`Tool "${tool.name}" requires JSON-schema constrained sampling, but ${strictUnsupportedReason(tool.parameters)}.`);
|
|
199
|
-
}
|
|
200
|
-
return fallback;
|
|
201
|
-
}
|
|
202
|
-
if (sampling.strict === "require") {
|
|
203
|
-
throw new Error(`Tool "${tool.name}" requires JSON-schema constrained sampling, but strict tools are unsupported.`);
|
|
204
|
-
}
|
|
205
|
-
return fallback;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** Mirror Pi's wire shape so compaction requests match ordinary Responses requests. */
|
|
209
|
-
export function convertResponsesTools(
|
|
210
|
-
tools: readonly Tool[],
|
|
211
|
-
options: ConvertResponsesToolsOptions = {},
|
|
212
|
-
): ResponsesWireTool[] {
|
|
213
|
-
const supportsStrictMode = options.supportsStrictMode ?? true;
|
|
214
|
-
const supportsOpenAIGrammarTools = options.supportsOpenAIGrammarTools ?? false;
|
|
215
|
-
return tools.map((tool) => {
|
|
216
|
-
if (supportsOpenAIGrammarTools) {
|
|
217
|
-
const grammar = grammarOf(tool);
|
|
218
|
-
if (grammar) {
|
|
219
|
-
return {
|
|
220
|
-
type: "custom" as const,
|
|
221
|
-
name: tool.name,
|
|
222
|
-
description: tool.description,
|
|
223
|
-
format: { type: "grammar", ...grammar },
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
const strict = strictFor(tool, options);
|
|
228
|
-
const wire: ResponsesWireTool = {
|
|
229
|
-
type: "function",
|
|
230
|
-
name: tool.name,
|
|
231
|
-
description: tool.description,
|
|
232
|
-
parameters: strict === true ? makeStrictJsonSchema(tool.parameters) : tool.parameters,
|
|
233
|
-
};
|
|
234
|
-
// Pi writes `strict` whenever the transport supports it, including `false` and `null`.
|
|
235
|
-
if (supportsStrictMode) wire.strict = strict;
|
|
236
|
-
return wire;
|
|
237
|
-
});
|
|
238
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
export const HISTORY_ACTIONS = [
|
|
2
|
-
"list_windows",
|
|
3
|
-
"list_items",
|
|
4
|
-
"read_item",
|
|
5
|
-
"search_contents",
|
|
6
|
-
] as const;
|
|
7
|
-
|
|
8
|
-
export const NOTES_ACTIONS = [
|
|
9
|
-
"list_files_by_prefix",
|
|
10
|
-
"read_file",
|
|
11
|
-
"search_contents",
|
|
12
|
-
"append_to_file",
|
|
13
|
-
"write_file",
|
|
14
|
-
] as const;
|
|
15
|
-
|
|
16
|
-
/** Stable names shared by Toolkit registration and an external Pi launcher. */
|
|
17
|
-
export const CONTEXT_MANAGEMENT_TOOL_NAMES = [
|
|
18
|
-
"new_context",
|
|
19
|
-
"get_context_remaining",
|
|
20
|
-
"history",
|
|
21
|
-
"notes",
|
|
22
|
-
] as const;
|
|
23
|
-
|
|
24
|
-
export type HistoryAction = (typeof HISTORY_ACTIONS)[number];
|
|
25
|
-
export type NotesAction = (typeof NOTES_ACTIONS)[number];
|
|
26
|
-
export type ContextManagementToolName = (typeof CONTEXT_MANAGEMENT_TOOL_NAMES)[number];
|
|
27
|
-
|
|
28
|
-
export type ContextToolSelection = {
|
|
29
|
-
/** Pi's explicit tool allowlist. Omitted means use Pi's default selection. */
|
|
30
|
-
tools?: readonly string[];
|
|
31
|
-
/** Pi's default suppression mode. Both values are explicit host policy. */
|
|
32
|
-
noTools?: "all" | "builtin";
|
|
33
|
-
/** Pi applies this denylist after the allowlist. */
|
|
34
|
-
excludeTools?: readonly string[];
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Add Toolkit context tools to a host-provided allowlist without overriding an
|
|
39
|
-
* explicit no-tools or exclude-tools decision. The launcher owns model eligibility;
|
|
40
|
-
* this helper only owns the shared selection contract.
|
|
41
|
-
*/
|
|
42
|
-
export function ensureContextManagementToolsInAllowlist(
|
|
43
|
-
selection: ContextToolSelection,
|
|
44
|
-
eligible: boolean,
|
|
45
|
-
): string[] | undefined {
|
|
46
|
-
if (!eligible || selection.noTools !== undefined || selection.tools === undefined) {
|
|
47
|
-
return selection.tools === undefined ? undefined : [...selection.tools];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const excluded = new Set(selection.excludeTools ?? []);
|
|
51
|
-
const names = [...selection.tools];
|
|
52
|
-
for (const name of CONTEXT_MANAGEMENT_TOOL_NAMES) {
|
|
53
|
-
if (!excluded.has(name) && !names.includes(name)) names.push(name);
|
|
54
|
-
}
|
|
55
|
-
return names;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export const HISTORY_DESCRIPTION =
|
|
59
|
-
"Prior-window detail. Pass IDs unchanged. Search, never browse.";
|
|
60
|
-
|
|
61
|
-
export const NOTES_DESCRIPTION =
|
|
62
|
-
"Cross-window checkpoints on virtual paths. Relative uses current agent; cross-agent uses <agent>/notes[/path].";
|
|
@@ -1,371 +0,0 @@
|
|
|
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 { ContextWindowIdentity, HistoryAction, NotesAction } from "./types";
|
|
12
|
-
|
|
13
|
-
const EMPTY_PARAMETERS = Type.Object({}, { additionalProperties: false });
|
|
14
|
-
|
|
15
|
-
type PublishedTool = {
|
|
16
|
-
name: string;
|
|
17
|
-
description: string;
|
|
18
|
-
parameters: unknown;
|
|
19
|
-
promptGuidelines?: string[];
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Pi's ToolInfo wrapper is new, but its definition fields retain the objects
|
|
24
|
-
* registered by the extension. Keep identity checks for those fields: an exact
|
|
25
|
-
* foreign clone under the same name is still not ownership of our tool.
|
|
26
|
-
*/
|
|
27
|
-
function isOurTool(
|
|
28
|
-
actual: PublishedTool,
|
|
29
|
-
expected: { description: string; parameters: unknown; promptGuidelines?: string[] },
|
|
30
|
-
): boolean {
|
|
31
|
-
return actual.description === expected.description
|
|
32
|
-
&& actual.parameters === expected.parameters
|
|
33
|
-
&& actual.promptGuidelines === expected.promptGuidelines;
|
|
34
|
-
}
|
|
35
|
-
const HISTORY_ACTIONS = Object.keys(HISTORY_ENDPOINTS) as [HistoryAction, ...HistoryAction[]];
|
|
36
|
-
const NOTES_ACTIONS = Object.keys(NOTES_ENDPOINTS) as [NotesAction, ...NotesAction[]];
|
|
37
|
-
|
|
38
|
-
export const HISTORY_PARAMETERS = Type.Object({
|
|
39
|
-
action: StringEnum(HISTORY_ACTIONS),
|
|
40
|
-
agent_name: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
41
|
-
item_id: Type.Optional(Type.String()),
|
|
42
|
-
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
43
|
-
limit_chars: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
44
|
-
max_chars_per_item: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
45
|
-
offset_chars: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
46
|
-
query: Type.Optional(Type.String()),
|
|
47
|
-
recent_first: Type.Optional(Type.Boolean()),
|
|
48
|
-
role: Type.Optional(Type.Union([StringEnum(["user", "assistant", "tool", "system", "developer"] as const), Type.Null()])),
|
|
49
|
-
tool_name: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
50
|
-
tool_namespace: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
51
|
-
window_id: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
52
|
-
}, { additionalProperties: false });
|
|
53
|
-
|
|
54
|
-
export const NOTES_PARAMETERS = Type.Object({
|
|
55
|
-
action: StringEnum(NOTES_ACTIONS),
|
|
56
|
-
file_order: Type.Optional(StringEnum(["ascending", "descending"] as const)),
|
|
57
|
-
file_order_by: Type.Optional(StringEnum(["name", "created_at", "updated_at"] as const)),
|
|
58
|
-
max_files: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
59
|
-
max_matches_per_file: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
60
|
-
max_results: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
61
|
-
path: Type.Optional(Type.String()),
|
|
62
|
-
path_prefix: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
63
|
-
prefix: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
64
|
-
query: Type.Optional(Type.String()),
|
|
65
|
-
recent_file_first: Type.Optional(Type.Boolean()),
|
|
66
|
-
start_line: Type.Optional(Type.Union([Type.Integer(), Type.Null()])),
|
|
67
|
-
stop_line: Type.Optional(Type.Union([Type.Integer(), Type.Null()])),
|
|
68
|
-
text: Type.Optional(Type.String()),
|
|
69
|
-
}, { additionalProperties: false });
|
|
70
|
-
|
|
71
|
-
export interface NewContextDetails { started: boolean; }
|
|
72
|
-
|
|
73
|
-
export const NEW_CONTEXT_PARAMETERS = Type.Object({}, { additionalProperties: false });
|
|
74
|
-
|
|
75
|
-
export const NEW_CONTEXT_CHECKPOINT_REQUIRED_MESSAGE =
|
|
76
|
-
"new_context refused: no persisted successful notes checkpoint in the current context window. "
|
|
77
|
-
+ "Write the active request, decisions, progress and next steps with notes append_to_file or write_file, "
|
|
78
|
-
+ "wait until that notes result is persisted, then retry new_context in a later tool turn. "
|
|
79
|
-
+ "Do not pass force arguments and do not repeat new_context while a rollover is already scheduled.";
|
|
80
|
-
|
|
81
|
-
export const NEW_CONTEXT_COOLDOWN_MESSAGE =
|
|
82
|
-
"new_context refused: this window was entered by a context switch and has not run any substantive "
|
|
83
|
-
+ "tool yet. Read the checkpoint receipt with notes read_file, continue the active task with real "
|
|
84
|
-
+ "tools, and call new_context only once this window has produced tool activity.";
|
|
85
|
-
export interface ContextRemainingDetails {
|
|
86
|
-
remainingTokens?: number;
|
|
87
|
-
windowId?: string;
|
|
88
|
-
contextWindow: number;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export type ContextManagementTools = {
|
|
92
|
-
newContext: ToolDefinition<typeof NEW_CONTEXT_PARAMETERS, NewContextDetails>;
|
|
93
|
-
getContextRemaining: ToolDefinition<typeof EMPTY_PARAMETERS, ContextRemainingDetails>;
|
|
94
|
-
history: ToolDefinition<typeof HISTORY_PARAMETERS, CodexHistoryNotesDetails>;
|
|
95
|
-
notes: ToolDefinition<typeof NOTES_PARAMETERS, CodexHistoryNotesDetails>;
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
export type ContextOperationPolicy = { active: boolean; gatewayModels: readonly string[] };
|
|
99
|
-
type ContextActivity = (ctx: ExtensionContext) => Promise<boolean | ContextOperationPolicy> | boolean | ContextOperationPolicy;
|
|
100
|
-
|
|
101
|
-
export interface ContextRolloverHandoff {
|
|
102
|
-
prepare(ctx: ExtensionContext): { triggerTurn: false; beforeSchedule: (identity: ContextWindowIdentity) => void } | undefined;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function createContextManagementTools(
|
|
106
|
-
pi: ExtensionAPI,
|
|
107
|
-
manager: CodexContextWindowManager,
|
|
108
|
-
isActive: ContextActivity,
|
|
109
|
-
getGatewayModels: () => readonly string[] = () => [],
|
|
110
|
-
handoff?: ContextRolloverHandoff,
|
|
111
|
-
): ContextManagementTools {
|
|
112
|
-
const assertActive = async (ctx: ExtensionContext): Promise<readonly string[]> => {
|
|
113
|
-
const policy = await isActive(ctx);
|
|
114
|
-
if (!(typeof policy === "boolean" ? policy : policy.active)) throw new Error("remote-context-inactive");
|
|
115
|
-
return typeof policy === "boolean" ? getGatewayModels() : policy.gatewayModels;
|
|
116
|
-
};
|
|
117
|
-
const newContext: ToolDefinition<typeof NEW_CONTEXT_PARAMETERS, NewContextDetails> = {
|
|
118
|
-
name: "new_context",
|
|
119
|
-
label: "new_context",
|
|
120
|
-
description: "Start a new remote Codex context window without generating a conversation summary. Requires a persisted successful notes checkpoint in the current window.",
|
|
121
|
-
parameters: NEW_CONTEXT_PARAMETERS,
|
|
122
|
-
exposure: "direct",
|
|
123
|
-
promptSnippet: "Start a new remote Codex context window without summarizing history.",
|
|
124
|
-
promptGuidelines: [
|
|
125
|
-
"Before this new_context call, checkpoint active work in notes; only a persisted successful notes append/write in the current window unlocks this rollover, and no conversation summary carries over.",
|
|
126
|
-
"A successful new_context completes one context switch. In the next window, read the checkpoint receipt first when present, then resume the active user task; do not immediately create another checkpoint or call new_context as part of that handoff.",
|
|
127
|
-
"Wait for the notes tool result before calling new_context; an in-flight or failed write is not a checkpoint. If new_context reports that a rollover is already scheduled, do not call it again in the same window.",
|
|
128
|
-
],
|
|
129
|
-
executionMode: "sequential",
|
|
130
|
-
async execute(_id, _params, signal, _update, ctx) {
|
|
131
|
-
const gatewayModels = await assertActive(ctx);
|
|
132
|
-
manager.synchronize(ctx);
|
|
133
|
-
if (manager.hasPendingRollover(ctx)) {
|
|
134
|
-
return {
|
|
135
|
-
content: [{ type: "text", text: "A new context window is already scheduled." }],
|
|
136
|
-
details: { started: false },
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
if (!manager.canRolloverFromCurrentWindow(ctx)) {
|
|
140
|
-
return {
|
|
141
|
-
content: [{ type: "text", text: NEW_CONTEXT_COOLDOWN_MESSAGE }],
|
|
142
|
-
details: { started: false },
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
if (!manager.hasNotesCheckpointSinceBoundary(ctx)) {
|
|
146
|
-
throw new Error(NEW_CONTEXT_CHECKPOINT_REQUIRED_MESSAGE);
|
|
147
|
-
}
|
|
148
|
-
const scheduling = handoff?.prepare(ctx);
|
|
149
|
-
const started = await manager.startNewWindow(pi, ctx, {
|
|
150
|
-
triggerTurn: true,
|
|
151
|
-
signal,
|
|
152
|
-
trimPreviousWindow: true,
|
|
153
|
-
gatewayModels,
|
|
154
|
-
...scheduling,
|
|
155
|
-
});
|
|
156
|
-
return {
|
|
157
|
-
content: [{
|
|
158
|
-
type: "text",
|
|
159
|
-
text: started
|
|
160
|
-
? "Context switch scheduled successfully. In the next context window, read the checkpoint receipt first when present, then resume the active user task; do not immediately create another checkpoint or call new_context."
|
|
161
|
-
: "A new context window is already scheduled.",
|
|
162
|
-
}],
|
|
163
|
-
details: { started },
|
|
164
|
-
...(started && scheduling ? { terminate: true } : {}),
|
|
165
|
-
};
|
|
166
|
-
},
|
|
167
|
-
};
|
|
168
|
-
const getContextRemaining: ToolDefinition<typeof EMPTY_PARAMETERS, ContextRemainingDetails> = {
|
|
169
|
-
name: "get_context_remaining",
|
|
170
|
-
label: "get_context_remaining",
|
|
171
|
-
description: "Get the remaining tokens in the current remote Codex context window.",
|
|
172
|
-
parameters: EMPTY_PARAMETERS,
|
|
173
|
-
exposure: "direct",
|
|
174
|
-
async execute(_id, _params, _signal, _update, ctx) {
|
|
175
|
-
await assertActive(ctx);
|
|
176
|
-
const remaining = manager.remaining(ctx);
|
|
177
|
-
return {
|
|
178
|
-
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.` }],
|
|
179
|
-
details: remaining,
|
|
180
|
-
};
|
|
181
|
-
},
|
|
182
|
-
};
|
|
183
|
-
const history: ToolDefinition<typeof HISTORY_PARAMETERS, CodexHistoryNotesDetails> = {
|
|
184
|
-
name: "history",
|
|
185
|
-
label: "history",
|
|
186
|
-
description: "Search or read prior remote Codex context-window history. Pass IDs unchanged.",
|
|
187
|
-
parameters: HISTORY_PARAMETERS,
|
|
188
|
-
exposure: "direct",
|
|
189
|
-
promptSnippet: "Search or read prior remote Codex context-window history.",
|
|
190
|
-
promptGuidelines: [
|
|
191
|
-
"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.",
|
|
192
|
-
"Use read_item with the exact item_id returned by list_items or search_contents; never rewrite, truncate, or guess IDs.",
|
|
193
|
-
"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.",
|
|
194
|
-
"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.",
|
|
195
|
-
"History is read-only; make no edits through it. Edits and durable checkpoints belong to notes.",
|
|
196
|
-
],
|
|
197
|
-
execute: async (_id, params, _signal, _update, ctx) => {
|
|
198
|
-
const gatewayModels = await assertActive(ctx);
|
|
199
|
-
return executeHistoryNotesTool("history", params.action, params as Record<string, unknown>, ctx, _signal, gatewayModels);
|
|
200
|
-
},
|
|
201
|
-
};
|
|
202
|
-
const notes: ToolDefinition<typeof NOTES_PARAMETERS, CodexHistoryNotesDetails> = {
|
|
203
|
-
name: "notes",
|
|
204
|
-
label: "notes",
|
|
205
|
-
description: "Read and checkpoint remote Codex notes across context windows.",
|
|
206
|
-
parameters: NOTES_PARAMETERS,
|
|
207
|
-
exposure: "direct",
|
|
208
|
-
promptSnippet: "Read and checkpoint remote Codex notes across context windows.",
|
|
209
|
-
promptGuidelines: [
|
|
210
|
-
"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; wait for that result to be persisted before calling new_context.",
|
|
211
|
-
"After a successful new_context handoff, read the checkpoint receipt first when present and resume the active user task; do not immediately create another checkpoint or call new_context unless a later rollover is actually needed.",
|
|
212
|
-
"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.",
|
|
213
|
-
"Prefer read_file or search_contents for looking things up; reserve write_file for explicit rewrite/clear and append_to_file for incremental state.",
|
|
214
|
-
"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.",
|
|
215
|
-
"Do not store live credentials, API keys, or full request bodies in notes; record the fact and where it lives instead.",
|
|
216
|
-
],
|
|
217
|
-
executionMode: "sequential",
|
|
218
|
-
execute: async (_id, params, _signal, _update, ctx) => {
|
|
219
|
-
const gatewayModels = await assertActive(ctx);
|
|
220
|
-
return executeHistoryNotesTool("notes", params.action, params as Record<string, unknown>, ctx, _signal, gatewayModels);
|
|
221
|
-
},
|
|
222
|
-
};
|
|
223
|
-
return { newContext, getContextRemaining, history, notes };
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export type ContextToolRegistrationState = "verified" | "excluded" | "conflict" | "unverified";
|
|
227
|
-
|
|
228
|
-
export type ContextToolSyncResult = {
|
|
229
|
-
synced: boolean;
|
|
230
|
-
registrationState: ContextToolRegistrationState;
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
export class ContextManagementToolController {
|
|
234
|
-
private readonly registeredNames = new Set<string>();
|
|
235
|
-
private readonly definitions = new Map<string, {
|
|
236
|
-
description: string;
|
|
237
|
-
parameters: unknown;
|
|
238
|
-
promptGuidelines?: string[];
|
|
239
|
-
}>();
|
|
240
|
-
private registered = false;
|
|
241
|
-
private verifiedOnce = false;
|
|
242
|
-
|
|
243
|
-
constructor(private readonly pi: ExtensionAPI) {}
|
|
244
|
-
|
|
245
|
-
register(tools: ContextManagementTools): boolean {
|
|
246
|
-
const definitions = [tools.newContext, tools.getContextRemaining, tools.history, tools.notes];
|
|
247
|
-
const api = this.pi as ExtensionAPI & { registerTool?: (tool: unknown) => void };
|
|
248
|
-
if (typeof api.registerTool !== "function") return false;
|
|
249
|
-
|
|
250
|
-
// registerTool() is a registration method and is valid while Pi is loading
|
|
251
|
-
// the extension. Do not call action methods such as getAllTools() here;
|
|
252
|
-
// those are bound only after extension loading completes.
|
|
253
|
-
try {
|
|
254
|
-
for (const definition of definitions) api.registerTool(definition);
|
|
255
|
-
this.registered = true;
|
|
256
|
-
} catch {
|
|
257
|
-
// Pi rejects a registration whose name is already owned elsewhere.
|
|
258
|
-
this.registered = false;
|
|
259
|
-
return false;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
this.registeredNames.clear();
|
|
263
|
-
this.definitions.clear();
|
|
264
|
-
this.verifiedOnce = false;
|
|
265
|
-
for (const definition of definitions) {
|
|
266
|
-
this.registeredNames.add(definition.name);
|
|
267
|
-
this.definitions.set(definition.name, {
|
|
268
|
-
description: definition.description,
|
|
269
|
-
parameters: definition.parameters,
|
|
270
|
-
promptGuidelines: definition.promptGuidelines,
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
// A successful call only proves the hand-off; it does not prove the runtime
|
|
274
|
-
// published the definitions. `checkRegistration()` reads that from Pi.
|
|
275
|
-
return true;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Read registration state until the runtime confirms publication.
|
|
280
|
-
*
|
|
281
|
-
* Pi 0.86 can reject action methods while a session replacement is still
|
|
282
|
-
* binding, and can publish the registry after `session_start` ran. Negative
|
|
283
|
-
* reads remain retryable; only a successful verification is cached for this
|
|
284
|
-
* controller instance. A Pi reload creates a new controller.
|
|
285
|
-
*/
|
|
286
|
-
checkRegistration(): ContextToolRegistrationState {
|
|
287
|
-
if (!this.registered) return "conflict";
|
|
288
|
-
// A confirmed publication is kept: the expensive part was never the read, it
|
|
289
|
-
// was trusting a failed one. Only negative verdicts are recomputed each time.
|
|
290
|
-
if (this.verifiedOnce) return "verified";
|
|
291
|
-
let available: PublishedTool[];
|
|
292
|
-
try {
|
|
293
|
-
const api = this.pi as ExtensionAPI & { getAllTools?: () => PublishedTool[] };
|
|
294
|
-
if (typeof api.getAllTools !== "function") return "conflict";
|
|
295
|
-
available = api.getAllTools();
|
|
296
|
-
} catch {
|
|
297
|
-
// Stale or unbound runtime: retry on the next sync.
|
|
298
|
-
return "unverified";
|
|
299
|
-
}
|
|
300
|
-
const byName = new Map(available.map((tool) => [tool.name, tool]));
|
|
301
|
-
let missing = false;
|
|
302
|
-
let conflict = false;
|
|
303
|
-
let published = 0;
|
|
304
|
-
for (const name of this.registeredNames) {
|
|
305
|
-
const actual = byName.get(name);
|
|
306
|
-
const expected = this.definitions.get(name);
|
|
307
|
-
if (actual === undefined || expected === undefined) {
|
|
308
|
-
missing = true;
|
|
309
|
-
continue;
|
|
310
|
-
}
|
|
311
|
-
published += 1;
|
|
312
|
-
if (!isOurTool(actual, expected)) conflict = true;
|
|
313
|
-
}
|
|
314
|
-
if (conflict) return "conflict";
|
|
315
|
-
// A readable registry with no Toolkit-owned definitions is the positive
|
|
316
|
-
// host-filtering signal used by --no-tools/restricted child sessions.
|
|
317
|
-
if (published === 0) return "excluded";
|
|
318
|
-
if (missing) return "unverified";
|
|
319
|
-
this.verifiedOnce = true;
|
|
320
|
-
return "verified";
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
sync(active: boolean): ContextToolSyncResult {
|
|
324
|
-
const registrationState = this.checkRegistration();
|
|
325
|
-
if (registrationState !== "verified") return { synced: false, registrationState };
|
|
326
|
-
const api = this.pi as ExtensionAPI & { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
|
|
327
|
-
if (typeof api.getActiveTools !== "function" || typeof api.setActiveTools !== "function") {
|
|
328
|
-
return { synced: false, registrationState };
|
|
329
|
-
}
|
|
330
|
-
try {
|
|
331
|
-
const current = api.getActiveTools();
|
|
332
|
-
// Pi may activate a newly registered tool before the first sync. Once
|
|
333
|
-
// registration is verified, every registered name belongs to this
|
|
334
|
-
// controller: active models get all four, inactive models get none.
|
|
335
|
-
const next = active
|
|
336
|
-
? [...current, ...[...this.registeredNames].filter((name) => !current.includes(name))]
|
|
337
|
-
: current.filter((name) => !this.registeredNames.has(name));
|
|
338
|
-
if (next.length !== current.length) api.setActiveTools(next);
|
|
339
|
-
return { synced: true, registrationState };
|
|
340
|
-
} catch {
|
|
341
|
-
return { synced: false, registrationState };
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
reset(): void {
|
|
346
|
-
this.sync(false);
|
|
347
|
-
}
|
|
348
|
-
get isRegistered(): boolean { return this.checkRegistration() === "verified"; }
|
|
349
|
-
get registrationState(): ContextToolRegistrationState { return this.checkRegistration(); }
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
export function registerContextManagementTools(
|
|
353
|
-
pi: ExtensionAPI,
|
|
354
|
-
manager: CodexContextWindowManager,
|
|
355
|
-
isActive: ContextActivity,
|
|
356
|
-
getGatewayModels?: () => readonly string[],
|
|
357
|
-
handoff?: ContextRolloverHandoff,
|
|
358
|
-
): ContextManagementToolController {
|
|
359
|
-
const controller = new ContextManagementToolController(pi);
|
|
360
|
-
controller.register(createContextManagementTools(pi, manager, isActive, getGatewayModels, handoff));
|
|
361
|
-
return controller;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
export const _contextToolsTest = {
|
|
365
|
-
EMPTY_PARAMETERS,
|
|
366
|
-
NEW_CONTEXT_PARAMETERS,
|
|
367
|
-
HISTORY_PARAMETERS,
|
|
368
|
-
NOTES_PARAMETERS,
|
|
369
|
-
HISTORY_ACTION_FIELDS,
|
|
370
|
-
NOTES_ACTION_FIELDS,
|
|
371
|
-
};
|