@koreanhole/opencodex 2.96.0 → 2.98.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.
@@ -0,0 +1,202 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { getConfigDir } from "../../../config";
5
+ import type { OcxMessage } from "../../../types";
6
+
7
+ export interface GeminiSessionRecord {
8
+ keyHash: string;
9
+ vendorSessionId: string;
10
+ fingerprint: string;
11
+ modelId: string;
12
+ workspace: string;
13
+ historyCount: number;
14
+ historyHash: string;
15
+ lastUsed: number;
16
+ turnCount: number;
17
+ }
18
+
19
+ interface SessionDocument {
20
+ version: 1;
21
+ sessions: Record<string, GeminiSessionRecord>;
22
+ responses: Record<string, string>;
23
+ }
24
+
25
+ const sessions = new Map<string, GeminiSessionRecord>();
26
+ const responses = new Map<string, string>();
27
+ let loaded = false;
28
+
29
+ function sessionPath(env: NodeJS.ProcessEnv): string {
30
+ return join(getConfigDir(env), "gemini", "sessions.json");
31
+ }
32
+
33
+ function positiveInt(value: unknown, fallback: number): number {
34
+ const parsed = typeof value === "string" ? Number(value) : value;
35
+ return typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
36
+ }
37
+
38
+ function ttlMs(env: NodeJS.ProcessEnv): number {
39
+ return positiveInt(env.OPENCODEX_GEMINI_SESSION_TTL_MS, 60 * 60 * 1000);
40
+ }
41
+
42
+ function maxSessions(env: NodeJS.ProcessEnv): number {
43
+ return positiveInt(env.OPENCODEX_GEMINI_SESSION_MAX, 32);
44
+ }
45
+
46
+ function parseRecord(value: unknown): GeminiSessionRecord | undefined {
47
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
48
+ const row = value as Record<string, unknown>;
49
+ const keyHash = typeof row.keyHash === "string" ? row.keyHash : undefined;
50
+ const vendorSessionId = typeof row.vendorSessionId === "string" ? row.vendorSessionId : undefined;
51
+ const fingerprint = typeof row.fingerprint === "string" ? row.fingerprint : undefined;
52
+ const modelId = typeof row.modelId === "string" ? row.modelId : undefined;
53
+ const workspace = typeof row.workspace === "string" ? row.workspace : undefined;
54
+ const historyHash = typeof row.historyHash === "string" ? row.historyHash : undefined;
55
+ const historyCount = typeof row.historyCount === "number" && Number.isInteger(row.historyCount) && row.historyCount >= 0 ? row.historyCount : undefined;
56
+ const lastUsed = typeof row.lastUsed === "number" && Number.isFinite(row.lastUsed) && row.lastUsed >= 0 ? row.lastUsed : undefined;
57
+ const turnCount = typeof row.turnCount === "number" && Number.isInteger(row.turnCount) && row.turnCount >= 0 ? row.turnCount : undefined;
58
+ if (!keyHash || !vendorSessionId || !fingerprint || !modelId || !workspace || !historyHash || historyCount === undefined || lastUsed === undefined || turnCount === undefined) return undefined;
59
+ return { keyHash, vendorSessionId, fingerprint, modelId, workspace, historyCount, historyHash, lastUsed, turnCount };
60
+ }
61
+
62
+ async function load(env: NodeJS.ProcessEnv): Promise<void> {
63
+ if (loaded) return;
64
+ loaded = true;
65
+ let parsed: unknown;
66
+ try {
67
+ parsed = JSON.parse(await readFile(sessionPath(env), "utf8"));
68
+ } catch {
69
+ return;
70
+ }
71
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
72
+ const document = parsed as Partial<SessionDocument>;
73
+ if (document.version !== 1) return;
74
+ if (document.sessions && typeof document.sessions === "object") {
75
+ for (const [key, value] of Object.entries(document.sessions)) {
76
+ const record = parseRecord(value);
77
+ if (record) sessions.set(key, record);
78
+ }
79
+ }
80
+ if (document.responses && typeof document.responses === "object") {
81
+ for (const [responseId, keyHash] of Object.entries(document.responses)) {
82
+ if (typeof keyHash === "string") responses.set(responseId, keyHash);
83
+ }
84
+ }
85
+ }
86
+
87
+ async function persist(env: NodeJS.ProcessEnv): Promise<void> {
88
+ const path = sessionPath(env);
89
+ await mkdir(join(getConfigDir(env), "gemini"), { recursive: true, mode: 0o700 });
90
+ const document: SessionDocument = {
91
+ version: 1,
92
+ sessions: Object.fromEntries(sessions),
93
+ responses: Object.fromEntries(responses),
94
+ };
95
+ const temporary = `${path}.${crypto.randomUUID()}.tmp`;
96
+ await writeFile(temporary, JSON.stringify(document, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
97
+ await rename(temporary, path);
98
+ }
99
+
100
+ function safeWorkspace(workspace: string, env: NodeJS.ProcessEnv): boolean {
101
+ const root = join(getConfigDir(env), "gemini", "sessions");
102
+ return workspace.startsWith(`${root}/`);
103
+ }
104
+
105
+ async function removeWorkspace(record: GeminiSessionRecord, env: NodeJS.ProcessEnv): Promise<void> {
106
+ if (!safeWorkspace(record.workspace, env)) return;
107
+ await rm(record.workspace, { recursive: true, force: true }).catch(() => undefined);
108
+ }
109
+
110
+ export function hashConversationKey(value: string): string {
111
+ return createHash("sha256").update(value).digest("hex");
112
+ }
113
+
114
+ export function hashMessages(messages: readonly OcxMessage[]): string {
115
+ return createHash("sha256")
116
+ .update(JSON.stringify(messages, (key, value) => key === "timestamp" ? undefined : value))
117
+ .digest("hex");
118
+ }
119
+
120
+ export function geminiSessionWorkspace(keyHash: string, env: NodeJS.ProcessEnv = process.env): string {
121
+ return join(getConfigDir(env), "gemini", "sessions", keyHash);
122
+ }
123
+
124
+ export async function findGeminiSessionByResponseId(
125
+ responseId: string,
126
+ env: NodeJS.ProcessEnv = process.env,
127
+ ): Promise<GeminiSessionRecord | undefined> {
128
+ await load(env);
129
+ const keyHash = responses.get(responseId);
130
+ return keyHash ? sessions.get(keyHash) : undefined;
131
+ }
132
+
133
+ export async function findGeminiSessionByKeyHash(
134
+ keyHash: string,
135
+ env: NodeJS.ProcessEnv = process.env,
136
+ ): Promise<GeminiSessionRecord | undefined> {
137
+ await load(env);
138
+ return sessions.get(keyHash);
139
+ }
140
+
141
+ export async function saveGeminiSession(
142
+ record: GeminiSessionRecord,
143
+ responseId: string | undefined,
144
+ env: NodeJS.ProcessEnv = process.env,
145
+ ): Promise<void> {
146
+ await load(env);
147
+ sessions.set(record.keyHash, record);
148
+ const previousResponses = [...responses].filter(([, key]) => key === record.keyHash);
149
+ for (const [id] of previousResponses.slice(0, Math.max(0, previousResponses.length - 1))) {
150
+ responses.delete(id);
151
+ }
152
+ if (responseId) responses.set(responseId, record.keyHash);
153
+ const cutoff = Date.now() - ttlMs(env);
154
+ for (const [key, value] of sessions) {
155
+ if (value.lastUsed < cutoff) {
156
+ sessions.delete(key);
157
+ for (const [id, mapped] of responses) if (mapped === key) responses.delete(id);
158
+ await removeWorkspace(value, env);
159
+ }
160
+ }
161
+ const limit = maxSessions(env);
162
+ while (sessions.size > limit) {
163
+ const oldest = [...sessions.values()].sort((left, right) => left.lastUsed - right.lastUsed)[0];
164
+ if (!oldest) break;
165
+ sessions.delete(oldest.keyHash);
166
+ for (const [id, mapped] of responses) if (mapped === oldest.keyHash) responses.delete(id);
167
+ await removeWorkspace(oldest, env);
168
+ }
169
+ await persist(env);
170
+ }
171
+
172
+ export async function deleteGeminiSession(
173
+ keyHash: string,
174
+ env: NodeJS.ProcessEnv = process.env,
175
+ ): Promise<void> {
176
+ await load(env);
177
+ const record = sessions.get(keyHash);
178
+ sessions.delete(keyHash);
179
+ for (const [id, mapped] of responses) if (mapped === keyHash) responses.delete(id);
180
+ if (record) await removeWorkspace(record, env);
181
+ await persist(env);
182
+ }
183
+
184
+ export async function reclaimGeminiSessions(env: NodeJS.ProcessEnv = process.env): Promise<void> {
185
+ await load(env);
186
+ const cutoff = Date.now() - ttlMs(env);
187
+ let changed = false;
188
+ for (const [key, record] of sessions) {
189
+ if (record.lastUsed >= cutoff) continue;
190
+ sessions.delete(key);
191
+ for (const [id, mapped] of responses) if (mapped === key) responses.delete(id);
192
+ await removeWorkspace(record, env);
193
+ changed = true;
194
+ }
195
+ if (changed) await persist(env);
196
+ }
197
+
198
+ export function resetGeminiSessionStoreForTests(): void {
199
+ sessions.clear();
200
+ responses.clear();
201
+ loaded = false;
202
+ }
@@ -108,7 +108,7 @@ export function buildSystemPrompt(parsed: OcxParsedRequest): string | undefined
108
108
 
109
109
  export function buildConversationInput(
110
110
  parsed: OcxParsedRequest,
111
- options: { maxHistoryChars?: number; vendorNameByWire?: ReadonlyMap<string, string> } = {},
111
+ options: { maxHistoryChars?: number; maxChars?: number; vendorNameByWire?: ReadonlyMap<string, string> } = {},
112
112
  ): string[] {
113
113
  const messages = parsed.context.messages.filter(message => message.role !== "developer");
114
114
  if (messages.length === 0) {
@@ -117,14 +117,8 @@ export function buildConversationInput(
117
117
  if (messages.length === 1 && messages[0]!.role === "user") {
118
118
  return [JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: textOf(messages[0]!.content) }] } })];
119
119
  }
