codex-copilot-dx 0.9.19 → 0.9.21
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 +3 -0
- package/bin/cli.mjs +6 -2
- package/docs/README.md +2 -0
- package/docs/REFERENCE.md +29 -2
- package/package.json +1 -1
- package/src/cli-image.mjs +4 -1
- package/src/cli-options.mjs +1 -1
- package/src/cli-status.mjs +1 -1
- package/src/codex-model-catalog.mjs +10 -0
- package/src/codex-thread-compression.mjs +90 -0
- package/src/dashboard/index.html +2 -0
- package/src/dashboard/language.js +4 -0
- package/src/dashboard/ui.css +2 -0
- package/src/dashboard/ui.js +5 -0
- package/src/dashboard-api.mjs +6 -3
- package/src/image-readiness.mjs +1 -1
- package/src/responses-proxy.mjs +8 -1
- package/src/stream-performance.mjs +47 -4
- package/src/usage-analysis.mjs +45 -0
- package/src/usage-store.mjs +1 -1
- package/src/usage.mjs +1 -0
package/README.md
CHANGED
|
@@ -62,6 +62,9 @@ The dashboard does not poll in the background; refresh when needed.
|
|
|
62
62
|
break continuations. Generated images and Codex transcripts are not deleted.
|
|
63
63
|
- Optional image setup/maintenance never blocks the primary service. Disabled
|
|
64
64
|
users get no CCDX image skill or MCP installation.
|
|
65
|
+
- CCDX adds experimental local history compression only when installed App
|
|
66
|
+
runtimes support it and report it off. Existing choices are preserved; an
|
|
67
|
+
already-enabled default, unavailable runtime or failed check is skipped.
|
|
65
68
|
- Image API redirects must stay on the same HTTPS origin. Returned images must
|
|
66
69
|
use public HTTPS URLs; well-known NAT64 mappings follow IPv4 address rules.
|
|
67
70
|
- Keep the adapter on loopback. LAN mode is only for trusted networks; do not
|
package/bin/cli.mjs
CHANGED
|
@@ -24,6 +24,7 @@ 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
26
|
import { setResponseHistoryMaxBytes } from "../src/response-history.mjs";
|
|
27
|
+
import { ensureCodexThreadCompression } from "../src/codex-thread-compression.mjs";
|
|
27
28
|
|
|
28
29
|
const LOCAL_VERSION = localPackageVersion();
|
|
29
30
|
const CLI_NAME = cliCommandName();
|
|
@@ -259,7 +260,7 @@ async function reuseRunningAdapterIfAvailable() {
|
|
|
259
260
|
if (!running.ok) return false;
|
|
260
261
|
|
|
261
262
|
console.log(status("ok", `Using existing adapter at ${running.baseUrl}`));
|
|
262
|
-
await configureCodexClient();
|
|
263
|
+
const { compressionSetup } = await configureCodexClient();
|
|
263
264
|
|
|
264
265
|
await openCodex();
|
|
265
266
|
console.log(`
|
|
@@ -269,6 +270,7 @@ async function reuseRunningAdapterIfAvailable() {
|
|
|
269
270
|
|
|
270
271
|
Tip: Run ${CLI_NAME} help to view more features and commands.
|
|
271
272
|
`);
|
|
273
|
+
await compressionSetup;
|
|
272
274
|
return true;
|
|
273
275
|
}
|
|
274
276
|
|
|
@@ -283,6 +285,7 @@ async function configureCodexClient() {
|
|
|
283
285
|
console.warn(status("warn", `Image guidance could not be updated: ${error.message}`));
|
|
284
286
|
}
|
|
285
287
|
}
|
|
288
|
+
return { compressionSetup: runInBackground(ensureCodexThreadCompression) };
|
|
286
289
|
}
|
|
287
290
|
|
|
288
291
|
if (CLI.command === "doctor") {
|
|
@@ -359,7 +362,7 @@ try {
|
|
|
359
362
|
});
|
|
360
363
|
|
|
361
364
|
// Point Codex App at the adapter.
|
|
362
|
-
await configureCodexClient();
|
|
365
|
+
const { compressionSetup } = await configureCodexClient();
|
|
363
366
|
|
|
364
367
|
// Launch Codex when available.
|
|
365
368
|
await openCodex();
|
|
@@ -404,6 +407,7 @@ try {
|
|
|
404
407
|
};
|
|
405
408
|
process.once("SIGINT", () => { void shutdown("SIGINT"); });
|
|
406
409
|
process.once("SIGTERM", () => { void shutdown("SIGTERM"); });
|
|
410
|
+
await compressionSetup;
|
|
407
411
|
} catch (e) {
|
|
408
412
|
console.error(status("err", e.message));
|
|
409
413
|
await closeHttpServer(activeServer, { timeoutMs: RUNTIME.shutdownTimeoutMs }).catch(() => {});
|
package/docs/README.md
CHANGED
|
@@ -16,6 +16,8 @@ test counts, timings, live-provider observations, and limitations remain
|
|
|
16
16
|
historical facts rather than current guarantees.
|
|
17
17
|
|
|
18
18
|
- [0.9.19: GitHub startup checks and stable-release updates](RELEASE_0.9.19.md)
|
|
19
|
+
- [0.9.21: compatibility-gated local history compression](RELEASE_0.9.21.md)
|
|
20
|
+
- [0.9.20: timing evidence, real-client checks and bounded analytics reuse](RELEASE_0.9.20.md)
|
|
19
21
|
- [0.9.13: model-bound Auto-review reasoning selection](RELEASE_0.9.13.md)
|
|
20
22
|
- [0.9.12: GPT-6.1 Sol / low Auto-review default](RELEASE_0.9.12.md)
|
|
21
23
|
- [0.9.11: bounded usage-summary cache and diagnostic redaction](RELEASE_0.9.11.md)
|
package/docs/REFERENCE.md
CHANGED
|
@@ -65,6 +65,10 @@ The `OPENAI_API_KEY=dummy` value used in the Codex shell environment is only a c
|
|
|
65
65
|
|
|
66
66
|
On startup, CCDX also adds `context_management = true` under `[features]` when the key is missing, creating the section if needed. Existing values, including `false`, are preserved. Existing inline or dotted `features` declarations and nested context-management settings are left intact to avoid conflicting TOML definitions. This opts unconfigured users into Codex context management; support and behavior depend on the installed Codex version. A configuration that already matches is not rewritten.
|
|
67
67
|
|
|
68
|
+
Startup and adapter reuse also attempt an optional `local_thread_store_compression = true` default under `[features]`. CCDX probes installed macOS App runtimes with `features list`, using an empty temporary Codex directory, no inherited credentials/provider overrides, a 1.5-second timeout per runtime and a 128 KiB output limit. All discovered App readers must advertise a supported, disabled feature; missing/removed/unknown/default-on features or failed probes skip setup. Existing user settings (including `false`, inline/dotted forms and legacy profile overrides) are preserved without probing. Alternative feature-table declarations that cannot be safely extended are left intact. The shared config is reread after probing, and only this key can change. Temporary probe directories are removed; no user transcript is read or compressed by CCDX itself, and no upstream or npm lookup is added. Ready and core requests do not wait for this optional task; its failures never abort the main service. On a first launch, the App may read the config before this background task finishes; the setting is available on its next config load/restart.
|
|
69
|
+
|
|
70
|
+
This remains an experimental Codex storage feature. Current Codex compresses cold local transcript files, including shared histories, with Zstandard; every external reader of that Codex directory must also support the format. It does not reduce model context, request-body limits or CCDX's in-memory history cache, and CCDX does not promise faster inference. Existing third-party transcript readers are not detected. `ccdx doctor config` remains a config-only preview of essential startup settings; it does not spawn runtimes or predict this optional compatibility-gated default.
|
|
71
|
+
|
|
68
72
|
If a compatible adapter is already running, a later launch reuses it and checks the local Codex configuration. Reuse requires the same package version, protocol version, and required capabilities. After updating the package, stop the old adapter before starting the new version. Installing or updating the package alone does not restart the adapter or update Codex configuration.
|
|
69
73
|
|
|
70
74
|
### Codex configuration writes
|
|
@@ -78,6 +82,7 @@ Normal startup and reuse of an existing adapter check `~/.codex/config.toml` onc
|
|
|
78
82
|
| `model_auto_compact_token_limit` | Add `900000` only when missing | `900000` |
|
|
79
83
|
| `[shell_environment_policy.set]` OpenAI URL/key | Update `OPENAI_BASE_URL` and set `OPENAI_API_KEY="dummy"` when this section is recognized; leave an absent section absent | Create the section and set both keys; set `shell_environment_policy.inherit="core"` |
|
|
80
84
|
| `[features]` `context_management` | Add `true` when missing; preserve explicit values and alternative declarations as described above | `true` |
|
|
85
|
+
| `[features]` `local_thread_store_compression` | Optional compatibility-gated default; preserve existing values, skip already-on/unsupported/unavailable runtimes | `true` only after a successful probe |
|
|
81
86
|
| `[mcp_servers.ccdx_image]` | Added in a marked CCDX-owned block only after `ccdx enable-image`; removed by `ccdx disable-image` | Absent by default |
|
|
82
87
|
|
|
83
88
|
The two token limits are fixed defaults, not detected model capabilities. Existing custom values are retained. Writes target the listed keys with format-preserving edits and atomic replacement; this is not a general TOML formatter. Quoted keys, quoted table headers, and explicit dotted shell-environment declarations are recognized; inline shell-environment tables are left intact. Startup and image configuration commands validate UTF-8/TOML and verify that unrelated settings retain their values before writing. An unsafe edit leaves the original file unchanged. Use `ccdx doctor config` to inspect parsed values and preview what this writer would do with the current file. No actual change means no file rewrite.
|
|
@@ -201,7 +206,7 @@ ccdx image-status
|
|
|
201
206
|
ccdx disable-image
|
|
202
207
|
```
|
|
203
208
|
|
|
204
|
-
`image-status` never prints the API key and performs only a local, non-generating tool-readiness check. Disabling removes the stored provider credential, the CCDX-owned MCP block, and unmodified skill, reference, and helper files; user-modified files are preserved. Cached tool calls then fail closed. The Node helper creates a new file under the task's `output/imagegen` directory by default and reports its absolute path; it never overwrites an existing file or automatically repeats a failed generation. The image endpoint is contacted only by `enable-image` validation and by explicit `generate_image` or `edit_image` tool calls. Ordinary Responses, models, authentication, Fast, Auto-review, compaction, and image-input paths remain on their existing routes.
|
|
209
|
+
`image-status` never prints the API key and performs only a local, non-generating tool-readiness check. It lists generation and editing separately based on the local tools/list response; a configured model name alone does not prove editing is available. Disabling removes the stored provider credential, the CCDX-owned MCP block, and unmodified skill, reference, and helper files; user-modified files are preserved. Cached tool calls then fail closed. The Node helper creates a new file under the task's `output/imagegen` directory by default and reports its absolute path; it never overwrites an existing file or automatically repeats a failed generation. The image endpoint is contacted only by `enable-image` validation and by explicit `generate_image` or `edit_image` tool calls. Ordinary Responses, models, authentication, Fast, Auto-review, compaction, and image-input paths remain on their existing routes.
|
|
205
210
|
|
|
206
211
|
Generated image downloads must use public HTTPS addresses, do not follow redirects, are DNS-checked against private and reserved addresses, and are capped at 32 MiB. Well-known NAT64 `64:ff9b::/96` addresses apply the same IPv4 public/private rules to the embedded address, without rejecting legitimate public translations. DNS resolution and downloading share one deadline and respond to cancellation; a late DNS result cannot start a cancelled download. The local MCP route accepts calls only from the same device and permits at most two concurrent image operations. Host must name loopback, the actual local socket address (including equivalent IPv6/IPv4-mapped forms), or the explicitly configured bind hostname. Missing Host remains compatible with HTTP/1.0 and in-process clients. It requires `application/json` and rejects browser `Origin` headers; native Codex and Node clients do not send an Origin. The tool supports new-image generation at `1024x1024`, `1536x1024`, or `1024x1536`.
|
|
207
212
|
|
|
@@ -240,6 +245,28 @@ If a configured npm mirror has not synchronized the current release yet, use the
|
|
|
240
245
|
|
|
241
246
|
## Diagnostics
|
|
242
247
|
|
|
248
|
+
`ccdx status` and the dashboard show the sample count beside each timing. First
|
|
249
|
+
output is observed at the gateway from generated deltas, hidden-reasoning/tool
|
|
250
|
+
announcements or content snapshots; a runtime-only result is marked separately.
|
|
251
|
+
Empty message/envelope frames, client tool results and compaction are excluded.
|
|
252
|
+
`first_output_sources` and each recent request's `first_output_source` describe
|
|
253
|
+
that evidence, not internal model decode or prefill timing. Per-token time is a
|
|
254
|
+
gateway estimate and can include hosted-tool and downstream delays. The new
|
|
255
|
+
timing classifier does not change stream forwarding or the existing output
|
|
256
|
+
commitment that gates compatibility retries.
|
|
257
|
+
|
|
258
|
+
The adapted Codex model catalog preserves valid multi-agent reasoning mappings.
|
|
259
|
+
Only a mapping no longer supported by the final advertised effort options is
|
|
260
|
+
repaired to the last available non-Ultra effort; no model switch or forced
|
|
261
|
+
highest-effort policy is introduced. Client-provided defaults, option order,
|
|
262
|
+
instructions and multi-agent version stay intact.
|
|
263
|
+
|
|
264
|
+
CI additionally provisions the latest official Codex on its runner and uses an
|
|
265
|
+
isolated app-server to verify configuration, its model list and synthetic direct
|
|
266
|
+
and proxied Responses message delivery. It logs the tested runtime version,
|
|
267
|
+
does not skip installed-version mismatches and does not install clients on user
|
|
268
|
+
machines or use live credentials/models.
|
|
269
|
+
|
|
243
270
|
`ccdx doctor` also inspects installed macOS App version metadata and running
|
|
244
271
|
Codex/ChatGPT App executable identities, without reading command arguments,
|
|
245
272
|
changing client files or killing processes. It shows the running adapter version,
|
|
@@ -432,7 +459,7 @@ The summary covers the current log and its single rotated backup, not an unbound
|
|
|
432
459
|
|
|
433
460
|
Usage writes are asynchronous and batched under the existing cross-process rotation lock. Pending and in-flight records share a 4096-record / 4 MiB ceiling. If either ceiling is reached, new excess records are dropped with a warning and a status counter; inference does not wait or retry. Normal records and per-record rotation boundaries are unchanged. The bounded shutdown flush remains 1.5 seconds; disk failures and shutdown deadlines can prevent persistence, so usage is diagnostic rather than a billing ledger.
|
|
434
461
|
|
|
435
|
-
Repeated plain usage summaries in the same process reuse one bounded cache entry when both retained log files are unchanged. File identity, size, modification/change timestamps and a two-second recent-write guard control reuse; concurrent settled-file queries share one scan. The serialized summary budget is 256 KiB and at most 128 model groups; larger results remain complete but uncached. Warnings, unstable reads, metadata errors and scans with changed files are not retained. Each caller receives an independent result. Analytics record callbacks and custom-warning reads keep the original streaming path. There is no persistent index, background polling, cross-process cache or change to usage writes;
|
|
462
|
+
Repeated plain usage summaries in the same process reuse one bounded cache entry when both retained log files are unchanged. File identity, size, modification/change timestamps and a two-second recent-write guard control reuse; concurrent settled-file queries share one scan. The serialized summary budget is 256 KiB and at most 128 model groups; larger results remain complete but uncached. Warnings, unstable reads, metadata errors and scans with changed files are not retained. Each caller receives an independent result. Analytics record callbacks and custom-warning reads keep the original streaming path. There is no persistent index, background polling, cross-process cache or change to usage writes; the first analytics read still scans the retained records. Dashboard analytics then reuses up to two settled aggregate snapshots within a combined 2 MiB serialized budget, keyed by file identity/version, time zone and local date. Concurrent identical reads share one scan; writes, rotation, truncation, replacement, warnings, future/undated records and oversized results prevent reuse. Returned data is cloned, and custom callbacks/warnings keep the uncached path.
|
|
436
463
|
|
|
437
464
|
Diagnostic redaction removes Basic authorization payloads and URL userinfo, and consumes escaped quotes inside sensitive JSON fields. Ordinary Basic-related prose, safe host/path information and error codes are retained. These protections apply to existing failure/status/debug diagnostics, not forwarded requests or upstream response bodies. They do not make arbitrary upstream prose guaranteed safe to share.
|
|
438
465
|
|
package/package.json
CHANGED
package/src/cli-image.mjs
CHANGED
|
@@ -159,7 +159,10 @@ export function syncEnabledImageSkill({ home = os.homedir(), codexPath = default
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
function printImageReadiness(readiness, output, commandName) {
|
|
162
|
-
if (readiness.ready)
|
|
162
|
+
if (readiness.ready) {
|
|
163
|
+
output.write("Local image service: ready (generation tool verified; no image generated).\n");
|
|
164
|
+
output.write(`Generate: available\nEdit: ${readiness.editing ? "available" : "not advertised"}\n`);
|
|
165
|
+
}
|
|
163
166
|
else output.write(`Local image service: unavailable (${readiness.reason}). Start or restart ${commandName} to use images.\n`);
|
|
164
167
|
}
|
|
165
168
|
|
package/src/cli-options.mjs
CHANGED
|
@@ -354,7 +354,7 @@ function topicHelp(name, topic) {
|
|
|
354
354
|
animation: `Usage:\n ${name} animation\n\nPreviews all terminal activity animations together and selects the theme used the next time the adapter starts.`,
|
|
355
355
|
"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.`,
|
|
356
356
|
"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.`,
|
|
357
|
-
"image-status": `Usage:\n ${name} image-status\n\nShows provider configuration and
|
|
357
|
+
"image-status": `Usage:\n ${name} image-status\n\nShows provider configuration and locally advertised generation/editing tools without generating an image or exposing its API key.`,
|
|
358
358
|
"auto-review-model": `Usage:\n ${name} auto-review-model\n\nSelect a Responses model, then an advertised reasoning effort. Enter keeps the default/current choice; q cancels both steps without saving. Effort 0 follows the client (low when omitted). Settings apply only to Auto-review on the next request; no client patch or restart is required for adapters running 0.9.13 or newer.`,
|
|
359
359
|
update: `Usage:\n ${name} update [npm|github]\n\nUpdates the global package from the configured npm registry or the latest stable GitHub release. With no source, an interactive terminal prompts for one.`,
|
|
360
360
|
version: `Usage:\n ${name} --version`,
|
package/src/cli-status.mjs
CHANGED
|
@@ -150,7 +150,7 @@ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", c
|
|
|
150
150
|
`${commandName} status`,
|
|
151
151
|
adapterLine,
|
|
152
152
|
status("info", `Requests: ${count(requests.completed)}/${count(requests.total)} completed, ${count(requests.active)} active, ${count(requests.status_4xx)} 4xx, ${count(requests.status_5xx)} 5xx, ${count(requests.aborted)} aborted`),
|
|
153
|
-
status("info", `Responses stream: TTFT avg ${latency(performance.ttft_ms?.avg)} (${count(performance.ttft_ms?.samples)} samples), TPOT avg ${tpot(performance.tpot_us)}`),
|
|
153
|
+
status("info", `Responses stream: TTFT avg ${latency(performance.ttft_ms?.avg)} (${count(performance.ttft_ms?.samples)} samples), TPOT avg ${tpot(performance.tpot_us)} (${count(performance.tpot_us?.samples)} samples; gateway estimate)`),
|
|
154
154
|
...(terminalOutcomes ? [status("info", `Model outcomes: ${count(terminalOutcomes.completed)} completed, ${count(terminalOutcomes.incomplete)} incomplete, ${count(terminalOutcomes.failed)} failed, ${count(terminalOutcomes.cancelled)} cancelled, ${count(terminalOutcomes.unknown)} without terminal`)] : []),
|
|
155
155
|
status("info", `Admission: ${count(admission.activeRequests)} active, ${count(admission.queued)} queued, ${count(admission.rejected)} rejected, ${count(admission.timedOut)} timed out, wait avg ${latency(admission.waitMsAvg)}`),
|
|
156
156
|
status("info", `Memory: RSS ${mebibytes(processStats.rss_bytes)}, heap ${mebibytes(processStats.heap_used_bytes)}`),
|
|
@@ -110,6 +110,16 @@ function catalogModelForCopilot(model, copilotModels, { filterReasoning = false
|
|
|
110
110
|
? model.supported_reasoning_levels.filter(({ effort }) => reasoning.has(effort))
|
|
111
111
|
: model.supported_reasoning_levels,
|
|
112
112
|
};
|
|
113
|
+
// Only repair a supplied mapping that no longer names an advertised wire effort.
|
|
114
|
+
const levels = patched.supported_reasoning_levels;
|
|
115
|
+
if (Array.isArray(levels) && patched.multi_agent_reasoning_effort != null
|
|
116
|
+
&& (!levels.some(level => level.effort === patched.multi_agent_reasoning_effort)
|
|
117
|
+
|| (reasoning.size && !reasoning.has(patched.multi_agent_reasoning_effort)))) {
|
|
118
|
+
const supported = levels.filter(level => level.effort !== "ultra"
|
|
119
|
+
&& (!reasoning.size || reasoning.has(level.effort)));
|
|
120
|
+
const effort = supported.at(-1)?.effort;
|
|
121
|
+
if (effort) patched.multi_agent_reasoning_effort = effort;
|
|
122
|
+
}
|
|
113
123
|
// The Codex catalog describes ChatGPT service tiers. Copilot must advertise
|
|
114
124
|
// an exact fast model before CCDX exposes the corresponding selector.
|
|
115
125
|
if (!isEligibleCopilotGpt6(copilotModels, `${model.slug}-fast`)) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { CODEX_APP_BINARY_PATHS } from "./codex-model-catalog.mjs";
|
|
7
|
+
import { parseCodexToml, codexTomlStatements, validateManagedConfigEdit } from "./config-toml.mjs";
|
|
8
|
+
import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
|
|
9
|
+
import { status } from "./status.mjs";
|
|
10
|
+
|
|
11
|
+
const FEATURE = "local_thread_store_compression";
|
|
12
|
+
const FEATURE_PATH = ["features", FEATURE];
|
|
13
|
+
|
|
14
|
+
function configured(config) {
|
|
15
|
+
if (Object.hasOwn(config, FEATURE) || Object.hasOwn(config.features || {}, FEATURE)) return true;
|
|
16
|
+
return Object.values(config.profiles || {}).some(profile => Object.hasOwn(profile?.features || {}, FEATURE));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function computeThreadCompressionDefault(content) {
|
|
20
|
+
const config = parseCodexToml(content);
|
|
21
|
+
if (configured(config)) return { content, changed: false };
|
|
22
|
+
const statements = codexTomlStatements(content);
|
|
23
|
+
// Only append to an explicit features table or create an absent one. Preserve
|
|
24
|
+
// inline/dotted/nested declarations rather than risking a conflicting table.
|
|
25
|
+
const table = statements.find(item => item.kind === "table"
|
|
26
|
+
&& item.keys.length === 1 && item.keys[0] === "features");
|
|
27
|
+
if (!table && Object.hasOwn(config, "features")) return { content, changed: false };
|
|
28
|
+
const lines = content.split("\n");
|
|
29
|
+
if (table) {
|
|
30
|
+
const end = statements.find(item => item.kind === "table" && item.start > table.start)?.line ?? lines.length;
|
|
31
|
+
lines.splice(end, 0, `${FEATURE} = true`);
|
|
32
|
+
} else {
|
|
33
|
+
while (lines.length && !lines.at(-1).trim()) lines.pop();
|
|
34
|
+
if (lines.length) lines.push("");
|
|
35
|
+
lines.push("[features]", `${FEATURE} = true`, "");
|
|
36
|
+
}
|
|
37
|
+
const joined = lines.join("\n");
|
|
38
|
+
const updated = joined + (content.endsWith("\n") && !joined.endsWith("\n") ? "\n" : "");
|
|
39
|
+
validateManagedConfigEdit(config, updated, [FEATURE_PATH]);
|
|
40
|
+
return { content: updated, changed: true };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function codexNeedsThreadCompressionDefault({
|
|
44
|
+
binaryPaths = CODEX_APP_BINARY_PATHS,
|
|
45
|
+
execFileFn = promisify(execFile),
|
|
46
|
+
} = {}) {
|
|
47
|
+
const binaries = new Set();
|
|
48
|
+
for (const binary of binaryPaths) {
|
|
49
|
+
try {
|
|
50
|
+
if (path.isAbsolute(binary) && fs.statSync(binary).isFile()) binaries.add(fs.realpathSync(binary));
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
if (!binaries.size) return false;
|
|
54
|
+
const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "ccdx-compression-probe-"));
|
|
55
|
+
try {
|
|
56
|
+
const defaults = await Promise.all([...binaries].map(async binary => {
|
|
57
|
+
try {
|
|
58
|
+
const { stdout } = await execFileFn(binary, ["features", "list"], {
|
|
59
|
+
cwd: home,
|
|
60
|
+
env: { CODEX_HOME: home, PATH: [path.dirname(process.execPath), "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(path.delimiter) },
|
|
61
|
+
encoding: "utf8", timeout: 1500, maxBuffer: 128 * 1024, windowsHide: true,
|
|
62
|
+
});
|
|
63
|
+
const rows = String(stdout).split("\n").filter(line => line.trim().split(/\s+/)[0] === FEATURE);
|
|
64
|
+
return rows.length === 1
|
|
65
|
+
&& /^local_thread_store_compression\s+(?:under development|experimental|beta|stable)\s+false\s*$/i.test(rows[0].trim());
|
|
66
|
+
} catch { return false; }
|
|
67
|
+
}));
|
|
68
|
+
return defaults.every(Boolean);
|
|
69
|
+
} finally { await fs.promises.rm(home, { recursive: true, force: true }); }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function ensureCodexThreadCompression({
|
|
73
|
+
filePath = path.join(os.homedir(), ".codex", "config.toml"),
|
|
74
|
+
probe = codexNeedsThreadCompressionDefault,
|
|
75
|
+
} = {}) {
|
|
76
|
+
try {
|
|
77
|
+
const read = () => new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(filePath));
|
|
78
|
+
if (configured(parseCodexToml(read()))) return;
|
|
79
|
+
if (!await probe()) return;
|
|
80
|
+
// The client/user may edit the shared file while the asynchronous probe runs.
|
|
81
|
+
// Recompute against its current bytes; explicit opt-outs always win.
|
|
82
|
+
const updated = computeThreadCompressionDefault(read());
|
|
83
|
+
if (updated.changed) {
|
|
84
|
+
atomicWriteFileIfChangedSync(filePath, updated.content);
|
|
85
|
+
console.log(status("ok", "Enabled Codex local history compression (experimental)"));
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
console.warn(status("warn", "Optional Codex history compression setup was skipped; core proxy remains available"));
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/dashboard/index.html
CHANGED
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
<div class="panel-heading"><h2 data-i18n>MODEL OUTCOMES</h2><span>RESPONSES</span></div>
|
|
37
37
|
<dl class="stats"><div><dt data-i18n>Completed</dt><dd id="completed">—</dd></div><div><dt data-i18n>Incomplete</dt><dd id="incomplete">—</dd></div><div><dt data-i18n>Failed</dt><dd id="failed">—</dd></div><div><dt data-i18n>Cancelled</dt><dd id="cancelled">—</dd></div></dl>
|
|
38
38
|
<p class="hint" data-i18n>HTTP 2xx != model completed.</p>
|
|
39
|
+
<dl class="stats timing-stats"><div><dt data-i18n>First output</dt><dd id="ttft">—</dd></div><div><dt data-i18n>Token time</dt><dd id="tpot">—</dd></div></dl>
|
|
40
|
+
<p class="hint" data-i18n>Gateway timing. Token time is estimated.</p>
|
|
39
41
|
</article>
|
|
40
42
|
<article class="panel">
|
|
41
43
|
<div class="panel-heading"><h2 data-i18n>LOCAL HISTORY</h2><span data-i18n>ACTIVE LIMIT</span></div>
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
(() => {
|
|
2
2
|
const translations = {
|
|
3
|
+
"First output": ["First output", "首次输出"],
|
|
4
|
+
"Token time": ["Token time", "输出耗时"],
|
|
5
|
+
"Gateway timing. Token time is estimated.": ["Observed at the gateway. Token time is an estimate.", "网关观测时间。\n每Token耗时为估算值。"],
|
|
6
|
+
"{value} / {samples} samples": ["{value} / {samples} samples", "{value} / {samples}次样本"],
|
|
3
7
|
"CCDX · Local dashboard": ["CCDX · Dashboard", "CCDX · 运行看板"],
|
|
4
8
|
"/ LOCAL STATUS": ["/ DASHBOARD", "/ 运行看板"],
|
|
5
9
|
"READING": ["READING", "读取中"],
|
package/src/dashboard/ui.css
CHANGED
|
@@ -134,3 +134,5 @@ footer { color: var(--footer); font-size: 10px; letter-spacing: .05em; padding:
|
|
|
134
134
|
@media (max-width: 620px) { .snapshot-meta { justify-content: flex-start; margin-top: 9px; } }
|
|
135
135
|
@media (max-width: 620px) { .shell { padding: 0 16px; } .topbar { min-height: 60px; } .brand-detail { display: none; } .topbar-actions { gap: 11px; } .intro { display: block; padding: 29px 0 21px; } .intro p { margin-top: 9px; } .overview, .details { grid-template-columns: 1fr; } .details .panel:last-child { grid-column: auto; } .card { min-height: 122px; } .card strong { margin-top: 14px; } .lower li { display: block; } .lower li span { display: block; margin-top: 5px; } }
|
|
136
136
|
@media (max-width: 460px) { .topbar { flex-wrap: wrap; padding: 12px 0; gap: 10px; } .topbar-actions { gap: 9px; } .panel-heading { flex-wrap: wrap; } .heading-actions { gap: 10px; } .failure-detail { grid-template-columns: 1fr; gap: 3px; } .failure-detail dd { margin-bottom: 7px; } select { max-width: 210px; } }
|
|
137
|
+
.timing-stats { grid-template-columns: 1fr; }
|
|
138
|
+
.timing-stats dt { white-space: nowrap; }
|
package/src/dashboard/ui.js
CHANGED
|
@@ -147,6 +147,11 @@ function render(data) {
|
|
|
147
147
|
|
|
148
148
|
const outcomes = data.stream_performance?.by_route?.responses?.terminal_outcomes?.totals || {};
|
|
149
149
|
for (const key of ["completed", "incomplete", "failed", "cancelled"]) text(key, number(outcomes[key]));
|
|
150
|
+
const timing = data.stream_performance?.by_route?.responses || {};
|
|
151
|
+
for (const [id, metric, scale, unit] of [["ttft", timing.ttft_ms, 1, "ms"], ["tpot", timing.tpot_us, timing.tpot_us?.unit === "us" ? 1000 : 1, "ms/token"]]) {
|
|
152
|
+
const value = metric?.samples > 0 && finite(metric.avg) ? `${(Number(metric.avg) / scale).toFixed(1)} ${unit}` : "—";
|
|
153
|
+
text(id, "{value} / {samples} samples", { value, samples: number(metric?.samples) });
|
|
154
|
+
}
|
|
150
155
|
|
|
151
156
|
const history = data.response_history || {};
|
|
152
157
|
const limit = Number(history.maxBytes || data.limits?.response_history_max_bytes || 0);
|
package/src/dashboard-api.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "./terminal-animation.mjs";
|
|
14
14
|
import { cacheReadTokens, summarizeUsageLogs, usageCacheHitRate } from "./usage-store.mjs";
|
|
15
15
|
import { createUsageAnalytics } from "./usage-analytics.mjs";
|
|
16
|
+
import { analyzeUsageLogs } from "./usage-analysis.mjs";
|
|
16
17
|
import { readUserSettings, terminalAnimationPreference, writeTerminalAnimationTheme } from "./user-settings.mjs";
|
|
17
18
|
|
|
18
19
|
const ANIMATION_PATH = "/_ccdx/ui/animation";
|
|
@@ -111,7 +112,7 @@ export async function handleDashboardApi(req, res, pathname, {
|
|
|
111
112
|
env = process.env,
|
|
112
113
|
home = os.homedir(),
|
|
113
114
|
liveModelsFn = () => fetchLiveCopilotModels({ home }),
|
|
114
|
-
usageSummaryFn
|
|
115
|
+
usageSummaryFn,
|
|
115
116
|
authStatusFn = () => authStatus({ home }),
|
|
116
117
|
} = {}) {
|
|
117
118
|
if (!isLoopbackAddress(req.socket?.remoteAddress)
|
|
@@ -229,8 +230,10 @@ export async function handleDashboardApi(req, res, pathname, {
|
|
|
229
230
|
}
|
|
230
231
|
}
|
|
231
232
|
try {
|
|
232
|
-
const
|
|
233
|
-
|
|
233
|
+
const analysis = analytics && !usageSummaryFn
|
|
234
|
+
? await analyzeUsageLogs(undefined, { timeZone: query.get("time_zone") || "UTC" }) : null;
|
|
235
|
+
const summary = analysis?.summary || await (usageSummaryFn || (options => summarizeUsageLogs(undefined, options)))(analytics ? { onRecord: analytics.record } : undefined);
|
|
236
|
+
sendJson(res, 200, { source: "local_usage_log", ...usageTable(summary), ...(analytics ? { analytics: analysis?.analytics || analytics.snapshot() } : {}) });
|
|
234
237
|
} catch {
|
|
235
238
|
sendJson(res, 500, { error: "Could not read local usage summary" });
|
|
236
239
|
}
|
package/src/image-readiness.mjs
CHANGED
|
@@ -47,7 +47,7 @@ export async function probeImageTool(codexContent, { fetchImpl = fetch, timeoutM
|
|
|
47
47
|
await rpc(undefined, "notifications/initialized", {});
|
|
48
48
|
const result = await rpc(2, "tools/list", {});
|
|
49
49
|
return result?.tools?.some((tool) => tool.name === "generate_image")
|
|
50
|
-
? { ready: true }
|
|
50
|
+
? { ready: true, generation: true, editing: result.tools.some(tool => tool.name === "edit_image") }
|
|
51
51
|
: { ready: false, reason: "tool_unavailable" };
|
|
52
52
|
} catch (error) {
|
|
53
53
|
return { ready: false, reason: signal.aborted ? "timeout" : /^HTTP \d+$/.test(error.message) ? error.message : "unreachable" };
|
package/src/responses-proxy.mjs
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
import {
|
|
41
41
|
isResponsesOutputEvent,
|
|
42
42
|
markFirstOutput,
|
|
43
|
+
responsesTimingSource,
|
|
43
44
|
markOutputTokens,
|
|
44
45
|
markRequestObservation,
|
|
45
46
|
markResponseErrorOrigin,
|
|
@@ -127,9 +128,15 @@ function inspectResponseSseEvent(state, eventName, data) {
|
|
|
127
128
|
if (outputTokens !== undefined) markOutputTokens(outputTokens);
|
|
128
129
|
const inputTokens = event.response?.usage?.input_tokens ?? event.usage?.input_tokens;
|
|
129
130
|
if (inputTokens !== undefined) markRequestObservation({ input_tokens: inputTokens });
|
|
131
|
+
if (!state.sawTiming) {
|
|
132
|
+
const timingSource = responsesTimingSource(event, eventType);
|
|
133
|
+
if (timingSource) {
|
|
134
|
+
state.sawTiming = true;
|
|
135
|
+
markFirstOutput(timingSource);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
130
138
|
if (!state.sawOutput && isResponsesOutputEvent(event, eventType)) {
|
|
131
139
|
state.sawOutput = true;
|
|
132
|
-
markFirstOutput();
|
|
133
140
|
}
|
|
134
141
|
if (["response.function_call_arguments.delta", "response.custom_tool_call_input.delta"].includes(eventType)) {
|
|
135
142
|
state.toolArgumentGuard.observe(event.output_index ?? event.item_id ?? event.call_id, event.delta);
|
|
@@ -39,6 +39,39 @@ export function isResponsesOutputEvent(event, eventType = event?.type) {
|
|
|
39
39
|
return RESPONSES_OUTPUT_EVENT_TYPES.has(eventType) && nonEmptyString(event?.delta);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Timing evidence is separate from the output/compatibility-retry commitment.
|
|
43
|
+
export function responsesTimingSource(event, eventType = event?.type) {
|
|
44
|
+
if (isResponsesOutputEvent(event, eventType)) return "delta";
|
|
45
|
+
const itemSource = (item, announced = false) => {
|
|
46
|
+
if (!item || typeof item !== "object") return null;
|
|
47
|
+
if (["function_call_output", "custom_tool_call_output", "computer_call_output", "additional_tools", "compaction", "compaction_summary", "mcp_approval_response"].includes(item.type)) return null;
|
|
48
|
+
if (["program_output", "shell_call_output", "tool_search_output"].includes(item.type)) return "runtime";
|
|
49
|
+
if (item.caller?.type === "program") return "runtime";
|
|
50
|
+
if (announced && ["reasoning", "function_call", "custom_tool_call", "web_search_call", "file_search_call", "computer_call", "tool_search_call", "mcp_call", "program", "multi_agent_call", "image_generation_call"].includes(item.type)) return "announcement";
|
|
51
|
+
const content = item.content || item.summary;
|
|
52
|
+
if (Array.isArray(content) && content.some(part => nonEmptyString(part?.text) || nonEmptyString(part?.refusal))) return "snapshot";
|
|
53
|
+
if (nonEmptyString(item.arguments) || nonEmptyString(item.input)) return "snapshot";
|
|
54
|
+
return null;
|
|
55
|
+
};
|
|
56
|
+
if (eventType === "response.output_item.added" || eventType === "response.output_item.done") {
|
|
57
|
+
return itemSource(event.item, eventType === "response.output_item.added");
|
|
58
|
+
}
|
|
59
|
+
if (["response.content_part.added", "response.content_part.done", "response.reasoning_summary_part.added", "response.reasoning_summary_part.done"].includes(eventType)
|
|
60
|
+
&& (nonEmptyString(event.part?.text) || nonEmptyString(event.part?.refusal))) return "snapshot";
|
|
61
|
+
if (eventType === "response.shell_call_output_content.delta"
|
|
62
|
+
&& (nonEmptyString(event.delta?.stdout) || nonEmptyString(event.delta?.stderr))) return "runtime";
|
|
63
|
+
if (["response.completed", "response.incomplete", "response.failed"].includes(eventType)) {
|
|
64
|
+
let runtime = null;
|
|
65
|
+
for (const item of Array.isArray(event.response?.output) ? event.response.output : []) {
|
|
66
|
+
const source = itemSource(item);
|
|
67
|
+
if (source && source !== "runtime") return source;
|
|
68
|
+
runtime ||= source;
|
|
69
|
+
}
|
|
70
|
+
return runtime;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
42
75
|
export function isChatOutputDelta(delta) {
|
|
43
76
|
if (!delta || typeof delta !== "object") return false;
|
|
44
77
|
return nonEmptyString(delta.content)
|
|
@@ -101,6 +134,7 @@ function createRoutePerformance() {
|
|
|
101
134
|
ttft: createHistogram(TTFT_EDGES_MS),
|
|
102
135
|
tpot: createHistogram(TPOT_EDGES_US),
|
|
103
136
|
requestTtft: createHistogram(TTFT_EDGES_MS),
|
|
137
|
+
outputSources: { delta: 0, announcement: 0, snapshot: 0, runtime: 0 },
|
|
104
138
|
preparation: Object.fromEntries(PREPARATION_STAGES.map((stage) => [stage, createHistogram(PREPARATION_EDGES_MS)])),
|
|
105
139
|
success_with_output: 0,
|
|
106
140
|
errors_with_output: 0,
|
|
@@ -128,6 +162,9 @@ function routeSnapshot(route) {
|
|
|
128
162
|
ttft_ms: histogramSnapshot(route.ttft, "ms"),
|
|
129
163
|
tpot_us: histogramSnapshot(route.tpot, "us"),
|
|
130
164
|
request_ttft_ms: histogramSnapshot(route.requestTtft, "ms"),
|
|
165
|
+
first_output_sources: { ...route.outputSources },
|
|
166
|
+
timing_boundary: "gateway_observed",
|
|
167
|
+
tpot_estimated: true,
|
|
131
168
|
preparation_ms: Object.fromEntries(PREPARATION_STAGES.map((stage) => [stage, histogramSnapshot(route.preparation[stage], "ms")])),
|
|
132
169
|
};
|
|
133
170
|
}
|
|
@@ -143,6 +180,7 @@ export function createStreamPerformanceMetrics({ now = () => performance.now(),
|
|
|
143
180
|
const requestStartedAt = now();
|
|
144
181
|
let upstreamStartedAt = null;
|
|
145
182
|
let firstOutputAt = null;
|
|
183
|
+
let firstOutputSource = null;
|
|
146
184
|
let outputTokens = null;
|
|
147
185
|
let failed = false;
|
|
148
186
|
let finished = false;
|
|
@@ -196,8 +234,11 @@ export function createStreamPerformanceMetrics({ now = () => performance.now(),
|
|
|
196
234
|
lastActivityAt = now();
|
|
197
235
|
phase = "upstream_stream";
|
|
198
236
|
},
|
|
199
|
-
firstOutput() {
|
|
200
|
-
if (firstOutputAt === null)
|
|
237
|
+
firstOutput(source = "delta") {
|
|
238
|
+
if (firstOutputAt === null) {
|
|
239
|
+
firstOutputAt = now();
|
|
240
|
+
firstOutputSource = Object.hasOwn(route.outputSources, source) ? source : "delta";
|
|
241
|
+
}
|
|
201
242
|
},
|
|
202
243
|
setOutputTokens(value) {
|
|
203
244
|
const tokens = Number(value);
|
|
@@ -250,6 +291,7 @@ export function createStreamPerformanceMetrics({ now = () => performance.now(),
|
|
|
250
291
|
failed: failed || aborted || (terminalOutcome !== null && terminalOutcome !== "completed"),
|
|
251
292
|
http_status: Number.isInteger(statusCode) && statusCode >= 100 && statusCode <= 599 ? statusCode : null,
|
|
252
293
|
upstream_attempts: attempts,
|
|
294
|
+
first_output_source: firstOutputSource,
|
|
253
295
|
context: { ...context },
|
|
254
296
|
timings_ms: {
|
|
255
297
|
...Object.fromEntries(PREPARATION_STAGES.map((stage) => [stage, Number(preparationMs[stage].toFixed(1))])),
|
|
@@ -260,6 +302,7 @@ export function createStreamPerformanceMetrics({ now = () => performance.now(),
|
|
|
260
302
|
});
|
|
261
303
|
if (recentRequests.length > MAX_RECENT_REQUESTS) recentRequests.shift();
|
|
262
304
|
if (firstOutputAt !== null) observe(route.requestTtft, Math.max(0, firstOutputAt - requestStartedAt));
|
|
305
|
+
if (firstOutputSource) route.outputSources[firstOutputSource] += 1;
|
|
263
306
|
if (upstreamStartedAt === null) {
|
|
264
307
|
route.neutral += 1;
|
|
265
308
|
return;
|
|
@@ -321,8 +364,8 @@ export function markUpstreamActivity() {
|
|
|
321
364
|
tracker()?.upstreamActivity?.();
|
|
322
365
|
}
|
|
323
366
|
|
|
324
|
-
export function markFirstOutput() {
|
|
325
|
-
tracker()?.firstOutput();
|
|
367
|
+
export function markFirstOutput(source) {
|
|
368
|
+
tracker()?.firstOutput(source);
|
|
326
369
|
}
|
|
327
370
|
|
|
328
371
|
export function markOutputTokens(value) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { summarizeUsageLogs, usageLogPath, usageSummaryVersion } from "./usage-store.mjs";
|
|
2
|
+
import { createUsageAnalytics } from "./usage-analytics.mjs";
|
|
3
|
+
|
|
4
|
+
const MAX_ENTRIES = 2;
|
|
5
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
const cache = new Map();
|
|
7
|
+
|
|
8
|
+
// Only bounded, settled-file aggregates survive. No record index or payload cache.
|
|
9
|
+
export async function analyzeUsageLogs(filePath = usageLogPath(), { timeZone = "UTC", now = Date.now(), warn = console.error } = {}) {
|
|
10
|
+
const analytics = createUsageAnalytics({ timeZone, now });
|
|
11
|
+
const zone = new Intl.DateTimeFormat("sv-SE", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" });
|
|
12
|
+
const version = warn === console.error ? await usageSummaryVersion(filePath) : null;
|
|
13
|
+
const key = version === null ? null : `${version}\0${zone.resolvedOptions().timeZone}\0${zone.format(new Date(now))}`;
|
|
14
|
+
if (key && cache.has(key)) return structuredClone(await cache.get(key).promise);
|
|
15
|
+
if (key) {
|
|
16
|
+
for (const [old, entry] of cache) if (entry.version !== version) cache.delete(old);
|
|
17
|
+
while (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value);
|
|
18
|
+
} else cache.clear();
|
|
19
|
+
const entry = { version, bytes: 0, promise: null };
|
|
20
|
+
entry.promise = (async () => {
|
|
21
|
+
let warned = false;
|
|
22
|
+
const summary = await summarizeUsageLogs(filePath, {
|
|
23
|
+
onRecord: analytics.record,
|
|
24
|
+
warn(message) { warned = true; warn(message); },
|
|
25
|
+
});
|
|
26
|
+
const result = { summary, analytics: analytics.snapshot() };
|
|
27
|
+
const after = key ? await usageSummaryVersion(filePath) : null;
|
|
28
|
+
if (key && cache.get(key) === entry) {
|
|
29
|
+
entry.bytes = Buffer.byteLength(JSON.stringify(result));
|
|
30
|
+
if (warned || after !== version || entry.bytes > MAX_BYTES || result.analytics.undated_records > 0) cache.delete(key);
|
|
31
|
+
else {
|
|
32
|
+
let bytes = [...cache.values()].reduce((total, item) => total + item.bytes, 0);
|
|
33
|
+
for (const [old, item] of cache) {
|
|
34
|
+
if (bytes <= MAX_BYTES) break;
|
|
35
|
+
cache.delete(old);
|
|
36
|
+
bytes -= item.bytes;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
})();
|
|
42
|
+
if (key) cache.set(key, entry);
|
|
43
|
+
try { return structuredClone(await entry.promise); }
|
|
44
|
+
catch (error) { if (cache.get(key) === entry) cache.delete(key); throw error; }
|
|
45
|
+
}
|
package/src/usage-store.mjs
CHANGED
|
@@ -312,7 +312,7 @@ const SUMMARY_CACHE_MAX_BYTES = 256 * 1024;
|
|
|
312
312
|
const SUMMARY_CACHE_SETTLED_MS = 2000;
|
|
313
313
|
let cachedSummary = null;
|
|
314
314
|
|
|
315
|
-
async function usageSummaryVersion(filePath) {
|
|
315
|
+
export async function usageSummaryVersion(filePath) {
|
|
316
316
|
try {
|
|
317
317
|
const files = await Promise.all([rotatedFilePath(filePath), filePath].map(async (file) => {
|
|
318
318
|
try {
|
package/src/usage.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { cliOutputFormat, cliOutputWidth, formatResponsiveCliTable, terminalCell
|
|
|
2
2
|
import { cacheReadTokens, summarizeUsageLogs, usageCacheHitRate, usageLogPath } from "./usage-store.mjs";
|
|
3
3
|
|
|
4
4
|
export {
|
|
5
|
+
usageSummaryVersion,
|
|
5
6
|
usageLoggingStats,
|
|
6
7
|
usageLogPath,
|
|
7
8
|
usageLogMaxBytes,
|