codex-copilot-dx 0.8.4 → 0.8.6

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/README.md CHANGED
@@ -18,15 +18,17 @@ The adapter handles response history, encrypted reasoning state, function/custom
18
18
 
19
19
  ### Fast and Standard modes
20
20
 
21
- Codex can select Standard or Fast without changing CCDX configuration. When the client requests `gpt-5.6-sol` with `service_tier: "priority"`, CCDX uses `gpt-5.6-sol-fast` only when the live Copilot catalog advertises that exact model as enabled, selectable, OpenAI-owned, and Responses-only. Standard mode continues to use `gpt-5.6-sol`; CCDX does not invent Fast availability when the upstream catalog does not provide it.
21
+ Codex can select Standard or Fast without changing CCDX configuration. When the client requests a model with `service_tier: "priority"`, CCDX uses its exact `-fast` model only when the live Copilot catalog advertises that variant as enabled, selectable, OpenAI-owned, and Responses-only. Standard mode continues to use the base model; CCDX does not invent Fast availability when the upstream catalog does not provide it. This retains the established `gpt-5.6-sol` / `gpt-5.6-sol-fast` mapping and applies the same exact-match rule to future models.
22
22
 
23
23
  Codex Auto-review uses the hidden `codex-auto-review` model ID. CCDX maps it to Copilot's `gpt-5.5` Responses model by default, independently of the interactive Fast/Standard selection, and logs both model IDs when the mapping is used. The review target can be changed interactively without reinstalling or restarting CCDX.
24
24
 
25
25
  ### GPT-6 model catalog
26
26
 
27
- Codex App requests a versioned, capability-rich model catalog that is different from Copilot's raw model list. CCDX reads the complete catalog bundled with the installed Codex App and preserves every other model entry and capability. It exposes the bundled `gpt-6-astra` entry only when Copilot's available catalog advertises that exact model as enabled, selectable, OpenAI-owned, and Responses-capable.
27
+ Codex App requests a versioned, capability-rich model catalog that is different from Copilot's raw model list. CCDX reads the complete catalog bundled with the installed Codex App and preserves every other model entry and capability. It exposes each managed GPT-6 entry only when Copilot advertises that exact model as enabled, selectable, OpenAI-owned, and Responses-capable.
28
28
 
29
- CCDX exposes the GPT-6 Standard path and removes its unverified speed-tier metadata. It leaves the bundled reasoning-effort options unchanged, including Ultra when supplied by the installed catalog. GPT-5.6 Sol Fast follows the separate rule above. Versioned model discovery matches the bundled catalog to the client's `client_version`, caches it by application binary identity, and reloads it after an App update. If a matching usable catalog cannot be loaded, the versioned models request returns `503`; an unversioned request returns the Copilot-style list.
29
+ When an installed 0.155.x catalog does not yet contain `gpt-6-sol` or `gpt-6-luna`, CCDX derives their client-compatible entries from that catalog's GPT-6 Astra schema and the published OpenAI Codex model definitions at commit `0a2eb4696c26ac33204bcd255721ab30220a4774`. Only those two missing entries are inserted. Their fallback reasoning controls are intersected with Copilot's advertised efforts; every other bundled entry remains byte-for-byte equivalent. A newer client-provided Sol/Luna entry always wins and is preserved.
30
+
31
+ CCDX exposes GPT-6 Standard paths and removes unverified speed-tier metadata. It leaves bundled reasoning-effort options unchanged, including Ultra when supplied by the installed catalog. Fast follows the exact upstream-variant rule above. Versioned model discovery matches the bundled catalog to the client's `client_version`, caches it by application binary identity, and reloads it after an App update. If a matching usable catalog cannot be loaded, the versioned models request returns `503`; an unversioned request returns the Copilot-style list.
30
32
 
31
33
  `/v1/models` still attempts live discovery first. With a usable in-process catalog, it limits that upstream wait to 1.5 seconds (or a shorter configured upstream timeout) before falling back to last-known-good data. Without a catalog it retains the regular upstream timeout. Received HTTP `401`/`403` responses remain errors, not cached successes; fallback does not grant inference access. This does not change the explicitly live `ccdx models` command.
32
34
 
@@ -115,6 +117,20 @@ The selector first queries the running adapter, including its last-known-good mo
115
117
 
116
118
  The selection is saved in `~/.config/codex-copilot-dx/config.json`, or under `XDG_CONFIG_HOME` when set. Choosing `gpt-5.5` clears the override and restores the package default. A running adapter reads the setting on the next Auto-review request.
117
119
 
120
+ ### Runtime caches
121
+
122
+ ```bash
123
+ ccdx cache
124
+ ccdx cache --limit 128
125
+ ccdx cache --limit default
126
+ ccdx cache --clean
127
+ ccdx cache --clean --history
128
+ ```
129
+
130
+ `ccdx cache` separates the shared in-memory Responses history from the rebuildable input-image transform cache. The history limit accepts 16–1024 MiB, is saved in the normal CCDX settings file, and applies immediately to a compatible running adapter. `CCDX_RESPONSE_HISTORY_MAX_BYTES` remains the higher-precedence environment override. Lowering the limit below current usage is rejected rather than evicting tasks silently. `ccdx cache -128` is a short alias for `--limit 128`; `-clean` aliases `--clean`.
131
+
132
+ Plain `--clean` clears only completed image transforms; the next matching request may recompute them. Adding `--history` also invalidates all process-local `previous_response_id` chains and can make a task's next continuation fail, so it requires interactive confirmation or `--yes`. Cleanup waits for active cache users to finish and never deletes Codex transcripts, generated image files, provider credentials, image edit handles, model catalogs, or usage/debug logs. A stopped or older adapter must be restarted before runtime cleanup; a saved limit applies on the next start.
133
+
118
134
  ### Terminal animation
119
135
 
120
136
  Choose the terminal activity animation interactively:
@@ -324,7 +340,7 @@ Recovery first tries compatible local credentials and starts Device Flow only wh
324
340
  | `CCDX_EXISTING_ADAPTER_TIMEOUT_MS` | `500` | Timeout for detecting an already-running local adapter during startup |
325
341
  | `CCDX_MODEL_REFRESH_TIMEOUT_MS` | `5000` | Timeout for Copilot model refresh or live Auto-review model lookup |
326
342
  | `CCDX_MODEL_REFRESH_INTERVAL_MS` | `7200000` | Periodic model refresh interval; `0` disables periodic refresh, while startup cache/refresh behavior still applies |
327
- | `CCDX_RESPONSE_HISTORY_MAX_BYTES` | `67108864` | Total in-memory byte budget for locally expanded Responses history |
343
+ | `CCDX_RESPONSE_HISTORY_MAX_BYTES` | `67108864` | Higher-precedence total in-memory byte budget for locally expanded Responses history; `ccdx cache --limit` provides a persistent 16–1024 MiB setting when this is unset |
328
344
  | `CCDX_RESPONSE_HISTORY_MAX_ENTRIES` | `4096` | Maximum stored incremental Responses history nodes |
