@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.
@@ -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 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;