Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,9 @@ jobs:
run: npm run test:storage:docker
env:
STORAGE_TEST_IMAGE: codex-usage-dashboard:ci
- name: Verify compressed archives in the container
run: >-
docker run --rm --read-only --tmpfs /tmp --cap-drop ALL
--security-opt no-new-privileges:true
--mount type=bind,source=${{ github.workspace }}/scripts/smoke-compressed-docker.mjs,target=/verify-compressed.mjs,readonly
codex-usage-dashboard:ci node /verify-compressed.mjs
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## Unreleased

## 1.6.1 — 2026-10-19

- Read Codex's Zstandard-compressed `.jsonl.zst` session archives directly, alongside plain JSONL logs. Compression no longer makes historical usage disappear locally or propagate as Mesh deletions.
- Preserve session identities, calls, tokens and incremental indexing when archives are compressed, and deduplicate compressed/plain copies.
- Keep the last successful snapshot and skip synchronization if a compressed archive cannot be read, preventing partial history from replacing stored data.
- Require Node.js 22.15 or newer for streaming Zstandard support. Existing Docker storage volumes and Mesh identities are retained.

Docker: `capitaine/codex-usage-dashboard:1.6.1` and `:latest` (Linux AMD64 and ARM64). Recreate containers with their existing configuration and storage volumes. Update reporting agents to restore collection of compressed archives.

## 1.6.0 — 2026-10-09

- Add dated Ultrafast pricing for GPT-6 Astra and GPT-6.1 Sol: API and purchased credits use 6x Standard; included-subscription quota calibration uses 8x.
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ LABEL org.opencontainers.image.source="https://github.com/capisoft-lib/codex_usa
org.opencontainers.image.licenses="AGPL-3.0-or-later" \
org.opencontainers.image.title="Local Usage Dashboard for Codex" \
org.opencontainers.image.description="Independent local dashboard for Codex usage, token activity, and cost estimates" \
org.opencontainers.image.version="1.6.0"
org.opencontainers.image.version="1.6.1"

ENV NODE_ENV=production \
HOST=0.0.0.0 \
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

Local Usage turns the Codex session metadata stored on your computer into a fast, privacy-conscious usage dashboard. It can run entirely on one machine, or several machines can send signed, minimized usage snapshots to an optional central dashboard hosted with OpenAI Sites or on your own server.