120
- const history = messages.slice(0, -1)
121
- .map(message => formatMessage(message, options.vendorNameByWire))
122
- .filter(Boolean)
123
- .join("\n\n");
124
- const limit = options.maxHistoryChars ?? MAX_PROJECTED_HISTORY_CHARS;
125
- const boundedHistory = history.length > limit
126
- ? `[Earlier conversation history truncated for length...]\n\n${history.slice(-limit)}`
127
- : history;
120
+ const limit = options.maxChars ?? options.maxHistoryChars ?? MAX_PROJECTED_HISTORY_CHARS;
121
+ const boundedHistory = projectHistory(messages.slice(0, -1), limit, options.vendorNameByWire);
128
122
  const current = messages.at(-1)!;
129
123
  const currentText = current.role === "toolResult"
130
124
  ? `${formatMessage(current)}\n\nPlease proceed based on the above tool result.`
@@ -138,4 +132,61 @@ export function buildConversationInput(
138
132
  })];
139
133
  }
140
134
 
135
+ function projectHistory(
136
+ messages: readonly OcxMessage[],
137
+ limit: number,
138
+ vendorNameByWire?: ReadonlyMap<string, string>,
139
+ ): string {
140
+ const entries = messages.map((message, index) => {
141
+ const age = messages.length - index;
142
+ const priority = message.role === "toolResult"
143
+ ? age <= 2 ? 10 : 2
144
+ : message.role === "user"
145
+ ? 9
146
+ : message.role === "assistant" && message.content.every(part => part.type === "thinking")
147
+ ? 1
148
+ : message.role === "assistant" && age <= 2
149
+ ? 8
150
+ : 4;
151
+ return {
152
+ index,
153
+ age,
154
+ priority,
155
+ text: formatMessage(message, vendorNameByWire),
156
+ };
157
+ }).filter(entry => entry.text.length > 0);
158
+ let total = entries.reduce((sum, entry) => sum + entry.text.length + 2, 0);
159
+ if (total <= limit) return entries.map(entry => entry.text).join("\n\n");
160
+ const removed = new Set<number>();
161
+ const candidates = entries
162
+ .filter(entry => entry.priority < 10)
163
+ .sort((left, right) => left.priority - right.priority || right.age - left.age);
164
+ for (const entry of candidates) {
165
+ if (total <= limit) break;
166
+ removed.add(entry.index);
167
+ total -= entry.text.length + 2;
168
+ }
169
+ for (const entry of entries.filter(entry => !removed.has(entry.index)).sort((left, right) => right.age - left.age)) {
170
+ if (total <= limit) break;
171
+ removed.add(entry.index);
172
+ total -= entry.text.length + 2;
173
+ }
174
+ const kept = entries.filter(entry => !removed.has(entry.index)).map(entry => entry.text);
175
+ return [`[Earlier conversation history omitted: ${removed.size} messages]`, ...kept].filter(Boolean).join("\n\n");
176
+ }
177
+
178
+ export function buildConversationDelta(
179
+ parsed: OcxParsedRequest,
180
+ startIndex: number,
181
+ options: { vendorNameByWire?: ReadonlyMap<string, string> } = {},
182
+ ): string {
183
+ const messages = parsed.context.messages.filter(message => message.role !== "developer");
184
+ const delta = messages.slice(startIndex).map(message => formatMessage(message, options.vendorNameByWire)).filter(Boolean);
185
+ if (delta.length === 0) return "";
186
+ const text = delta.join("\n\n");
187
+ return messages.at(-1)?.role === "toolResult"
188
+ ? `${text}\n\nPlease proceed based on the above tool result.`
189
+ : text;
190
+ }
191
+
141
192
  export type { AdapterEvent };