329
345
  | `CCDX_USAGE_PATH` | `~/.local/share/codex-copilot-dx/usage.jsonl` | Local JSONL token-usage log |
330
346
  | `CCDX_USAGE_MAX_BYTES` | `33554432` | Rotate the usage log at this size and retain one `.1` backup; set to `0` to disable rotation |
package/bin/cli.mjs CHANGED
@@ -16,13 +16,14 @@ import { cliCommandName, cliHelp, parseAdapterAddressOptions, parseAdapterProbeO
16
16
  import { formatAdapterStatus, readAdapterStatus } from "../src/cli-status.mjs";
17
17
  import { closeHttpServer } from "../src/shutdown.mjs";
18
18
  import { runAutoReviewModelCommand } from "../src/auto-review-model.mjs";
19
- import { autoReviewModelPreference, terminalAnimationPreference } from "../src/user-settings.mjs";
19
+ import { autoReviewModelPreference, responseHistoryLimitPreference, terminalAnimationPreference } from "../src/user-settings.mjs";
20
20
  import { fetchLiveCopilotModels, formatLiveCopilotModels } from "../src/cli-models.mjs";
21
21
  import { runAuthCommand } from "../src/cli-auth.mjs";
22
22
  import { createProfileRuntime } from "../src/profile-runtime.mjs";
23
23
  import { createProfileModelRuntime } from "../src/profile-model-runtime.mjs";
24
24
  import { createCodexModelCatalog } from "../src/codex-model-catalog.mjs";
25
25
  import { readImageProviderConfig } from "../src/image-provider-config.mjs";
26
+ import { setResponseHistoryMaxBytes } from "../src/response-history.mjs";
26
27
 
27
28
  const LOCAL_VERSION = localPackageVersion();
28
29
  const CLI_NAME = cliCommandName();
@@ -80,6 +81,26 @@ if (CLI.command === "usage") {
80
81
  });
81
82
  process.exit(0);
82
83
  }