[**Stable release 1.6.0**](https://github.com/capisoft-lib/codex_usage/releases/tag/v1.6.0) · [Docker image 1.6.0](https://hub.docker.com/r/capitaine/codex-usage-dashboard) · [AGPL-3.0-or-later](LICENSE) · [Changelog](CHANGELOG.md) · [CI status](https://github.com/capisoft-lib/codex_usage/actions/workflows/ci.yml)
[**Stable release 1.6.1**](https://github.com/capisoft-lib/codex_usage/releases/tag/v1.6.1) · [Docker image 1.6.1](https://hub.docker.com/r/capitaine/codex-usage-dashboard) · [AGPL-3.0-or-later](LICENSE) · [Changelog](CHANGELOG.md) · [CI status](https://github.com/capisoft-lib/codex_usage/actions/workflows/ci.yml)

Setup guides: [deploy the central dashboard with OpenAI Sites](docs/sites-deployment.md) · [deploy the public Mesh ingress](docs/mesh-ingress.md) · [install a reporting agent](docs/reporting-agent.md)

Expand Down Expand Up @@ -75,7 +75,7 @@ Local mode is always the default. Nothing is sent to a hub unless `MESH_HUB_URL`

## Requirements

- [Node.js](https://nodejs.org/) 22.13 or newer for the local dashboard/agent, or [Docker](https://www.docker.com/);
- [Node.js](https://nodejs.org/) 22.15 or newer for the local dashboard/agent, or [Docker](https://www.docker.com/);
- a local Codex installation with session files under the Codex home directory;
- Node.js 22.13 or newer when developing or testing `sites-hub/` locally.

Expand Down Expand Up @@ -123,11 +123,13 @@ Direct mode reads only these paths under the current user's Codex directory:

It does not authenticate with OpenAI and never opens `auth.json`.

Both plain `.jsonl` sessions and Zstandard-compressed `.jsonl.zst` archives are indexed directly. Compressing a Codex archive preserves its usage history; no extracted copy is needed. If an archive cannot be read, the collector keeps its last successful snapshot and skips synchronization instead of sending historical deletions.

## Optional mini quota window

In dashboard Settings, choose the five-hour quota, weekly quota, or both, then open the compact window. It uses the selected data source, language and palette. The browser popup does not guarantee always-on-top behavior.

For a native always-on-top window, install the **separate** desktop helper with Node.js 22.13 or newer:
For a native always-on-top window, install the **separate** desktop helper with Node.js 22.15 or newer:

```bash
npm ci --prefix desktop
Expand Down Expand Up @@ -163,7 +165,7 @@ The dashboard image runs as a non-root user with all Linux capabilities removed.
The public Linux AMD64/ARM64 image is:

```text
capitaine/codex-usage-dashboard:1.6.0
capitaine/codex-usage-dashboard:1.6.1
```

The 1.0.2 release is also mirrored at `ghcr.io/capisoft-lib/codex-usage-dashboard:1.0.2`.
Expand All @@ -172,7 +174,7 @@ On Windows PowerShell:

```powershell
$codexData = Join-Path $env:USERPROFILE ".codex"
$image = "capitaine/codex-usage-dashboard:1.6.0"
$image = "capitaine/codex-usage-dashboard:1.6.1"

docker pull $image
docker volume create codex-usage-dashboard-storage
Expand All @@ -196,7 +198,7 @@ docker run -d `
On macOS or Linux:

```bash
IMAGE="capitaine/codex-usage-dashboard:1.6.0"
IMAGE="capitaine/codex-usage-dashboard:1.6.1"

docker pull "$IMAGE"
docker volume create codex-usage-dashboard-storage
Expand Down
14 changes: 9 additions & 5 deletions docs/dockerhub-readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

Local Usage turns the Codex session metadata stored on your computer into a fast, privacy-conscious usage dashboard. It can run entirely on one machine, or several machines can send signed, minimized usage snapshots to an optional central dashboard.

[**Stable release 1.6.0**](https://github.com/capisoft-lib/codex_usage/releases/tag/v1.6.0) · [Docker image 1.6.0](https://hub.docker.com/r/capitaine/codex-usage-dashboard/tags) · [Source code](https://github.com/capisoft-lib/codex_usage) · [AGPL-3.0-or-later](https://github.com/capisoft-lib/codex_usage/blob/main/LICENSE) · [Changelog](https://github.com/capisoft-lib/codex_usage/blob/main/CHANGELOG.md) · [CI status](https://github.com/capisoft-lib/codex_usage/actions/workflows/ci.yml)
[**Stable release 1.6.1**](https://github.com/capisoft-lib/codex_usage/releases/tag/v1.6.1) · [Docker image 1.6.1](https://hub.docker.com/r/capitaine/codex-usage-dashboard/tags) · [Source code](https://github.com/capisoft-lib/codex_usage) · [AGPL-3.0-or-later](https://github.com/capisoft-lib/codex_usage/blob/main/LICENSE) · [Changelog](https://github.com/capisoft-lib/codex_usage/blob/main/CHANGELOG.md) · [CI status](https://github.com/capisoft-lib/codex_usage/actions/workflows/ci.yml)

## Donate

Expand All @@ -22,7 +22,11 @@ If you like this project and want to support its ongoing development, you can [b
- project, model, period, usage, and conversation filters;
- nine interface languages.

## New in 1.6.0
## New in 1.6.1

Restore historical usage from Codex's compressed `.jsonl.zst` archives without extracting them. Compressed and plain copies keep the same session identity, calls and tokens. An unreadable archive preserves the last successful snapshot instead of triggering historical deletions. The image includes the required Zstandard-capable Node runtime; keep the existing storage volume when upgrading.

### Included since 1.6.0

- Astra and Sol 6.1 Ultrafast are recognized, displayed and priced with dated eligibility.
- Fast credit billing (2x) and quota weighting (2.5x), plus Ultrafast credit billing (6x) and quota weighting (8x), are calculated separately.
Expand All @@ -33,18 +37,18 @@ If you like this project and want to support its ongoing development, you can [b
The public image supports Linux AMD64 and ARM64 and runs as a non-root user:

```text
capitaine/codex-usage-dashboard:1.6.0
capitaine/codex-usage-dashboard:1.6.1
```

```bash
docker pull capitaine/codex-usage-dashboard:1.6.0
docker pull capitaine/codex-usage-dashboard:1.6.1
docker compose up -d
```

Inspect the published multi-platform manifest and digest:

```text
docker buildx imagetools inspect capitaine/codex-usage-dashboard:1.6.0
docker buildx imagetools inspect capitaine/codex-usage-dashboard:1.6.1
```

The published manifests include SBOM and provenance attestations. See the [full README](https://github.com/capisoft-lib/codex_usage#readme) for scoped read-only mounts, PowerShell/macOS/Linux commands, Compose configuration, and hardened deployment examples.
Expand Down
2 changes: 1 addition & 1 deletion docs/relational-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The dashboard uses embedded SQLite on Node.js and in Docker, and Cloudflare D1 on Sites. The schema and read queries are shared. No SQL server, container, account or connection string is required for local operation.

Node.js **22.13 or newer** is required because the application uses `node:sqlite`. The Docker image already supplies Node 22. The optional desktop helper supplies its own compatible Node runtime through Electron.
Node.js **22.15 or newer** is required for embedded SQLite and streaming Zstandard archive reads. The Docker image already supplies Node 22. The optional desktop helper supplies its own compatible Node runtime through Electron.

## Data model

Expand Down
2 changes: 1 addition & 1 deletion docs/reporting-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Both modes use the same collector, privacy filter, signing identity, enrollment,
Each machine needs:

- Codex session data under its local Codex directory;
- Node.js 22.13 or newer, or Docker;
- Node.js 22.15 or newer, or Docker;
- network access to the public Mesh ingress over HTTPS when the private Sites hub is used;
- a one-time association command created by the hub owner;
- a persistent writable location for the agent state file.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "codex-usage-dashboard",
"private": true,
"version": "1.6.0",
"version": "1.6.1",
"description": "An independent, privacy-conscious local dashboard for Codex usage, model calls, timing, and theoretical cost.",
"license": "AGPL-3.0-or-later",
"type": "module",
Expand All @@ -17,6 +17,6 @@
"start:desktop": "npm run build:ui && npm --prefix desktop start --"
},
"engines": {
"node": ">=22.13.0"
"node": ">=22.15.0"
}
}
40 changes: 40 additions & 0 deletions scripts/smoke-compressed-docker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Run inside the built image: node /verify-compressed.mjs.
// All source data and storage are disposable fixtures under /tmp.
import assert from 'node:assert/strict';
import {mkdir, writeFile, unlink, readFile} from 'node:fs/promises';
import {spawn} from 'node:child_process';
import {once} from 'node:events';
import {zstdCompressSync} from 'node:zlib';

assert.equal(JSON.parse(await readFile('/app/package.json','utf8')).version,'1.6.1');
const root='/tmp/codex-compression-smoke';
await mkdir(root+'/sessions',{recursive:true});
await mkdir(root+'/archives');
const rows=[
{timestamp:'2026-06-28T10:00:00Z',type:'session_meta',payload:{id:'compressed-history',cwd:'/project'}},
{timestamp:'2026-06-28T10:00:01Z',type:'turn_context',payload:{turn_id:'t',model:'gpt-6-sol'}},
{timestamp:'2026-06-28T10:00:02Z',type:'event_msg',payload:{type:'token_count',info:{last_token_usage:{input_tokens:123,output_tokens:45,total_tokens:168}}}},
];
const text=rows.map(JSON.stringify).join('\n')+'\n';
await writeFile(root+'/sessions/history.jsonl',text);
const server=spawn(process.execPath,['/app/server.mjs'],{env:{...process.env,
PORT:'4317',CODEX_ACCOUNT_QUOTA_MODE:'off',CODEX_SESSIONS_PATH:root+'/sessions',
CODEX_ARCHIVED_SESSIONS_PATH:root+'/archives',CODEX_SESSION_INDEX_PATH:root+'/index.jsonl',
SNAPSHOT_PATH:root+'/usage.json',MESH_HUB_URL:'',MESH_AGENT_STATE_PATH:root+'/mesh.json'}});
let diagnostics='';server.stdout.on('data',chunk=>{diagnostics+=chunk});server.stderr.on('data',chunk=>{diagnostics+=chunk});
const closed=once(server,'close');
try {
let ready=false;
for(let i=0;i<100;i++) {try{const r=await fetch('http://127.0.0.1:4317/api/health');if((await r.json()).ready){ready=true;break;}}catch{}await new Promise(r=>setTimeout(r,100));}
assert.ok(ready,diagnostics);
const usage=async()=>{const r=await fetch('http://127.0.0.1:4317/api/usage?refresh=1');assert.equal(r.status,200,diagnostics);return r.json();};
const plain=await usage();assert.equal(plain.sessions.length,1);assert.equal(plain.sessions[0].usage.totalTokens,168);
await writeFile(root+'/archives/history.jsonl.zst',zstdCompressSync(Buffer.from(text)));
await unlink(root+'/sessions/history.jsonl');
const compressed=await usage();assert.deepEqual(compressed.sessions,plain.sessions);
await writeFile(root+'/archives/history.jsonl.zst',Buffer.from('damaged archive'));
const preserved=await usage();assert.deepEqual(preserved.sessions,compressed.sessions);
const health=await (await fetch('http://127.0.0.1:4317/api/health')).json();
assert.match(health.lastError,/archive Codex impossible/);
console.log(`PASS: ${process.arch} v1.6.1 HTTP collector preserves identity/tokens after Zstandard compression and retains history on archive failure.`);
} finally {server.kill('SIGTERM');await closed;}
28 changes: 24 additions & 4 deletions src/analyzer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import readline from "node:readline";
import * as zlib from "node:zlib";
import { mergeWeeklyQuotaObservations } from "./quota-history.mjs";

const EMPTY_USAGE = Object.freeze({
Expand Down Expand Up @@ -187,7 +188,7 @@ async function walkJsonl(root) {
await Promise.all(entries.map(async (entry) => {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) await walk(fullPath);
else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(fullPath);
else if (entry.isFile() && /\.jsonl(?:\.zst)?$/.test(entry.name)) files.push(fullPath);
}));
}
await walk(root);
Expand Down Expand Up @@ -243,7 +244,17 @@ export async function parseSessionFile(filePath, threadNames = new Map()) {
const calls = [];
const parseErrors = [];

const input = createReadStream(filePath, { encoding: "utf8" });
const compressed = filePath.endsWith(".jsonl.zst");
if (compressed && typeof zlib.createZstdDecompress !== "function") {
throw new Error("Les archives Codex .jsonl.zst nécessitent Node.js 22.15 ou plus récent.");
}
const source = createReadStream(filePath);
const input = compressed ? zlib.createZstdDecompress() : source;
if (compressed) {
source.on("error", (error) => input.destroy(error));
source.pipe(input);
}
input.setEncoding("utf8");
const lines = readline.createInterface({ input, crlfDelay: Infinity });
let lineNumber = 0;

Expand Down Expand Up @@ -348,10 +359,14 @@ export async function parseSessionFile(filePath, threadNames = new Map()) {
}
}
} catch (error) {
if (error.code !== "EBUSY" && error.code !== "EPERM") throw error;
if (compressed || (error.code !== "EBUSY" && error.code !== "EPERM")) throw error;
} finally {
lines.close();
input.destroy();
source.destroy();
}

const sessionId = meta?.id || meta?.session_id || path.basename(filePath, ".jsonl");
const sessionId = meta?.id || meta?.session_id || path.basename(filePath).replace(/\.jsonl(?:\.zst)?$/, "");
const turnList = [...turns.values()];
const models = [...new Set(calls.map((call) => call.model).filter(Boolean))];
const usage = calls.reduce((total, call) => addUsage(total, call.usage), { ...EMPTY_USAGE });
Expand Down Expand Up @@ -425,6 +440,11 @@ export async function analyzeCodexUsage(options = {}) {
}
}));

// A failed archive read must not become a successful partial snapshot whose
// missing sessions would then be deleted from both SQLite and the Mesh hub.
const archiveError = sessions.find((session) => session.error && session.filePath.endsWith(".jsonl.zst"));
if (archiveError) throw new Error(`Lecture de l'archive Codex impossible : ${archiveError.filePath}: ${archiveError.error}`);

// A session may briefly exist in both active and archived folders. Keep the newest copy.
const unique = new Map();
for (const session of sessions.filter((item) => item.id)) {
Expand Down
60 changes: 60 additions & 0 deletions test/compressed-sessions.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import {mkdtemp, mkdir, writeFile, unlink, rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import path from 'node:path';
import test from 'node:test';
import * as zlib from 'node:zlib';
import {analyzeCodexUsage, parseSessionFile, usageFingerprint} from '../src/analyzer.mjs';
import {UsageStore} from '../src/usage-store.mjs';

const supported = typeof zlib.zstdCompressSync === 'function';
const rows = [
{timestamp:'2026-06-28T10:00:00Z',type:'session_meta',payload:{id:'archived',cwd:'C:/projet-é'}},
{timestamp:'2026-06-28T10:00:01Z',type:'turn_context',payload:{turn_id:'turn',model:'gpt-test',effort:'high'}},
{timestamp:'2026-06-28T10:00:02Z',type:'event_msg',payload:{type:'token_count',info:{last_token_usage:{input_tokens:123,cached_input_tokens:23,output_tokens:45,total_tokens:168}}}},
];
const content = Buffer.from(rows.map(JSON.stringify).join('\n')+'\n');
async function fixture(t) {
const root=await mkdtemp(path.join(tmpdir(),'codex-zstd-'));
t.after(()=>rm(root,{recursive:true}));
await mkdir(path.join(root,'sessions'));await mkdir(path.join(root,'archived_sessions'));
return root;
}

test('compressed archives preserve calls, tokens, Unicode and names; compression does not delete a session', {skip:!supported}, async t=>{
const codexHome=await fixture(t), plain=path.join(codexHome,'sessions','rollout.jsonl');
const archive=path.join(codexHome,'archived_sessions','rollout.jsonl.zst');
await writeFile(plain,content);
const first=await analyzeCodexUsage({codexHome}), fingerprint=await usageFingerprint({codexHome});
await writeFile(archive,zlib.zstdCompressSync(content));
assert.equal((await analyzeCodexUsage({codexHome})).sessions.length,1,'plain and compressed copies are deduplicated');
await unlink(plain);
assert.notEqual(await usageFingerprint({codexHome}),fingerprint);
const next=await analyzeCodexUsage({codexHome,previousData:first});
assert.equal(next.sessions.length,1);assert.equal(next.errors.length,0);
const {filePath,...before}=first.sessions[0], {filePath:archivePath,...after}=next.sessions[0];
delete before.fileSize;delete before.fileModifiedAtMs;delete after.fileSize;delete after.fileModifiedAtMs;
assert.deepEqual(after,before);
assert.equal(archivePath,archive);assert.equal(next.sessions[0].usage.totalTokens,168);
assert.equal((await parseSessionFile(archive,new Map([['archived','Résumé historique']]))).title,'Résumé historique');
});

test('compressed filenames without session metadata retain the same fallback identity', {skip:!supported}, async t=>{
const root=await fixture(t), file=path.join(root,'archived_sessions','fallback.jsonl.zst');
await writeFile(file,zlib.zstdCompressSync(Buffer.from(JSON.stringify(rows[2])+'\n')));
assert.equal((await parseSessionFile(file)).id,'fallback');
});

test('a damaged archive rejects the refresh and preserves persisted history without syncing removals', {skip:!supported}, async t=>{
const codexHome=await fixture(t), archive=path.join(codexHome,'archived_sessions','rollout.jsonl.zst');
await writeFile(archive,zlib.zstdCompressSync(content));
let syncs=0;
const store=new UsageStore({analyze:previousData=>analyzeCodexUsage({codexHome,previousData}),fingerprint:()=>usageFingerprint({codexHome}),onUpdated:()=>{syncs++},logger:{log(){},warn(){},error(){}}});
t.after(()=>store.close());
await store.refresh();assert.equal(syncs,1);
await writeFile(archive,Buffer.from('corrupted zstd'));
assert.equal((await store.refresh(true)).sessions.length,1);
assert.equal(store.repository.loadUsage().data.sessions.length,1);
assert.equal(syncs,1);assert.match(store.status().lastError,/archive Codex impossible/);
await assert.rejects(parseSessionFile(path.join(codexHome,'absent.jsonl.zst')),/ENOENT/);
});
Loading