@narumitw/pi-codex-compact 0.47.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Narumi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # @narumitw/pi-codex-compact
2
+
3
+ > [!WARNING]
4
+ > This extension is experimental. It depends on an undocumented OpenAI Codex Responses wire
5
+ > contract and stores provider-specific opaque checkpoints. Keep backups of important sessions.
6
+
7
+ Codex Remote Compaction V2 for Pi's built-in `openai-codex` OAuth provider. It replaces Pi's
8
+ plaintext summary-generation call with a server-generated opaque compaction item, then replays
9
+ that item in later OpenAI Codex Responses requests.
10
+
11
+ Pi still owns compaction thresholds, `/compact`, overflow retries, retained-message selection, and
12
+ the append-only session tree. This package does not reproduce Codex core's context-window lineage
13
+ or exact pre-turn and mid-turn lifecycle.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pi install npm:@narumitw/pi-codex-compact
19
+ ```
20
+
21
+ From this repository:
22
+
23
+ ```bash
24
+ just try codex-compact
25
+ ```
26
+
27
+ Loading the package enables it. Use Pi normally, including its built-in `/compact` command. Open
28
+ the extension's TUI control menu with:
29
+
30
+ ```text
31
+ /codex-compact
32
+ ```
33
+
34
+ The menu shows the active model and whether compaction will use Codex Remote V2 or Pi's native
35
+ fallback. Choose **Compact now** to close the menu and compact immediately, or **Settings** to edit
36
+ user settings in `~/.pi/agent/pi-codex-compact.json` (or the active Pi agent directory). Invalid
37
+ files are never overwritten; manual compaction remains available, while Settings opens read-only
38
+ repair guidance until the file is fixed and `/reload` is run.
39
+
40
+ | Setting | Default | Accepted values |
41
+ | --- | ---: | --- |
42
+ | `enabled` | `true` | Boolean |
43
+ | `requestTimeoutMs` | `300000` | Integer from 30,000 to 600,000 ms |
44
+ | `maxRetries` | `2` | Integer from 0 to 2 |
45
+ | `replacementTokenBudget` | `64000` | Integer from 8,000 to 128,000 tokens |
46
+ | `notifyOnFallback` | `true` | Boolean |
47
+
48
+ Missing fields use built-in defaults; the file has no environment-variable or project-level
49
+ override. Settings reload on every `session_start`, including `/reload`, resume, and fork. Menu
50
+ writes apply immediately, preserve unknown JSON fields, serialize within the current Pi process, and
51
+ use a conflict check plus atomic rename. Separate Pi processes are not coordinated by a shared lock;
52
+ a detected concurrent edit is rejected for the user to reopen and retry.
53
+
54
+ ## Requirements
55
+
56
+ - Pi `0.83.0`-compatible extension APIs.
57
+ - A model using provider `openai-codex` and API `openai-codex-responses`.
58
+ - A working OpenAI Codex OAuth login in Pi.
59
+
60
+ OpenAI API-key, Azure, Copilot, proxies, and generic Responses-compatible providers are not
61
+ supported. Unsupported models continue to use Pi's native compaction.
62
+
63
+ ## How it works
64
+
65
+ 1. Pi decides when compaction is needed.
66
+ 2. The extension sends the current Codex Responses input with a final `compaction_trigger`.
67
+ 3. It validates and persists the server's encrypted `compaction` item in the Pi
68
+ `CompactionEntry.details` field.
69
+ 4. Later Codex requests replace Pi's fallback summary and kept suffix with the opaque replacement
70
+ history before dispatch.
71
+
72
+ The SSE response is limited to 8 MiB, the opaque item to 2 MiB, and the persisted replacement
73
+ history to the configured text budget (64,000 tokens by default) and 8 MiB. Oversized media
74
+ retention is dropped rather than creating an unbounded session entry.
75
+
76
+ ## Failure and portability
77
+
78
+ Remote auth, transport, protocol, or validation failures fall back to Pi's native plaintext
79
+ compaction. User cancellation does not start that fallback.
80
+
81
+ After a successful remote compaction, full older history can be replayed only when this extension
82
+ is loaded with the same `openai-codex` model. If the extension is removed or the model/provider is
83
+ changed, Pi exposes a warning summary plus its retained recent messages. Switching back restores
84
+ opaque replay. Repeated compaction after an opaque checkpoint also requires the checkpoint to be
85
+ projected safely; otherwise Pi falls back rather than guessing.
86
+
87
+ ## Privacy and storage
88
+
89
+ Remote compaction sends the active conversation context, system prompt, and active tool schemas to
90
+ the same OpenAI Codex backend used by the selected model. The extension stores the encrypted
91
+ compaction item and bounded recent user-role Responses items in the Pi session. It never persists
92
+ the OAuth token or request headers.
93
+
94
+ ## Development
95
+
96
+ ```bash
97
+ npm --workspace @narumitw/pi-codex-compact run check
98
+ npm test
99
+ just pack codex-compact
100
+ ```
101
+
102
+ See
103
+ [`docs/implementation-notes/codex-compaction-mechanism.md`](../../docs/implementation-notes/codex-compaction-mechanism.md)
104
+ for the underlying Codex mechanism research and the extension boundary.
105
+
106
+ ## License
107
+
108
+ MIT
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@narumitw/pi-codex-compact",
3
+ "version": "0.47.0",
4
+ "description": "Experimental Codex Remote Compaction V2 for Pi's OpenAI Codex provider.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "codex",
13
+ "compaction",
14
+ "openai"
15
+ ],
16
+ "files": [
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "pi": {
22
+ "extensions": [
23
+ "./src/index.ts"
24
+ ]
25
+ },
26
+ "scripts": {
27
+ "check": "biome check --vcs-use-ignore-file=false src test package.json tsconfig.json README.md && npm run typecheck",
28
+ "format": "biome check --write --vcs-use-ignore-file=false src test package.json tsconfig.json README.md",
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "dependencies": {
32
+ "@narumitw/pi-tui-kit": "^0.45.0"
33
+ },
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-agent-core": "*",
36
+ "@earendil-works/pi-ai": "*",
37
+ "@earendil-works/pi-coding-agent": "*"
38
+ },
39
+ "devDependencies": {
40
+ "@biomejs/biome": "2.5.6",
41
+ "@earendil-works/pi-agent-core": "0.83.0",
42
+ "@earendil-works/pi-ai": "0.83.0",
43
+ "@earendil-works/pi-coding-agent": "0.83.0",
44
+ "@types/node": "26.1.2",
45
+ "typescript": "7.0.2"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "https://github.com/narumiruna/pi-extensions",
50
+ "directory": "experimental/pi-codex-compact"
51
+ }
52
+ }
@@ -0,0 +1,257 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import type { CompactionEntry, SessionEntry } from "@earendil-works/pi-coding-agent";
4
+ import { type JsonObject, validateCompactionItem } from "./protocol.js";
5
+
6
+ export const CHECKPOINT_KIND = "pi-codex-remote-compaction";
7
+ export const CHECKPOINT_VERSION = 1;
8
+ export const REPLACEMENT_TOKEN_BUDGET = 64_000;
9
+ export const REPLACEMENT_BYTE_BUDGET = 8 * 1024 * 1024;
10
+ const MAX_MEDIA_ITEM_BYTES = 2 * 1024 * 1024;
11
+
12
+ export interface CodexCheckpointDetails {
13
+ kind: typeof CHECKPOINT_KIND;
14
+ version: typeof CHECKPOINT_VERSION;
15
+ checkpointId: string;
16
+ provider: "openai-codex";
17
+ api: "openai-codex-responses";
18
+ modelId: string;
19
+ protocol: "remote-compaction-v2";
20
+ replacementHistory: JsonObject[];
21
+ keptMessageFingerprints: string[];
22
+ createdAt: string;
23
+ }
24
+
25
+ function isObject(value: unknown): value is JsonObject {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+
29
+ function stableValue(value: unknown): unknown {
30
+ if (Array.isArray(value)) return value.map(stableValue);
31
+ if (!isObject(value)) return value;
32
+ return Object.fromEntries(
33
+ Object.entries(value)
34
+ .sort(([left], [right]) => left.localeCompare(right))
35
+ .map(([key, child]) => [key, stableValue(child)]),
36
+ );
37
+ }
38
+
39
+ function serializedBytes(value: unknown): number {
40
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
41
+ }
42
+
43
+ export function fingerprintMessage(message: AgentMessage): string {
44
+ return createHash("sha256")
45
+ .update(JSON.stringify(stableValue(message)))
46
+ .digest("hex");
47
+ }
48
+
49
+ export function checkpointMarker(checkpointId: string): string {
50
+ return [
51
+ `[PI_CODEX_REMOTE_CHECKPOINT:${checkpointId}]`,
52
+ "Opaque checkpoint injection failed. Do not infer missing history; tell the user to re-enable",
53
+ "@narumitw/pi-codex-compact with an openai-codex model.",
54
+ ].join(" ");
55
+ }
56
+
57
+ export function fallbackSummary(checkpointId: string): string {
58
+ return [
59
+ `OpenAI Codex Remote Compaction V2 checkpoint ${checkpointId} stores the older history opaquely.`,
60
+ "Full replay requires @narumitw/pi-codex-compact and an openai-codex model.",
61
+ "Without them, only Pi's retained recent messages remain available.",
62
+ ].join(" ");
63
+ }
64
+
65
+ function markerMessage(checkpointId: string, timestamp: number): AgentMessage {
66
+ return {
67
+ role: "user",
68
+ content: [{ type: "text", text: checkpointMarker(checkpointId) }],
69
+ timestamp,
70
+ };
71
+ }
72
+
73
+ export function parseCheckpointDetails(value: unknown): CodexCheckpointDetails | undefined {
74
+ if (!isObject(value)) return undefined;
75
+ if (
76
+ value.kind !== CHECKPOINT_KIND ||
77
+ value.version !== CHECKPOINT_VERSION ||
78
+ typeof value.checkpointId !== "string" ||
79
+ value.checkpointId.length < 8 ||
80
+ value.provider !== "openai-codex" ||
81
+ value.api !== "openai-codex-responses" ||
82
+ typeof value.modelId !== "string" ||
83
+ value.protocol !== "remote-compaction-v2" ||
84
+ !Array.isArray(value.replacementHistory) ||
85
+ !Array.isArray(value.keptMessageFingerprints) ||
86
+ typeof value.createdAt !== "string"
87
+ ) {
88
+ return undefined;
89
+ }
90
+ if (
91
+ value.replacementHistory.length === 0 ||
92
+ !value.replacementHistory.every(isObject) ||
93
+ !value.keptMessageFingerprints.every(
94
+ (fingerprint) => typeof fingerprint === "string" && /^[a-f0-9]{64}$/.test(fingerprint),
95
+ ) ||
96
+ serializedBytes(value.replacementHistory) > REPLACEMENT_BYTE_BUDGET
97
+ ) {
98
+ return undefined;
99
+ }
100
+ const last = value.replacementHistory.at(-1);
101
+ try {
102
+ validateCompactionItem(last);
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ return structuredClone(value) as unknown as CodexCheckpointDetails;
107
+ }
108
+
109
+ export function latestCheckpoint(entries: readonly SessionEntry[]):
110
+ | {
111
+ entry: CompactionEntry<CodexCheckpointDetails>;
112
+ details: CodexCheckpointDetails;
113
+ }
114
+ | undefined {
115
+ for (let index = entries.length - 1; index >= 0; index--) {
116
+ const entry = entries[index];
117
+ if (entry.type !== "compaction") continue;
118
+ const details = parseCheckpointDetails(entry.details);
119
+ return details
120
+ ? { entry: entry as CompactionEntry<CodexCheckpointDetails>, details }
121
+ : undefined;
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ export function projectCheckpointContext(
127
+ messages: readonly AgentMessage[],
128
+ details: CodexCheckpointDetails,
129
+ ): AgentMessage[] | undefined {
130
+ const summary = fallbackSummary(details.checkpointId);
131
+ const summaryIndex = messages.findIndex(
132
+ (message) => message.role === "compactionSummary" && message.summary === summary,
133
+ );
134
+ if (summaryIndex < 0) return undefined;
135
+ const keptStart = summaryIndex + 1;
136
+ const keptEnd = keptStart + details.keptMessageFingerprints.length;
137
+ if (keptEnd > messages.length) return undefined;
138
+ for (let index = keptStart; index < keptEnd; index++) {
139
+ if (
140
+ fingerprintMessage(messages[index]) !== details.keptMessageFingerprints[index - keptStart]
141
+ ) {
142
+ return undefined;
143
+ }
144
+ }
145
+ const timestamp = messages[summaryIndex].timestamp;
146
+ return [
147
+ ...messages.slice(0, summaryIndex),
148
+ markerMessage(details.checkpointId, timestamp),
149
+ ...messages.slice(keptEnd),
150
+ ];
151
+ }
152
+
153
+ function rawText(item: JsonObject): string {
154
+ if (!Array.isArray(item.content)) return "";
155
+ return item.content
156
+ .flatMap((part) =>
157
+ isObject(part) && typeof part.text === "string" && part.type === "input_text"
158
+ ? [part.text]
159
+ : [],
160
+ )
161
+ .join("\n");
162
+ }
163
+
164
+ function hasMedia(item: JsonObject): boolean {
165
+ return (
166
+ Array.isArray(item.content) &&
167
+ item.content.some((part) => isObject(part) && part.type === "input_image")
168
+ );
169
+ }
170
+
171
+ function truncateTextItem(item: JsonObject, maxChars: number): JsonObject | undefined {
172
+ if (!Array.isArray(item.content) || maxChars <= 32) return undefined;
173
+ let remaining = maxChars - 16;
174
+ const content = [...item.content].reverse().flatMap((part) => {
175
+ if (
176
+ !isObject(part) ||
177
+ part.type !== "input_text" ||
178
+ typeof part.text !== "string" ||
179
+ remaining <= 0
180
+ ) {
181
+ return [];
182
+ }
183
+ const text = part.text.slice(-remaining);
184
+ remaining -= text.length;
185
+ return [{ ...part, text: `[truncated]\n${text}` }];
186
+ });
187
+ if (content.length === 0) return undefined;
188
+ return { ...item, content: content.reverse() };
189
+ }
190
+
191
+ export function buildReplacementHistory(
192
+ input: readonly unknown[],
193
+ compactionItem: JsonObject,
194
+ options: { tokenBudget?: number; byteBudget?: number } = {},
195
+ ): JsonObject[] {
196
+ const tokenBudget = options.tokenBudget ?? REPLACEMENT_TOKEN_BUDGET;
197
+ const byteBudget = options.byteBudget ?? REPLACEMENT_BYTE_BUDGET;
198
+ const opaque = validateCompactionItem(compactionItem);
199
+ let remainingBytes = byteBudget - serializedBytes(opaque);
200
+ let remainingChars = tokenBudget * 4;
201
+ if (remainingBytes <= 0)
202
+ throw new Error("Opaque compaction item exceeds replacement history budget");
203
+ const retainedNewestFirst: JsonObject[] = [];
204
+ const candidates = input.filter(
205
+ (item): item is JsonObject =>
206
+ isObject(item) && item.role === "user" && item.type !== "compaction_trigger",
207
+ );
208
+ for (let index = candidates.length - 1; index >= 0; index--) {
209
+ const candidate = candidates[index];
210
+ const bytes = serializedBytes(candidate);
211
+ if (hasMedia(candidate) && bytes > MAX_MEDIA_ITEM_BYTES) continue;
212
+ const text = rawText(candidate);
213
+ let retained = candidate;
214
+ if (text.length > remainingChars) {
215
+ if (hasMedia(candidate)) continue;
216
+ const truncated = truncateTextItem(candidate, remainingChars);
217
+ if (!truncated) continue;
218
+ retained = truncated;
219
+ }
220
+ if (serializedBytes(retained) > remainingBytes) {
221
+ if (hasMedia(retained)) continue;
222
+ const maxCharsByBytes = Math.max(0, remainingBytes - 128);
223
+ const truncated = truncateTextItem(retained, Math.min(remainingChars, maxCharsByBytes));
224
+ if (!truncated || serializedBytes(truncated) > remainingBytes) continue;
225
+ retained = truncated;
226
+ }
227
+ retainedNewestFirst.push(structuredClone(retained));
228
+ remainingBytes -= serializedBytes(retained);
229
+ remainingChars -= Math.min(remainingChars, rawText(retained).length);
230
+ if (remainingBytes <= 128 || remainingChars <= 32) break;
231
+ }
232
+ return [...retainedNewestFirst.reverse(), opaque];
233
+ }
234
+
235
+ export function createCheckpointDetails(input: {
236
+ modelId: string;
237
+ replacementHistory: JsonObject[];
238
+ keptMessages: readonly AgentMessage[];
239
+ checkpointId?: string;
240
+ createdAt?: string;
241
+ }): CodexCheckpointDetails {
242
+ const details: CodexCheckpointDetails = {
243
+ kind: CHECKPOINT_KIND,
244
+ version: CHECKPOINT_VERSION,
245
+ checkpointId: input.checkpointId ?? randomUUID(),
246
+ provider: "openai-codex",
247
+ api: "openai-codex-responses",
248
+ modelId: input.modelId,
249
+ protocol: "remote-compaction-v2",
250
+ replacementHistory: structuredClone(input.replacementHistory),
251
+ keptMessageFingerprints: input.keptMessages.map(fingerprintMessage),
252
+ createdAt: input.createdAt ?? new Date().toISOString(),
253
+ };
254
+ const parsed = parseCheckpointDetails(details);
255
+ if (!parsed) throw new Error("Created an invalid Codex checkpoint");
256
+ return parsed;
257
+ }