@koreanhole/opencodex 2.97.0 → 2.99.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/AGENTS_INSTALL.md +12 -2
- package/README.md +83 -5
- package/package.json +6 -1
- package/src/adapters/cli-agent/acp-client.ts +60 -3
- package/src/adapters/cli-agent/cline/acp.ts +1 -7
- package/src/adapters/cli-agent/cline/adapter.ts +54 -151
- package/src/adapters/cli-agent/cline/settings.ts +38 -260
- package/src/adapters/cli-agent/cline/stream.ts +2 -216
- package/src/adapters/cli-agent/gemini/acp.ts +141 -9
- package/src/adapters/cli-agent/gemini/adapter.ts +179 -16
- package/src/adapters/cli-agent/gemini/live-models.ts +32 -3
- package/src/adapters/cli-agent/gemini/model-metadata.ts +100 -4
- package/src/adapters/cli-agent/gemini/runtime.ts +183 -0
- package/src/adapters/cli-agent/gemini/session-store.ts +202 -0
- package/src/adapters/cli-agent/protocol.ts +60 -9
- package/src/adapters/cli-agent/types.ts +1 -22
- package/src/cli/index.ts +83 -15
- package/src/lib/codex-catalog.ts +107 -13
- package/src/lib/codex-inject.ts +467 -0
- package/src/server/index.ts +30 -3
- package/src/server/responses.ts +91 -9
- package/src/server/websocket.ts +261 -0
- package/src/types.ts +12 -1
- package/src/adapters/cli-agent/turn.ts +0 -307
|
@@ -26,6 +26,9 @@ export interface GeminiMetadataResult {
|
|
|
26
26
|
packageRoot?: string;
|
|
27
27
|
settingsPath?: string;
|
|
28
28
|
capabilities: Record<string, GeminiModelCapability>;
|
|
29
|
+
tokenLimits: Record<string, number>;
|
|
30
|
+
defaultTokenLimit?: number;
|
|
31
|
+
compressionThreshold?: number;
|
|
29
32
|
warning?: string;
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -123,6 +126,85 @@ async function readThinkingLevels(packageRoot: string): Promise<Array<{ effort:
|
|
|
123
126
|
return [];
|
|
124
127
|
}
|
|
125
128
|
|
|
129
|
+
interface RuntimeLimits {
|
|
130
|
+
tokenLimits: Record<string, number>;
|
|
131
|
+
defaultTokenLimit?: number;
|
|
132
|
+
compressionThreshold?: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function numericLiteral(value: string): number | undefined {
|
|
136
|
+
const parsed = Number(value);
|
|
137
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function functionBody(text: string, name: string): string | undefined {
|
|
141
|
+
const start = text.indexOf(`function ${name}(`);
|
|
142
|
+
if (start < 0) return undefined;
|
|
143
|
+
const open = text.indexOf("{", start);
|
|
144
|
+
if (open < 0) return undefined;
|
|
145
|
+
let depth = 0;
|
|
146
|
+
for (let index = open; index < text.length; index++) {
|
|
147
|
+
if (text[index] === "{") depth++;
|
|
148
|
+
else if (text[index] === "}" && --depth === 0) return text.slice(open + 1, index);
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function readRuntimeLimits(packageRoot: string): Promise<RuntimeLimits> {
|
|
154
|
+
let files: string[];
|
|
155
|
+
try {
|
|
156
|
+
files = (await readdir(join(packageRoot, "bundle"))).filter(name => name.endsWith(".js"));
|
|
157
|
+
} catch {
|
|
158
|
+
return { tokenLimits: {} };
|
|
159
|
+
}
|
|
160
|
+
const candidates = await Promise.all(files.map(async name => {
|
|
161
|
+
const path = join(packageRoot, "bundle", name);
|
|
162
|
+
const info = await stat(path).catch(() => undefined);
|
|
163
|
+
return info ? { path, size: info.size } : undefined;
|
|
164
|
+
}));
|
|
165
|
+
candidates.sort((left, right) => (left?.size ?? Infinity) - (right?.size ?? Infinity));
|
|
166
|
+
for (const candidate of candidates) {
|
|
167
|
+
if (!candidate) continue;
|
|
168
|
+
const text = await readFile(candidate.path, "utf8").catch(() => "");
|
|
169
|
+
if (!text.includes("DEFAULT_TOKEN_LIMIT")) continue;
|
|
170
|
+
const defaultMatch = text.match(/var DEFAULT_TOKEN_LIMIT = ([0-9.e+]+);/);
|
|
171
|
+
const defaultTokenLimit = defaultMatch ? numericLiteral(defaultMatch[1]!) : undefined;
|
|
172
|
+
if (!defaultTokenLimit) continue;
|
|
173
|
+
const gemmaMatch = text.match(/var GEMMA_4_TOKEN_LIMIT = ([0-9.e+]+);/);
|
|
174
|
+
const gemmaLimit = gemmaMatch ? numericLiteral(gemmaMatch[1]!) : undefined;
|
|
175
|
+
const thresholdMatch = text.match(/var DEFAULT_COMPRESSION_TOKEN_THRESHOLD = ([0-9.]+);/);
|
|
176
|
+
const compressionThreshold = thresholdMatch ? Number(thresholdMatch[1]) : undefined;
|
|
177
|
+
const constants = new Map<string, string>();
|
|
178
|
+
for (const match of text.matchAll(/var ([A-Z][A-Z0-9_]*) = "([^"]+)";/g)) {
|
|
179
|
+
constants.set(match[1]!, match[2]!);
|
|
180
|
+
}
|
|
181
|
+
const body = functionBody(text, "tokenLimit");
|
|
182
|
+
const tokenLimits: Record<string, number> = {};
|
|
183
|
+
if (body) {
|
|
184
|
+
const groups = [...body.matchAll(/case ([A-Z][A-Z0-9_]*):([\s\S]*?)(?=case |default:|$)/g)];
|
|
185
|
+
for (const group of groups) {
|
|
186
|
+
const modelId = constants.get(group[1]!);
|
|
187
|
+
const returned = group[2]!.match(/return ([A-Z][A-Z0-9_]*|[0-9.e+]+);/)?.[1];
|
|
188
|
+
if (!modelId || !returned) continue;
|
|
189
|
+
const limit = returned === "DEFAULT_TOKEN_LIMIT"
|
|
190
|
+
? defaultTokenLimit
|
|
191
|
+
: returned === "GEMMA_4_TOKEN_LIMIT"
|
|
192
|
+
? gemmaLimit
|
|
193
|
+
: numericLiteral(returned);
|
|
194
|
+
if (limit) tokenLimits[modelId] = limit;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
tokenLimits,
|
|
199
|
+
defaultTokenLimit,
|
|
200
|
+
...(compressionThreshold !== undefined && compressionThreshold > 0 && compressionThreshold <= 1
|
|
201
|
+
? { compressionThreshold }
|
|
202
|
+
: {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return { tokenLimits: {} };
|
|
206
|
+
}
|
|
207
|
+
|
|
126
208
|
function mergeConfig(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
|
|
127
209
|
const out = { ...target };
|
|
128
210
|
for (const [key, value] of Object.entries(source)) {
|
|
@@ -195,19 +277,20 @@ function capabilitiesFromMetadata(
|
|
|
195
277
|
async function loadMetadata(options: GeminiMetadataOptions): Promise<GeminiMetadataResult> {
|
|
196
278
|
const env = options.env ?? process.env;
|
|
197
279
|
const packageRoot = options.packageRoot ?? await findPackageRoot(options.binary);
|
|
198
|
-
if (!packageRoot) return { capabilities: {}, warning: "Gemini CLI package metadata was not found." };
|
|
280
|
+
if (!packageRoot) return { capabilities: {}, tokenLimits: {}, warning: "Gemini CLI package metadata was not found." };
|
|
281
|
+
const runtimeLimits = await readRuntimeLimits(packageRoot);
|
|
199
282
|
const docsPath = join(packageRoot, "bundle", "docs", "reference", "configuration.md");
|
|
200
283
|
let markdown: string;
|
|
201
284
|
try {
|
|
202
285
|
markdown = await readFile(docsPath, "utf8");
|
|
203
286
|
} catch {
|
|
204
|
-
return { packageRoot, capabilities: {}, warning: "Gemini CLI model configuration documentation was not found." };
|
|
287
|
+
return { packageRoot, capabilities: {}, ...runtimeLimits, warning: "Gemini CLI model configuration documentation was not found." };
|
|
205
288
|
}
|
|
206
289
|
const defaultAliases = extractDefaultJson(markdown, "aliases");
|
|
207
290
|
const defaultDefinitions = extractDefaultJson(markdown, "modelDefinitions");
|
|
208
291
|
const defaultOverrides = extractDefaultJsonValue(markdown, "overrides");
|
|
209
292
|
if (!defaultAliases || !defaultDefinitions) {
|
|
210
|
-
return { packageRoot, capabilities: {}, warning: "Gemini CLI model configuration metadata could not be parsed." };
|
|
293
|
+
return { packageRoot, capabilities: {}, ...runtimeLimits, warning: "Gemini CLI model configuration metadata could not be parsed." };
|
|
211
294
|
}
|
|
212
295
|
|
|
213
296
|
const home = stringValue(env.GEMINI_CLI_HOME) ?? stringValue(env.HOME) ?? stringValue(env.USERPROFILE) ?? homedir();
|
|
@@ -231,12 +314,13 @@ async function loadMetadata(options: GeminiMetadataOptions): Promise<GeminiMetad
|
|
|
231
314
|
];
|
|
232
315
|
const levels = await readThinkingLevels(packageRoot);
|
|
233
316
|
if (levels.length === 0) {
|
|
234
|
-
return { packageRoot, settingsPath, capabilities: {}, warning: "Gemini CLI thinking-level metadata was not found." };
|
|
317
|
+
return { packageRoot, settingsPath, capabilities: {}, ...runtimeLimits, warning: "Gemini CLI thinking-level metadata was not found." };
|
|
235
318
|
}
|
|
236
319
|
return {
|
|
237
320
|
packageRoot,
|
|
238
321
|
settingsPath,
|
|
239
322
|
capabilities: capabilitiesFromMetadata(definitions, aliases, levels, overrides),
|
|
323
|
+
...runtimeLimits,
|
|
240
324
|
};
|
|
241
325
|
}
|
|
242
326
|
|
|
@@ -273,6 +357,18 @@ export function mergeGeminiModelDescriptors(
|
|
|
273
357
|
...(model.description ? { description: model.description } : {}),
|
|
274
358
|
supportedReasoningLevels: reasoningLevels,
|
|
275
359
|
...(capability?.defaultReasoningLevel ? { defaultReasoningLevel: capability.defaultReasoningLevel } : {}),
|
|
360
|
+
...(metadata.tokenLimits[model.modelId] !== undefined
|
|
361
|
+
? {
|
|
362
|
+
contextWindowTokens: metadata.tokenLimits[model.modelId],
|
|
363
|
+
maxContextWindowTokens: metadata.tokenLimits[model.modelId],
|
|
364
|
+
}
|
|
365
|
+
: metadata.defaultTokenLimit !== undefined
|
|
366
|
+
? {
|
|
367
|
+
contextWindowTokens: metadata.defaultTokenLimit,
|
|
368
|
+
maxContextWindowTokens: metadata.defaultTokenLimit,
|
|
369
|
+
}
|
|
370
|
+
: {}),
|
|
371
|
+
...(metadata.compressionThreshold !== undefined ? { compressionThreshold: metadata.compressionThreshold } : {}),
|
|
276
372
|
};
|
|
277
373
|
});
|
|
278
374
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getConfigDir } from "../../../config";
|
|
4
|
+
import type { AcpInitialize } from "../acp-client";
|
|
5
|
+
|
|
6
|
+
export interface GeminiModelRuntime {
|
|
7
|
+
contextSize?: number;
|
|
8
|
+
usedTokens?: number;
|
|
9
|
+
promptChars?: number;
|
|
10
|
+
charsPerToken?: number;
|
|
11
|
+
observedAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GeminiRuntime {
|
|
15
|
+
loadSession: boolean;
|
|
16
|
+
prompt: {
|
|
17
|
+
image: boolean;
|
|
18
|
+
audio: boolean;
|
|
19
|
+
embeddedContext: boolean;
|
|
20
|
+
};
|
|
21
|
+
compressionThreshold?: number;
|
|
22
|
+
models: Record<string, GeminiModelRuntime>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const runtimeCache = new Map<string, GeminiRuntime>();
|
|
26
|
+
let loadedFromDisk = false;
|
|
27
|
+
|
|
28
|
+
function positive(value: unknown): number | undefined {
|
|
29
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function nonNegative(value: unknown): number | undefined {
|
|
33
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function runtimePath(env: NodeJS.ProcessEnv): string {
|
|
37
|
+
return join(getConfigDir(env), "gemini", "capabilities.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function emptyRuntime(): GeminiRuntime {
|
|
41
|
+
return {
|
|
42
|
+
loadSession: false,
|
|
43
|
+
prompt: { image: false, audio: false, embeddedContext: false },
|
|
44
|
+
models: {},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseRuntime(value: unknown): GeminiRuntime | undefined {
|
|
49
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
50
|
+
const row = value as Record<string, unknown>;
|
|
51
|
+
const prompt = row.prompt && typeof row.prompt === "object" && !Array.isArray(row.prompt)
|
|
52
|
+
? row.prompt as Record<string, unknown>
|
|
53
|
+
: {};
|
|
54
|
+
const models: Record<string, GeminiModelRuntime> = {};
|
|
55
|
+
if (row.models && typeof row.models === "object" && !Array.isArray(row.models)) {
|
|
56
|
+
for (const [modelId, raw] of Object.entries(row.models as Record<string, unknown>)) {
|
|
57
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
|
|
58
|
+
const item = raw as Record<string, unknown>;
|
|
59
|
+
models[modelId] = {
|
|
60
|
+
...(positive(item.contextSize) !== undefined ? { contextSize: positive(item.contextSize) } : {}),
|
|
61
|
+
...(nonNegative(item.usedTokens) !== undefined ? { usedTokens: nonNegative(item.usedTokens) } : {}),
|
|
62
|
+
...(nonNegative(item.promptChars) !== undefined ? { promptChars: nonNegative(item.promptChars) } : {}),
|
|
63
|
+
...(positive(item.charsPerToken) !== undefined ? { charsPerToken: positive(item.charsPerToken) } : {}),
|
|
64
|
+
observedAt: nonNegative(item.observedAt) ?? 0,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const threshold = positive(row.compressionThreshold);
|
|
69
|
+
return {
|
|
70
|
+
loadSession: row.loadSession === true,
|
|
71
|
+
prompt: {
|
|
72
|
+
image: prompt.image === true,
|
|
73
|
+
audio: prompt.audio === true,
|
|
74
|
+
embeddedContext: prompt.embeddedContext === true,
|
|
75
|
+
},
|
|
76
|
+
...(threshold !== undefined && threshold <= 1 ? { compressionThreshold: threshold } : {}),
|
|
77
|
+
models,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function readDisk(env: NodeJS.ProcessEnv): Promise<void> {
|
|
82
|
+
if (loadedFromDisk) return;
|
|
83
|
+
loadedFromDisk = true;
|
|
84
|
+
let parsed: unknown;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(await readFile(runtimePath(env), "utf8"));
|
|
87
|
+
} catch {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
|
|
91
|
+
for (const [fingerprint, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
92
|
+
const runtime = parseRuntime(value);
|
|
93
|
+
if (runtime) runtimeCache.set(fingerprint, runtime);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function persist(env: NodeJS.ProcessEnv): Promise<void> {
|
|
98
|
+
const path = runtimePath(env);
|
|
99
|
+
await mkdir(join(getConfigDir(env), "gemini"), { recursive: true, mode: 0o700 });
|
|
100
|
+
const limit = Number(env.OPENCODEX_GEMINI_CAPABILITY_MAX ?? "") || 8;
|
|
101
|
+
if (runtimeCache.size > limit) {
|
|
102
|
+
const oldest = [...runtimeCache.entries()]
|
|
103
|
+
.sort((left, right) => {
|
|
104
|
+
const leftAt = Math.max(0, ...Object.values(left[1].models).map(model => model.observedAt));
|
|
105
|
+
const rightAt = Math.max(0, ...Object.values(right[1].models).map(model => model.observedAt));
|
|
106
|
+
return leftAt - rightAt;
|
|
107
|
+
});
|
|
108
|
+
for (const [fingerprint] of oldest.slice(0, Math.max(0, runtimeCache.size - limit))) {
|
|
109
|
+
runtimeCache.delete(fingerprint);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const payload = Object.fromEntries(runtimeCache);
|
|
113
|
+
const temporary = `${path}.${crypto.randomUUID()}.tmp`;
|
|
114
|
+
await writeFile(temporary, JSON.stringify(payload, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
115
|
+
await rename(temporary, path);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function getGeminiRuntime(
|
|
119
|
+
fingerprint: string,
|
|
120
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
121
|
+
): Promise<GeminiRuntime> {
|
|
122
|
+
await readDisk(env);
|
|
123
|
+
return runtimeCache.get(fingerprint) ?? emptyRuntime();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function recordGeminiInitialize(
|
|
127
|
+
fingerprint: string,
|
|
128
|
+
initialize: AcpInitialize,
|
|
129
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
await readDisk(env);
|
|
132
|
+
const current = runtimeCache.get(fingerprint) ?? emptyRuntime();
|
|
133
|
+
runtimeCache.set(fingerprint, {
|
|
134
|
+
...current,
|
|
135
|
+
loadSession: initialize.capabilities.loadSession,
|
|
136
|
+
prompt: { ...initialize.capabilities.prompt },
|
|
137
|
+
});
|
|
138
|
+
await persist(env).catch(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function recordGeminiUsage(
|
|
142
|
+
fingerprint: string,
|
|
143
|
+
modelId: string,
|
|
144
|
+
usedTokens: number,
|
|
145
|
+
contextSize: number,
|
|
146
|
+
promptChars: number,
|
|
147
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
148
|
+
): Promise<void> {
|
|
149
|
+
if (!positive(contextSize) || !nonNegative(usedTokens) || !nonNegative(promptChars)) return;
|
|
150
|
+
await readDisk(env);
|
|
151
|
+
const current = runtimeCache.get(fingerprint) ?? emptyRuntime();
|
|
152
|
+
const previous = current.models[modelId];
|
|
153
|
+
const charsPerToken = usedTokens > 0 && promptChars > 0
|
|
154
|
+
? promptChars / usedTokens
|
|
155
|
+
: previous?.charsPerToken;
|
|
156
|
+
current.models[modelId] = {
|
|
157
|
+
contextSize,
|
|
158
|
+
usedTokens,
|
|
159
|
+
promptChars,
|
|
160
|
+
...(charsPerToken !== undefined ? { charsPerToken } : {}),
|
|
161
|
+
observedAt: Date.now(),
|
|
162
|
+
};
|
|
163
|
+
runtimeCache.set(fingerprint, current);
|
|
164
|
+
await persist(env).catch(() => undefined);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function recordGeminiCompressionThreshold(
|
|
168
|
+
fingerprint: string,
|
|
169
|
+
threshold: number | undefined,
|
|
170
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
if (threshold === undefined || !positive(threshold) || threshold > 1) return;
|
|
173
|
+
await readDisk(env);
|
|
174
|
+
const current = runtimeCache.get(fingerprint) ?? emptyRuntime();
|
|
175
|
+
current.compressionThreshold = threshold;
|
|
176
|
+
runtimeCache.set(fingerprint, current);
|
|
177
|
+
await persist(env).catch(() => undefined);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function resetGeminiRuntimeCacheForTests(): void {
|
|
181
|
+
runtimeCache.clear();
|
|
182
|
+
loadedFromDisk = false;
|
|
183
|
+
}
|
|
@@ -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
|
|
121
|
-
|
|
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 {
|
|
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;
|