84
+ if (CLI.command === "cache") {
85
+ try {
86
+ const probe = parseAdapterProbeOptions(process.env);
87
+ const { runCacheCommand } = await import("../src/cli-cache.mjs");
88
+ await runCacheCommand({
89
+ action: CLI.action,
90
+ limitMib: CLI.limitMib,
91
+ resetLimit: CLI.resetLimit,
92
+ history: CLI.history,
93
+ yes: CLI.yes,
94
+ host: probe.adapterHost,
95
+ port: probe.adapterPort,
96
+ timeoutMs: probe.existingAdapterTimeoutMs,
97
+ });
98
+ process.exit(0);
99
+ } catch (e) {
100
+ console.error(status("err", e.message));
101
+ process.exit(1);
102
+ }
103
+ }
83
104
  if (CLI.command === "retired") {
84
105
  console.error(status("err", `${CLI.integration} integration was retired in ccdx 0.7.0.`));
85
106
  if (CLI.integration === "PM Studio") {
@@ -182,6 +203,7 @@ if (CLI.command === "doctor" && CLI.configOnly) {
182
203
  let RUNTIME;
183
204
  try {
184
205
  RUNTIME = parseRuntimeOptions(process.env);
206
+ setResponseHistoryMaxBytes(responseHistoryLimitPreference().bytes);
185
207
  } catch (e) {
186
208
  console.error(e.message);
187
209
  process.exit(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/adapter.mjs CHANGED
@@ -17,12 +17,19 @@ import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs
17
17
  import { createResponsesCompactHandler, createResponsesHandler } from "./responses-handler.mjs";
18
18
  import { createResponsesImagePressureController } from "./responses-image-pressure.mjs";
19
19
  import { createResponseFailureDiagnostics } from "./response-failures.mjs";
20
+ import {
21
+ ADAPTER_CACHE_PATH,
22
+ cacheRuntimeSnapshot,
23
+ cleanRuntimeCaches,
24
+ setHistoryCacheLimit,
25
+ } from "./cache-control.mjs";
20
26
  import { loadRuntimeConfig, parsePositiveInteger } from "./runtime-config.mjs";
21
27
  import {
22
28
  createRequestAdmission,
23
29
  createRequestAbort,
24
30
  httpError,
25
31
  logRequestFailure,
32
+ readJsonBody,
26
33
  sendJsonError,
27
34
  } from "./http-transport.mjs";
28
35
 
@@ -192,7 +199,7 @@ export function createAdapterHandler(options = {}) {
192
199
  streamIdleTimeoutMs,
193
200
  upstreamTimeoutMs,
194
201
  });
195
- const dispatch = (req, res, pathname) => {
202
+ const dispatch = async (req, res, pathname) => {
196
203
  if (pathname === "/mcp/image") {
197
204
  if (imageMcpHandler) return imageMcpHandler(req, res);
198
205
  return import("./image-mcp.mjs").then(({ createImageMcpHandler }) => {
@@ -227,6 +234,56 @@ export function createAdapterHandler(options = {}) {
227
234
  return;
228
235
  }
229
236
 
237
+ if (pathname === ADAPTER_CACHE_PATH) {
238
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
239
+ res.writeHead(403, { "Content-Type": "application/json" });
240
+ res.end(JSON.stringify({ error: "Cache control is available only from loopback" }));
241
+ return;
242
+ }
243
+ if (req.method === "GET") {
244
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
245
+ res.end(JSON.stringify(cacheRuntimeSnapshot()));
246
+ return;
247
+ }
248
+ if (req.method !== "POST") {
249
+ res.writeHead(405, { "Content-Type": "application/json", Allow: "GET, POST" });
250
+ res.end(JSON.stringify({ error: "Only GET and POST are supported" }));
251
+ return;
252
+ }
253
+ if (Object.hasOwn(req.headers || {}, "origin")) {
254
+ res.writeHead(403, { "Content-Type": "application/json" });
255
+ res.end(JSON.stringify({ error: "Browser origins are not allowed for cache control" }));
256
+ return;
257
+ }
258
+ const mediaType = String(req.headers?.["content-type"] || "").split(";", 1)[0].trim().toLowerCase();
259
+ if (mediaType !== "application/json") {
260
+ res.writeHead(415, { "Content-Type": "application/json" });
261
+ res.end(JSON.stringify({ error: "Cache control requires application/json" }));
262
+ return;
263
+ }
264
+ const abort = createRequestAbort(req, res);
265
+ abort.setTimeout(requestBodyTimeoutMs, "request_body_timeout");
266
+ try {
267
+ const body = await readJsonBody(req, {
268
+ maxBodyBytes: 8 * 1024,
269
+ maxDecodedBodyBytes: 8 * 1024,
270
+ signal: abort.signal,
271
+ });
272
+ let result;
273
+ if (body?.action === "set_limit") result = setHistoryCacheLimit(body.max_bytes);
274
+ else if (body?.action === "clean") result = cleanRuntimeCaches({ history: body.history === true });
275
+ else throw httpError("Unknown cache action", 400);
276
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
277
+ res.end(JSON.stringify(result));
278
+ } catch (error) {
279
+ if (["ccdx_cache_busy", "ccdx_cache_limit_below_usage"].includes(error?.code)) error.statusCode = 409;
280
+ sendJsonError(res, error, 400);
281
+ } finally {
282
+ abort.cleanup();
283
+ }
284
+ return;
285
+ }
286
+
230
287
  if (req.method === "POST" && pathname === "/v1/responses") {
231
288
  return responsesHandler(req, res);
232
289
  }
@@ -314,7 +371,9 @@ export function createAdapterHandler(options = {}) {
314
371
  return;
315
372
  }
316
373
 
317
- const trackRequest = pathname !== ADAPTER_HEALTH_PATH && pathname !== ADAPTER_STATUS_PATH;
374
+ const trackRequest = pathname !== ADAPTER_HEALTH_PATH
375
+ && pathname !== ADAPTER_STATUS_PATH
376
+ && pathname !== ADAPTER_CACHE_PATH;
318
377
  const routeName = classifyAdapterRoute(req.method, pathname);
319
378
  const complete = trackRequest ? requestMetrics.begin(routeName) : () => {};
320
379
  const streamPerformance = trackRequest ? streamPerformanceMetrics.begin(routeName) : null;
@@ -0,0 +1,59 @@
1
+ import {
2
+ MIB,
3
+ RESPONSE_HISTORY_MAX_MIB,
4
+ RESPONSE_HISTORY_MIN_MIB,
5
+ } from "./cache-limits.mjs";
6
+ import {
7
+ clearResponseHistory,
8
+ responseHistoryStats,
9
+ setResponseHistoryMaxBytes,
10
+ } from "./response-history.mjs";
11
+ import {
12
+ clearImageOptimizationCache,
13
+ imageOptimizationStats,
14
+ } from "./image-optimization.mjs";
15
+
16
+ export const ADAPTER_CACHE_PATH = "/_ccdx/cache";
17
+
18
+ export function cacheRuntimeSnapshot() {
19
+ return {
20
+ response_history: responseHistoryStats(),
21
+ image_optimization: imageOptimizationStats(),
22
+ };
23
+ }
24
+
25
+ export function setHistoryCacheLimit(maxBytes) {
26
+ const mib = Number(maxBytes) / MIB;
27
+ if (!Number.isSafeInteger(mib)
28
+ || mib < RESPONSE_HISTORY_MIN_MIB
29
+ || mib > RESPONSE_HISTORY_MAX_MIB) {
30
+ const error = new Error(`History cache limit must be an integer from ${RESPONSE_HISTORY_MIN_MIB} to ${RESPONSE_HISTORY_MAX_MIB} MiB`);
31
+ error.code = "ccdx_cache_limit_invalid";
32
+ throw error;
33
+ }
34
+ return { changed: setResponseHistoryMaxBytes(maxBytes).previousMaxBytes !== maxBytes, ...cacheRuntimeSnapshot() };
35
+ }
36
+
37
+ export function cleanRuntimeCaches({ history = false } = {}) {
38
+ const imageStats = imageOptimizationStats();
39
+ if (imageStats.active > 0 || imageStats.queued > 0 || imageStats.cache_inflight > 0) {
40
+ const error = new Error("Image optimization is active; retry cache cleanup after current requests finish");
41
+ error.code = "ccdx_cache_busy";
42
+ throw error;
43
+ }
44
+ const historyStats = responseHistoryStats();
45
+ if (history && historyStats.pinnedTrees > 0) {
46
+ const error = new Error("Response history is active; retry cleanup after current requests finish");
47
+ error.code = "ccdx_cache_busy";
48
+ throw error;
49
+ }
50
+ const imageOptimization = clearImageOptimizationCache();
51
+ const responseHistory = history ? clearResponseHistory() : null;
52
+ return {
53
+ cleaned: {
54
+ image_optimization: imageOptimization,
55
+ ...(responseHistory ? { response_history: responseHistory } : {}),
56
+ },
57
+ ...cacheRuntimeSnapshot(),
58
+ };
59
+ }
@@ -0,0 +1,4 @@
1
+ export const RESPONSE_HISTORY_DEFAULT_MIB = 64;
2
+ export const RESPONSE_HISTORY_MIN_MIB = 16;
3
+ export const RESPONSE_HISTORY_MAX_MIB = 1024;
4
+ export const MIB = 1024 * 1024;
@@ -0,0 +1,147 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import os from "node:os";
3
+ import { ADAPTER_CACHE_PATH } from "./cache-control.mjs";
4
+ import { MIB, RESPONSE_HISTORY_DEFAULT_MIB } from "./cache-limits.mjs";
5
+ import { adapterBaseUrl } from "./running-adapter.mjs";
6
+ import {
7
+ readUserSettings,
8
+ responseHistoryLimitPreference,
9
+ writeResponseHistoryLimitMib,
10
+ } from "./user-settings.mjs";
11
+
12
+ const RESPONSE_HISTORY_MAX_MIB_KEY = "response_history_max_mib";
13
+
14
+ function mib(bytes) {
15
+ const value = Number(bytes);
16
+ return Number.isFinite(value) ? `${(value / MIB).toFixed(value % MIB === 0 ? 0 : 1)} MiB` : "unknown";
17
+ }
18
+
19
+ async function cacheRequest(baseUrl, { body, fetchImpl, timeoutMs }) {
20
+ const response = await fetchImpl(`${baseUrl}${ADAPTER_CACHE_PATH}`, {
21
+ method: body ? "POST" : "GET",
22
+ headers: { Accept: "application/json", ...(body ? { "Content-Type": "application/json" } : {}) },
23
+ ...(body ? { body: JSON.stringify(body) } : {}),
24
+ signal: AbortSignal.timeout(timeoutMs),
25
+ });
26
+ let payload;
27
+ try { payload = await response.json(); } catch { payload = null; }
28
+ if (!response.ok) {
29
+ const error = new Error(payload?.error || `Cache control returned HTTP ${response.status}`);
30
+ error.statusCode = response.status;
31
+ throw error;
32
+ }
33
+ return payload;
34
+ }
35
+
36
+ async function runningSnapshot(baseUrl, options) {
37
+ try { return { supported: true, data: await cacheRequest(baseUrl, options) }; }
38
+ catch (error) {
39
+ if ([404, 405].includes(error?.statusCode)) return { supported: false, error };
40
+ if (error?.name === "TimeoutError" || error?.cause?.code === "ECONNREFUSED") return { supported: false, offline: true, error };
41
+ throw error;
42
+ }
43
+ }
44
+
45
+ async function confirmHistoryCleanup({ input, output, prompt, yes }) {
46
+ if (yes) return;
47
+ if (prompt) {
48
+ if (!await prompt()) throw new Error("History cache cleanup cancelled");
49
+ return;
50
+ }
51
+ if (!input.isTTY || !output.isTTY) {
52
+ throw new Error("History cleanup can break task continuation; rerun with --yes to confirm");
53
+ }
54
+ const readline = createInterface({ input, output });
55
+ try {
56
+ const answer = String(await readline.question(
57
+ "Clear response history for all tasks? Their next continuation may fail [y/N]: ",
58
+ )).trim().toLowerCase();
59
+ if (!["y", "yes"].includes(answer)) throw new Error("History cache cleanup cancelled");
60
+ } finally { readline.close(); }
61
+ }
62
+
63
+ function formatStatus(preference, runtime) {
64
+ const lines = ["ccdx cache", `Configured history limit: ${mib(preference.bytes)} (${preference.source})`];
65
+ if (!runtime?.supported) {
66
+ lines.push(runtime?.offline
67
+ ? "Runtime cache: adapter is not running"
68
+ : "Runtime cache: restart ccdx to enable cache control");
69
+ return lines.join("\n");
70
+ }
71
+ const history = runtime.data.response_history || {};
72
+ const images = runtime.data.image_optimization || {};
73
+ lines.push(
74
+ `History: ${mib(history.bytes)} / ${mib(history.maxBytes)}, ${history.entries || 0} entries, ${history.evicted || 0} evicted`,
75
+ `Image transforms: ${mib(images.cache_bytes)} / ${mib(images.cache_max_bytes)}, ${images.cache_entries || 0} entries`,
76
+ );
77
+ return lines.join("\n");
78
+ }
79
+
80
+ export async function runCacheCommand({
81
+ action = "status",
82
+ limitMib,
83
+ resetLimit = false,
84
+ history = false,
85
+ yes = false,
86
+ env = process.env,
87
+ home = os.homedir(),
88
+ host = "127.0.0.1",
89
+ port = 2026,
90
+ input = process.stdin,
91
+ output = process.stdout,
92
+ prompt,
93
+ fetchImpl = fetch,
94
+ timeoutMs = 1000,
95
+ } = {}) {
96
+ const baseUrl = adapterBaseUrl(host, port);
97
+ const requestOptions = { fetchImpl, timeoutMs };
98
+ const preference = responseHistoryLimitPreference({ env, home });
99
+ const runtime = await runningSnapshot(baseUrl, requestOptions);
100
+ if (action === "status") {
101
+ const text = formatStatus(preference, runtime);
102
+ output.write(`${text}\n`);
103
+ return { preference, runtime };
104
+ }
105
+ if (action === "limit") {
106
+ if (String(env.CCDX_RESPONSE_HISTORY_MAX_BYTES || "").trim()) {
107
+ throw new Error("CCDX_RESPONSE_HISTORY_MAX_BYTES overrides saved cache settings; unset it before using ccdx cache --limit");
108
+ }
109
+ const targetMib = resetLimit ? RESPONSE_HISTORY_DEFAULT_MIB : Number(limitMib);
110
+ const targetBytes = targetMib * MIB;
111
+ if (runtime.supported && targetBytes < runtime.data.response_history.bytes) {
112
+ throw new Error(`History currently uses ${mib(runtime.data.response_history.bytes)}; clean history or choose a larger limit`);
113
+ }
114
+ const previous = readUserSettings({ env, home, strict: true });
115
+ const previousMib = Object.hasOwn(previous, RESPONSE_HISTORY_MAX_MIB_KEY)
116
+ ? previous[RESPONSE_HISTORY_MAX_MIB_KEY]
117
+ : null;
118
+ const saved = writeResponseHistoryLimitMib(resetLimit ? null : targetMib, { env, home });
119
+ try {
120
+ if (runtime.supported) {
121
+ await cacheRequest(baseUrl, { ...requestOptions, body: { action: "set_limit", max_bytes: targetBytes } });
122
+ }
123
+ } catch (error) {
124
+ writeResponseHistoryLimitMib(previousMib, { env, home });
125
+ throw error;
126
+ }
127
+ output.write(`History cache limit: ${targetMib} MiB${runtime.supported ? " (active now)" : " (saved; restart ccdx to apply)"}\n`);
128
+ return { ...saved, active: runtime.supported };
129
+ }
130
+ if (action === "clean") {
131
+ if (!runtime.supported) throw new Error("Start or restart ccdx before cleaning runtime caches");
132
+ if (history) await confirmHistoryCleanup({ input, output, prompt, yes });
133
+ const result = await cacheRequest(baseUrl, {
134
+ ...requestOptions,
135
+ body: { action: "clean", history },
136
+ });
137
+ const images = result.cleaned.image_optimization;
138
+ output.write(`Cleared image transform cache: ${images.entries} entries, ${mib(images.bytes)}\n`);
139
+ if (history) {
140
+ const cleared = result.cleaned.response_history;
141
+ output.write(`Cleared response history: ${cleared.entries} entries, ${mib(cleared.bytes)}\n`);
142
+ output.write("Saved Codex transcripts and generated image files were not deleted.\n");
143
+ }
144
+ return result;
145
+ }
146
+ throw new Error(`Unknown cache action: ${action}`);
147
+ }
@@ -20,6 +20,7 @@ const HELP_TOPICS = new Set([
20
20
  "status",
21
21
  "models",
22
22
  "usage",
23
+ "cache",
23
24
  "animation",
24
25
  "enable-image",
25
26
  "disable-image",
@@ -155,6 +156,37 @@ export function parseCliArgs(args = []) {
155
156
  const options = parseOptions(rest, new Map([["--format", "value"]]));
156
157
  return baseCommand("usage", outputFormatOption(options["--format"]));
157
158
  }
159
+ if (command === "cache") {
160
+ let cacheArgs = rest;
161
+ if (rest.length === 1 && /^-\d+$/.test(rest[0])) {
162
+ cacheArgs = ["--limit", rest[0].slice(1)];
163
+ } else if (rest[0] === "-clean") {
164
+ cacheArgs = ["--clean", ...rest.slice(1)];
165
+ }
166
+ const options = parseOptions(cacheArgs, new Map([
167
+ ["--limit", "value"],
168
+ ["--clean", "flag"],
169
+ ["--history", "flag"],
170
+ ["--yes", "flag"],
171
+ ]));
172
+ if (options["--limit"] && options["--clean"]) {
173
+ throw new Error("Use --limit or --clean in one cache command, not both");
174
+ }
175
+ if ((options["--history"] || options["--yes"]) && !options["--clean"]) {
176
+ throw new Error("--history and --yes require --clean");
177
+ }
178
+ const rawLimit = options["--limit"];
179
+ if (rawLimit && rawLimit !== "default" && !/^\d+$/.test(rawLimit)) {
180
+ throw new Error(`Cache limit must be an integer MiB value or default: ${rawLimit}`);
181
+ }
182
+ return baseCommand("cache", {
183
+ action: rawLimit ? "limit" : options["--clean"] ? "clean" : "status",
184
+ limitMib: rawLimit && rawLimit !== "default" ? Number(rawLimit) : undefined,
185
+ resetLimit: rawLimit === "default",
186
+ history: Boolean(options["--history"]),
187
+ yes: Boolean(options["--yes"]),
188
+ });
189
+ }
158
190
  if (PM_COMMANDS.has(command)) {
159
191
  return baseCommand("retired", { integration: "PM Studio" });
160
192
  }
@@ -301,6 +333,7 @@ function topicHelp(name, topic) {
301
333
  status: `Usage:\n ${name} status\n\nShows bounded runtime, routing, performance, queue, and cache metrics from a running adapter.`,
302
334
  models: `Usage:\n ${name} models [--format table|plain]\n\nPerforms a fresh, read-only Copilot model-directory lookup for the saved account. Interactive terminals use a table by default.`,
303
335
  usage: `Usage:\n ${name} usage [--format table|plain]\n\nSummarizes local token usage metadata without reading prompt or completion content. Interactive terminals use a table by default.`,
336
+ cache: `Usage:\n ${name} cache\n ${name} cache --limit <MiB|default>\n ${name} cache --clean [--history] [--yes]\n\nShows runtime cache use, saves and applies the shared response-history limit, or clears the rebuildable image-transform cache. --history also clears in-memory response history for all tasks and can make their next continuation fail, so interactive confirmation or --yes is required. Generated image files and saved Codex transcripts are never deleted. Short aliases: -128 sets 128 MiB and -clean equals --clean.`,
304
337
  animation: `Usage:\n ${name} animation\n\nPreviews all terminal activity animations together and selects the theme used the next time the adapter starts.`,
305
338
  "enable-image": `Usage:\n ${name} enable-image\n\nInteractively configures an HTTPS image API endpoint (base URLs are also accepted) and API key, detects its supported image model and protocol, installs CCDX image guidance, and checks the local image tool for Codex App without generating an image. A blank key preserves the saved key only for the same origin; a new origin requires explicit key entry. Concurrent unrelated config edits are preserved.`,
306
339
  "disable-image": `Usage:\n ${name} disable-image\n\nDisables the local CCDX image tool and removes its stored API credential and unmodified CCDX image guidance.`,
@@ -324,6 +357,7 @@ export function cliHelp(commandName = "ccdx", topic = "") {
324
357
  ${name} status
325
358
  ${name} models [--format table|plain]
326
359
  ${name} usage [--format table|plain]
360
+ ${name} cache [--limit <MiB|default> | --clean [--history] [--yes]]
327
361
  ${name} animation
328
362
  ${name} enable-image
329
363
  ${name} disable-image
@@ -340,6 +374,7 @@ Commands:
340
374
  status Show runtime routing, performance, queue, and cache health
341
375
  models Query a saved account's live Copilot model catalog
342
376
  usage Summarize locally recorded token usage
377
+ cache Inspect, size, or clear bounded runtime caches
343
378
  animation Select the terminal activity animation
344
379
  enable-image Configure and enable the optional image provider
345
380
  disable-image Disable the image provider and remove its credential
@@ -4,6 +4,30 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { CODEX_GPT6_MODEL } from "./models.mjs";
6
6
 
7
+ // Compatibility metadata is derived from OpenAI Codex's public models catalog
8
+ // at commit 0a2eb4696c26ac33204bcd255721ab30220a4774. The installed client's
9
+ // GPT-6 Astra entry supplies its own schema and instructions; these overrides
10
+ // describe only the two new model identities and capability differences.
11
+ const GPT6_COMPATIBILITY_SPECS = new Map([
12
+ ["gpt-6-sol", Object.freeze({
13
+ display_name: "GPT-6-Sol",
14
+ description: "Workhorse model for coding and everyday work.",
15
+ default_reasoning_level: "medium",
16
+ shell_type: "shell_command",
17
+ priority: 2,
18
+ node_repl_auto_review_required: true,
19
+ })],
20
+ ["gpt-6-luna", Object.freeze({
21
+ display_name: "GPT-6-Luna",
22
+ description: "Fast and affordable model for easier tasks.",
23
+ default_reasoning_level: "medium",
24
+ shell_type: "shell_command",
25
+ priority: 3,
26
+ node_repl_auto_review_required: false,
27
+ })],
28
+ ]);
29
+ const MANAGED_GPT6_MODELS = new Set([CODEX_GPT6_MODEL, ...GPT6_COMPATIBILITY_SPECS.keys()]);
30
+
7
31
  export { CODEX_GPT6_MODEL };
8
32
 
9
33
  export const CODEX_APP_BINARY_PATHS = [
@@ -50,29 +74,81 @@ function isEligibleOpenAIModel(model, id) {
50
74
  && supportsResponses(model);
51
75
  }
52
76
 
53
- export function isEligibleCopilotGpt6(copilotModels) {
77
+ export function isEligibleCopilotGpt6(copilotModels, id = CODEX_GPT6_MODEL) {
54
78
  const data = copilotModelData(copilotModels);
55
79
  if (!data) return false;
56
- const matches = data.filter((model) => String(model?.id || "").trim() === CODEX_GPT6_MODEL);
57
- return matches.length === 1 && isEligibleOpenAIModel(matches[0], CODEX_GPT6_MODEL);
80
+ const matches = data.filter((model) => String(model?.id || "").trim() === id);
81
+ return matches.length === 1 && isEligibleOpenAIModel(matches[0], id);
82
+ }
83
+
84
+ function copilotModel(copilotModels, id) {
85
+ return copilotModelData(copilotModels)?.find((model) => String(model?.id || "").trim() === id);
86
+ }
87
+
88
+ function advertisedReasoningEfforts(model) {
89
+ const values = model?.capabilities?.supports?.reasoning_effort;
90
+ return new Set(Array.isArray(values) ? values.map((value) => String(value)) : []);
91
+ }
92
+
93
+ function catalogModelForCopilot(model, copilotModels, { filterReasoning = false } = {}) {
94
+ const upstream = copilotModel(copilotModels, model.slug);
95
+ const reasoning = advertisedReasoningEfforts(upstream);
96
+ const patched = {
97
+ ...structuredClone(model),
98
+ visibility: "list",
99
+ supported_reasoning_levels: filterReasoning && reasoning.size
100
+ ? model.supported_reasoning_levels.filter(({ effort }) => reasoning.has(effort))
101
+ : model.supported_reasoning_levels,
102
+ };
103
+ // The Codex catalog describes ChatGPT service tiers. Copilot must advertise
104
+ // an exact fast model before CCDX exposes the corresponding selector.
105
+ if (!isEligibleCopilotGpt6(copilotModels, `${model.slug}-fast`)) {
106
+ patched.additional_speed_tiers = [];
107
+ patched.service_tiers = [];
108
+ delete patched.default_service_tier;
109
+ }
110
+ return patched;
111
+ }
112
+
113
+ function compatibilityModel(codexCatalog, slug) {
114
+ const astra = codexCatalog.models.find((model) => model.slug === CODEX_GPT6_MODEL);
115
+ const spec = GPT6_COMPATIBILITY_SPECS.get(slug);
116
+ if (!astra || !spec) return null;
117
+ return {
118
+ ...structuredClone(astra),
119
+ ...spec,
120
+ slug,
121
+ supports_parallel_tool_calls: true,
122
+ multi_agent_reasoning_effort: null,
123
+ supports_experimental_context: false,
124
+ minimal_client_version: "0.155.0",
125
+ };
126
+ }
127
+
128
+ function insertByPriority(models, model) {
129
+ const priority = Number(model.priority);
130
+ const index = models.findIndex((candidate) => Number.isFinite(priority)
131
+ && Number.isFinite(Number(candidate.priority))
132
+ && Number(candidate.priority) > priority);
133
+ if (index < 0) models.push(model);
134
+ else models.splice(index, 0, model);
58
135
  }
59
136
 
60
137
  export function buildCodexModelResponse({ copilotModels, codexCatalog } = {}) {
61
138
  if (!copilotModelData(copilotModels) || !codexCatalogIsValid(codexCatalog)) return null;
62
139
 
63
- const exposeGpt6 = isEligibleCopilotGpt6(copilotModels);
140
+ const bundledSlugs = new Set(codexCatalog.models.map(({ slug }) => slug));
64
141
  const models = codexCatalog.models.map((model) => {
65
- if (model.slug !== CODEX_GPT6_MODEL) return model;
66
- if (!exposeGpt6) return { ...model, visibility: "hide" };
67
- const patched = {
68
- ...model,
69
- visibility: "list",
70
- additional_speed_tiers: [],
71
- service_tiers: [],
72
- };
73
- delete patched.default_service_tier;
74
- return patched;
142
+ if (!MANAGED_GPT6_MODELS.has(model.slug)) return model;
143
+ if (!isEligibleCopilotGpt6(copilotModels, model.slug)) return { ...model, visibility: "hide" };
144
+ return catalogModelForCopilot(model, copilotModels);
75
145
  });
146
+ for (const slug of GPT6_COMPATIBILITY_SPECS.keys()) {
147
+ if (bundledSlugs.has(slug) || !isEligibleCopilotGpt6(copilotModels, slug)) continue;
148
+ const model = compatibilityModel(codexCatalog, slug);
149
+ if (!model) continue;
150
+ insertByPriority(models, catalogModelForCopilot(model, copilotModels, { filterReasoning: true }));
151
+ }
76
152
 
77
153
  return { ...copilotModels, object: copilotModels.object || "list", models };
78
154
  }
@@ -572,7 +572,15 @@ async function readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes, signal)
572
572
  throw payloadTooLarge("Decoded", maxDecodedBodyBytes);
573
573
  }
574
574
 
575
- const decoder = new TextDecoder();
575
+ // Larger preallocations raise peak RSS; keep the streaming path for those bodies.
576
+ const preallocatedLength = Number.isFinite(contentLength)
577
+ && contentLength >= 1024 * 1024
578
+ && contentLength <= 24 * 1024 * 1024
579
+ ? contentLength
580
+ : 0;
581
+ let allocated = null;
582
+ let overflow = null;
583
+ const decoder = preallocatedLength ? null : new TextDecoder();
576
584
  let text = "";
577
585
  let total = 0;
578
586
  await consumeRequestChunks(req, {
@@ -581,9 +589,23 @@ async function readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes, signal)
581
589
  total += buffer.length;
582
590
  if (total > maxBodyBytes) throw payloadTooLarge("Raw", maxBodyBytes);
583
591
  if (total > maxDecodedBodyBytes) throw payloadTooLarge("Decoded", maxDecodedBodyBytes);
584
- text += decoder.decode(buffer, { stream: true });
592
+ if (preallocatedLength) {
593
+ allocated ||= Buffer.allocUnsafe(preallocatedLength);
594
+ if (!overflow && total <= allocated.length) {
595
+ buffer.copy(allocated, total - buffer.length);
596
+ } else {
597
+ if (!overflow) overflow = [allocated.subarray(0, total - buffer.length)];
598
+ overflow.push(buffer);
599
+ }
600
+ } else {
601
+ text += decoder.decode(buffer, { stream: true });
602
+ }
585
603
  },
586
604
  });
605
+ if (preallocatedLength) {
606
+ if (!allocated) return "";
607
+ return new TextDecoder().decode(overflow ? Buffer.concat(overflow, total) : allocated.subarray(0, total));
608
+ }
587
609
  return text + decoder.decode();
588
610
  }
589
611
 
@@ -159,6 +159,18 @@ export function imageOptimizationStats() {
159
159
  };
160
160
  }
161
161
 
162
+ export function clearImageOptimizationCache() {
163
+ const stats = imageOptimizationStats();
164
+ if (stats.active > 0 || stats.queued > 0 || stats.cache_inflight > 0) {
165
+ const error = new Error("Image optimization is active; retry cache cleanup after current requests finish");
166
+ error.code = "ccdx_cache_busy";
167
+ throw error;
168
+ }
169
+ const removed = { entries: stats.cache_entries, bytes: stats.cache_bytes };
170
+ imageResultCache.clear();
171
+ return removed;
172
+ }
173
+
162
174
  export function resetImageOptimizationCacheForTests() {
163
175
  imageResultCache.clear();
164
176
  }
package/src/models.mjs CHANGED
@@ -26,8 +26,8 @@ export function modelIsResponsesOnly(model) {
26
26
 
27
27
  export function resolveCopilotPriorityTierModel(model, serviceTier, models) {
28
28
  const baseModel = String(model || "").trim();
29
- if (baseModel !== "gpt-5.6-sol" || serviceTier !== "priority") return null;
30
- const fastModelId = "gpt-5.6-sol-fast";
29
+ if (!baseModel.startsWith("gpt-") || serviceTier !== "priority") return null;
30
+ const fastModelId = `${baseModel}-fast`;
31
31
  const fastModel = copilotModelData(models)
32
32
  .find((entry) => String(entry?.id || "").trim() === fastModelId);
33
33
  const vendor = String(fastModel?.vendor || fastModel?.owned_by || "").trim().toLowerCase();
@@ -167,6 +167,7 @@ export function runtimeStatusPayload({
167
167
  const memory = process.memoryUsage();
168
168
  const codexRuntime = safeClientRuntimeStatus(codexClient);
169
169
  const codexModels = modelRegistryStatus(codexModelRegistry || modelRegistry);
170
+ const history = responseHistoryStats();
170
171
  return {
171
172
  ...adapterHealthPayload(),
172
173
  uptime_ms: Math.round(process.uptime() * 1000),
@@ -180,7 +181,7 @@ export function runtimeStatusPayload({
180
181
  requests: metrics?.snapshot?.() || createRequestMetrics().snapshot(),
181
182
  stream_performance: streamPerformance?.snapshot?.() || null,
182
183
  admission: admission?.diagnostics?.() || admission?.stats?.() || null,
183
- response_history: responseHistoryStats(),
184
+ response_history: history,
184
185
  image_optimization: imageOptimizationStats(),
185
186
  image_generation: imageGeneration || null,
186
187
  logging: { usage: usageLoggingStats(), debug: debugLoggingStats() },
@@ -196,8 +197,8 @@ export function runtimeStatusPayload({
196
197
  max_body_bytes: OBSERVABILITY_RUNTIME_CONFIG.maxBodyBytes,
197
198
  max_decoded_body_bytes: OBSERVABILITY_RUNTIME_CONFIG.maxDecodedBodyBytes,
198
199
  max_sse_buffer_bytes: OBSERVABILITY_RUNTIME_CONFIG.maxSseBufferBytes,
199
- response_history_max_bytes: OBSERVABILITY_RUNTIME_CONFIG.responseHistoryMaxBytes,
200
- response_history_max_entries: OBSERVABILITY_RUNTIME_CONFIG.responseHistoryMaxEntries,
200
+ response_history_max_bytes: history.maxBytes,
201
+ response_history_max_entries: history.maxEntries,
201
202
  },
202
203
  };
203
204
  }
@@ -10,8 +10,15 @@ function cloneJson(value) {
10
10
  }
11
11
 
12
12
  function jsonStringByteLength(value) {
13
+ let start = 0;
13
14
  let bytes = 2;
14
- for (let index = 0; index < value.length; index += 1) {
15
+ if (value.length >= 1024) {
16
+ const firstEscape = /["\\\u0000-\u001f\uD800-\uDFFF]/u.exec(value);
17
+ if (!firstEscape) return Buffer.byteLength(value) + 2;
18
+ start = firstEscape.index;
19
+ bytes += Buffer.byteLength(value.slice(0, start));
20
+ }
21
+ for (let index = start; index < value.length; index += 1) {
15
22
  const code = value.charCodeAt(index);
16
23
  if (code === 0x22 || code === 0x5c) {
17
24
  bytes += 2;
@@ -203,24 +210,51 @@ function makeRoomFor({ additionalBytes, additionalEntries, protectedRoots }) {
203
210
  return true;
204
211
  }
205
212
 
206
- export function clearResponseHistoryForTests() {
213
+ export function clearResponseHistory() {
214
+ if (pinnedTrees.size > 0) {
215
+ const error = new Error("Response history is active; retry cleanup after current requests finish");
216
+ error.code = "ccdx_cache_busy";
217
+ throw error;
218
+ }
219
+ const removed = { entries: histories.size, bytes: totalBytes };
207
220
  histories.clear();
208
221
  childrenById.clear();
209
222
  treeLru.clear();
210
223
  pinnedTrees.clear();
211
224
  evictedIds.clear();
212
225
  totalBytes = 0;
226
+ return removed;
227
+ }
228
+
229
+ export function clearResponseHistoryForTests() {
230
+ clearResponseHistory();
213
231
  maxBytes = DEFAULT_MAX_BYTES;
214
232
  maxEntries = DEFAULT_MAX_ENTRIES;
215
233
  }
216
234
 
235
+ export function setResponseHistoryMaxBytes(nextMaxBytes) {
236
+ const parsed = Number(nextMaxBytes);
237
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
238
+ throw new TypeError("Response history cache limit must be a positive safe integer");
239
+ }
240
+ if (parsed < totalBytes) {
241
+ const error = new Error(`Response history currently uses ${totalBytes} bytes, above the requested ${parsed}-byte limit`);
242
+ error.code = "ccdx_cache_limit_below_usage";
243
+ throw error;
244
+ }
245
+ const previousMaxBytes = maxBytes;
246
+ maxBytes = parsed;
247
+ return { previousMaxBytes, maxBytes };
248
+ }
249
+
217
250
  export function configureResponseHistoryForTests({ maxBytes: nextMaxBytes, maxEntries: nextMaxEntries } = {}) {
218
251
  if (nextMaxBytes !== undefined) maxBytes = parsePositiveInteger(nextMaxBytes, DEFAULT_MAX_BYTES);
219
252
  if (nextMaxEntries !== undefined) maxEntries = parsePositiveInteger(nextMaxEntries, DEFAULT_MAX_ENTRIES);
220
253
  }
221
254
 
222
255
  export function responseHistoryStats() {
223
- return { entries: histories.size, bytes: totalBytes, evicted: evictedIds.size };
256
+ return { entries: histories.size, bytes: totalBytes, evicted: evictedIds.size,
257
+ pinnedTrees: pinnedTrees.size, maxBytes, maxEntries };
224
258
  }
225
259
 
226
260
  export function responseHistoryRootId(responseId) {
@@ -89,9 +89,10 @@ function resolveRequestModel(model, openAIModelEnv, autoReviewModelResolver) {
89
89
  return resolveOpenAIModel(model, openAIModelEnv, options);
90
90
  }
91
91
 
92
- function stripUnsupportedGpt6ServiceTier(body, requestedModel, upstreamModel) {
93
- if (requestedModel === CODEX_GPT6_MODEL
92
+ function stripUnsupportedGpt6ServiceTier(body, requestedModel, upstreamModel, priorityTierModel) {
93
+ if (requestedModel.startsWith("gpt-6-")
94
94
  && upstreamModel === requestedModel
95
+ && !priorityTierModel
95
96
  && body.service_tier === "priority") {
96
97
  delete body.service_tier;
97
98
  }
@@ -263,7 +264,7 @@ export function createResponsesHandler(options) {
263
264
  upstreamModel = priorityTierModel;
264
265
  delete prepared.body.service_tier;
265
266
  }
266
- stripUnsupportedGpt6ServiceTier(prepared.body, requestedModel, upstreamModel);
267
+ stripUnsupportedGpt6ServiceTier(prepared.body, requestedModel, upstreamModel, priorityTierModel);
267
268
  if (requestedModel === CODEX_AUTO_REVIEW_MODEL) delete prepared.body.service_tier;
268
269
  if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
269
270
  const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
@@ -516,7 +517,7 @@ export function createResponsesCompactHandler(options) {
516
517
  upstreamModel = priorityTierModel;
517
518
  delete prepared.body.service_tier;
518
519
  }
519
- stripUnsupportedGpt6ServiceTier(prepared.body, requestedModel, upstreamModel);
520
+ stripUnsupportedGpt6ServiceTier(prepared.body, requestedModel, upstreamModel, priorityTierModel);
520
521
  if (requestedModel === CODEX_AUTO_REVIEW_MODEL) delete prepared.body.service_tier;
521
522
  if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
522
523
  const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
@@ -3,9 +3,9 @@ import { localPackageVersion } from "./version.mjs";
3
3
 
4
4
  export const ADAPTER_HEALTH_PATH = "/_ccdx/health";
5
5
  export const ADAPTER_STATUS_PATH = "/_ccdx/status";
6
- export const ADAPTER_PROTOCOL_VERSION = 3;
6
+ export const ADAPTER_PROTOCOL_VERSION = 4;
7
7
  export const ADAPTER_VERSION = localPackageVersion();
8
- export const ADAPTER_CAPABILITIES = Object.freeze([]);
8
+ export const ADAPTER_CAPABILITIES = Object.freeze(["cache-control-v1"]);
9
9
  const ADAPTER_INSTANCE_ID = randomUUID();
10
10
 
11
11
  export function adapterHealthPayload() {
@@ -3,6 +3,13 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
5
5
  import { DEFAULT_CODEX_AUTO_REVIEW_MODEL } from "./models.mjs";
6
+ import { loadRuntimeConfig } from "./runtime-config.mjs";
7
+ import {
8
+ MIB,
9
+ RESPONSE_HISTORY_DEFAULT_MIB,
10
+ RESPONSE_HISTORY_MAX_MIB,
11
+ RESPONSE_HISTORY_MIN_MIB,
12
+ } from "./cache-limits.mjs";
6
13
  import {
7
14
  DEFAULT_TERMINAL_ANIMATION_THEME,
8
15
  isTerminalAnimationTheme,
@@ -11,6 +18,8 @@ import {
11
18
 
12
19
  const AUTO_REVIEW_MODEL_KEY = "auto_review_model";
13
20
  const TERMINAL_ANIMATION_KEY = "terminal_animation";
21
+ const RESPONSE_HISTORY_MAX_MIB_KEY = "response_history_max_mib";
22
+ export { RESPONSE_HISTORY_MAX_MIB, RESPONSE_HISTORY_MIN_MIB };
14
23
 
15
24
  export function userSettingsPath({ env = process.env, home = os.homedir() } = {}) {
16
25
  const xdgConfigHome = String(env.XDG_CONFIG_HOME || "").trim();
@@ -46,6 +55,17 @@ export function readUserSettings({ env = process.env, home = os.homedir(), stric
46
55
  delete parsed[TERMINAL_ANIMATION_KEY];
47
56
  }
48
57
  }
58
+ if (Object.hasOwn(parsed, RESPONSE_HISTORY_MAX_MIB_KEY)) {
59
+ const value = parsed[RESPONSE_HISTORY_MAX_MIB_KEY];
60
+ if (!Number.isSafeInteger(value)
61
+ || value < RESPONSE_HISTORY_MIN_MIB
62
+ || value > RESPONSE_HISTORY_MAX_MIB) {
63
+ if (strict) {
64
+ throw invalidSettings(filePath, `${RESPONSE_HISTORY_MAX_MIB_KEY} must be an integer from ${RESPONSE_HISTORY_MIN_MIB} to ${RESPONSE_HISTORY_MAX_MIB}`);
65
+ }
66
+ delete parsed[RESPONSE_HISTORY_MAX_MIB_KEY];
67
+ }
68
+ }
49
69
  return parsed;
50
70
  } catch (error) {
51
71
  if (error?.code === "ENOENT") return {};
@@ -83,6 +103,39 @@ export function terminalAnimationPreference({ env = process.env, home = os.homed
83
103
  return { theme: DEFAULT_TERMINAL_ANIMATION_THEME, source: "default" };
84
104
  }
85
105
 
106
+ export function responseHistoryLimitPreference({ env = process.env, home = os.homedir() } = {}) {
107
+ const environment = String(env.CCDX_RESPONSE_HISTORY_MAX_BYTES || "").trim();
108
+ if (environment) {
109
+ return { bytes: loadRuntimeConfig(env).responseHistoryMaxBytes, source: "environment" };
110
+ }
111
+ const settings = readUserSettings({ env, home });
112
+ const mib = settings[RESPONSE_HISTORY_MAX_MIB_KEY];
113
+ if (Number.isSafeInteger(mib)) return { bytes: mib * MIB, mib, source: "settings" };
114
+ return { bytes: RESPONSE_HISTORY_DEFAULT_MIB * MIB, mib: RESPONSE_HISTORY_DEFAULT_MIB, source: "default" };
115
+ }
116
+
117
+ export function writeResponseHistoryLimitMib(mib, { env = process.env, home = os.homedir() } = {}) {
118
+ const filePath = userSettingsPath({ env, home });
119
+ const settings = readUserSettings({ env, home, strict: true });
120
+ const next = { ...settings };
121
+ if (mib === null || mib === undefined || mib === "") {
122
+ if (!Object.hasOwn(next, RESPONSE_HISTORY_MAX_MIB_KEY)) {
123
+ return { changed: false, filePath, ...responseHistoryLimitPreference({ env, home }) };
124
+ }
125
+ delete next[RESPONSE_HISTORY_MAX_MIB_KEY];
126
+ } else {
127
+ const parsed = Number(mib);
128
+ if (!Number.isSafeInteger(parsed)
129
+ || parsed < RESPONSE_HISTORY_MIN_MIB
130
+ || parsed > RESPONSE_HISTORY_MAX_MIB) {
131
+ throw new Error(`History cache limit must be an integer from ${RESPONSE_HISTORY_MIN_MIB} to ${RESPONSE_HISTORY_MAX_MIB} MiB`);
132
+ }
133
+ next[RESPONSE_HISTORY_MAX_MIB_KEY] = parsed;
134
+ }
135
+ const changed = atomicWriteFileIfChangedSync(filePath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
136
+ return { changed, filePath, ...responseHistoryLimitPreference({ env, home }) };
137
+ }
138
+
86
139
  export function writeAutoReviewModel(model, { env = process.env, home = os.homedir() } = {}) {
87
140
  const filePath = userSettingsPath({ env, home });
88
141
  const settings = readUserSettings({ env, home, strict: true });