@@ -1,5 +1,5 @@
1
1
  import type { ChildProcess, SpawnOptions } from "node:child_process";
2
- import type { AdapterEvent, OcxUsage } from "../../types";
2
+ import type { OcxUsage } from "../../types";
3
3
 
4
4
  export type CliAgentSpawnFn = (
5
5
  command: string,
@@ -17,27 +17,6 @@ export interface CliAgentStreamState {
17
17
  lastError?: string;
18
18
  }
19
19
 
20
- export interface CliAgentFailure {
21
- exitCode: number;
22
- stderr: string;
23
- }
24
-
25
- export interface CliAgentTurnPlan {
26
- label: string;
27
- binary: string;
28
- args: readonly string[];
29
- env: Record<string, string>;
30
- cwd: string;
31
- stdin: string;
32
- stdinFile?: string;
33
- state: CliAgentStreamState;
34
- parseLine: (message: Record<string, unknown>, state: CliAgentStreamState) => AdapterEvent[];
35
- classifyFailure?: (failure: CliAgentFailure) => Extract<AdapterEvent, { type: "error" }>;
36
- requireToolCall?: boolean;
37
- toolBatchIdleMs?: number;
38
- cleanup: () => Promise<void>;
39
- }
40
-
41
20
  export interface CliAgentTurnDeps {
42
21
  spawn?: CliAgentSpawnFn;
43
22
  platform?: NodeJS.Platform;
package/src/cli/index.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import { startServer } from "../server";
20
20
  import { loginClineAcp } from "../adapters/cli-agent/cline/acp";
21
21
  import { runCodingAgentMcpServer } from "../adapters/cli-agent/mcp-server";
22
+ import { syncCodexCatalog } from "../lib/codex-catalog";
22
23
 
23
24
  const here = dirname(fileURLToPath(import.meta.url));
24
25
 
@@ -61,6 +62,7 @@ function help(): string {
61
62
  " start [--port <port>] [--host <host>] Start the proxy",
62
63
  " status [--json] Check the running proxy",
63
64
  " stop [--json] Stop the running proxy",
65
+ " codex sync [--json] Refresh the Codex model catalog",
64
66
  " version Print the package version",
65
67
  " help Show this help",
66
68
  "",
@@ -177,6 +179,20 @@ async function startCommand(args: string[]): Promise<number> {
177
179
  config.port = parsed;
178
180
  }
179
181
  if (host !== undefined) config.host = host;
182
+ try {
183
+ const catalog = await syncCodexCatalog({ env });
184
+ if (catalog.ok && catalog.written) {
185
+ console.log(`Codex catalog refreshed: ${catalog.path}`);
186
+ console.log("Restart the Codex app/app-server to reload the model picker.");
187
+ if (!catalog.configured) {
188
+ console.log(`Activate it with: model_catalog_json = "${catalog.path}"`);
189
+ }
190
+ } else if (!catalog.ok) {
191
+ console.warn(`[opencodex] Codex catalog was not refreshed: ${catalog.message}`);
192
+ }
193
+ } catch (error) {
194
+ console.warn(`[opencodex] Codex catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`);
195
+ }
180
196
  const running = startServer(config, { env });
181
197
  await writeRuntimeState({ pid: process.pid, port: running.port, host: running.host, startedAt: new Date().toISOString() }, env);
182
198
  console.log(`opencodex listening on http://${running.host}:${running.port}/v1 (default provider: ${config.defaultProvider})`);
@@ -194,6 +210,31 @@ async function startCommand(args: string[]): Promise<number> {
194
210
  return 0;
195
211
  }
196
212
 
213
+ async function codexCommand(args: string[]): Promise<number> {
214
+ const sub = args.shift();
215
+ if (sub !== "sync") throw new ConfigError(`Unknown codex command: ${sub ?? "(missing)"}. Expected sync.`);
216
+ const json = takeFlag(args, "--json");
217
+ if (args.length > 0) throw new ConfigError(`Unknown codex sync option: ${args[0]}`);
218
+ const result = await syncCodexCatalog({ env: process.env });
219
+ if (!result.ok) {
220
+ if (json) console.log(JSON.stringify({ ok: false, path: result.path, reason: result.reason, message: result.message }));
221
+ else console.error(`ocx: ${result.message}`);
222
+ return 1;
223
+ }
224
+ if (json) {
225
+ console.log(JSON.stringify(result));
226
+ } else {
227
+ console.log(`Catalog: ${result.path}`);
228
+ console.log(`Models: ${result.modelCount} (Gemini ${result.geminiModelCount}, reasoning-enabled ${result.reasoningModelCount})`);
229
+ for (const warning of result.warnings) console.warn(`[opencodex] ${warning}`);
230
+ if (!result.configured) {
231
+ console.log(`Activate it with: model_catalog_json = "${result.path}"`);
232
+ }
233
+ console.log("Codex reload required: restart the Codex app or app-server model picker.");
234
+ }
235
+ return 0;
236
+ }
237
+
197
238
  async function statusCommand(args: string[]): Promise<number> {
198
239
  const json = takeFlag(args, "--json");
199
240
  if (args.length > 0) throw new ConfigError(`Unknown status option: ${args[0]}`);
@@ -256,10 +297,11 @@ async function main(argv: string[]): Promise<number> {
256
297
  }
257
298
  if (command === "login") return await loginCommand(argv);
258
299
  if (command === "provider") return await providerCommand(argv);
300
+ if (command === "codex") return await codexCommand(argv);
259
301
  if (command === "start") return await startCommand(argv);
260
302
  if (command === "status") return await statusCommand(argv);
261
303
  if (command === "stop") return await stopCommand(argv);
262
- throw new ConfigError(`Unknown command: ${command}. Expected login, provider, start, status, stop, version, or help.`);
304
+ throw new ConfigError(`Unknown command: ${command}. Expected login, provider, codex, start, status, stop, version, or help.`);
263
305
  }
264
306
 
265
307
  try {