ai-native-profile 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,6 +33,14 @@ The command prints a short-lived verification URL and code. Open the URL, sign i
33
33
  npx --yes ai-native-profile@latest sync
34
34
  ```
35
35
 
36
+ For the fastest setup, sign in on the website and choose **Power up with coding activity**. The site creates a one-time command like this:
37
+
38
+ ```bash
39
+ npx --yes ai-native-profile@latest connect --code ABCD1234
40
+ ```
41
+
42
+ That command is already associated with the signed-in profile. It detects sources, displays the privacy boundary, asks once before the first sync, and updates the open card without requiring the code to be entered again.
43
+
36
44
  To keep syncing every 15 minutes while the command is running:
37
45
 
38
46
  ```bash
@@ -46,7 +54,7 @@ npx --yes ai-native-profile@latest watch
46
54
  | Source | Collection path | Trust label |
47
55
  |---|---|---|
48
56
  | Codex | Official local App Server account usage | Official account source |
49
- | Claude Code | Local statistics cache | Local exact |
57
+ | Claude Code | Deduplicated local session usage plus non-overlapping statistics-cache history | Local exact for retained records; partial lifetime coverage |
50
58
  | Cursor | Supported local database metadata | Estimated; excluded from rankings |
51
59
  | Gemini CLI | Locally retained CLI telemetry | Locally derived |
52
60
  | GitHub Copilot CLI | Locally retained CLI activity | Locally derived |
@@ -59,6 +67,7 @@ Normal synchronization may include:
59
67
 
60
68
  - Dates and provider/model identifiers
61
69
  - Token, session, turn, and tool-call counts
70
+ - Input, output, cache-write, and cache-read token counts when a provider reports them
62
71
  - Duration, coverage, freshness, and provenance
63
72
  - Collector version and an anonymous device identifier
64
73
 
@@ -96,6 +105,16 @@ Use a self-hosted deployment instead of the managed service:
96
105
  npx --yes ai-native-profile@latest connect --api-url https://your-domain.example
97
106
  ```
98
107
 
108
+ ## Troubleshooting Codex discovery
109
+
110
+ The collector checks the shell `PATH` and the standard macOS ChatGPT/Codex app-bundle locations for the Codex App Server executable. If yours lives elsewhere, provide its exact path:
111
+
112
+ ```bash
113
+ ANP_CODEX_PATH=/path/to/codex npx --yes ai-native-profile@latest preview
114
+ ```
115
+
116
+ If the App Server is unavailable, the collector reports that Codex was skipped and continues collecting other detected sources instead of failing the entire sync.
117
+
99
118
  ## What the evidence means
100
119
 
101
120
  Every metric carries its source, coverage dates, freshness, and provenance. A device signature protects integrity in transit; it does not make a user-controlled computer tamper-proof. Activity demonstrates workflow adoption, not developer skill, productivity, code quality, or AI authorship.
package/bin/anp.mjs CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHmac, randomUUID } from 'node:crypto';
3
3
  import { spawn } from 'node:child_process';
4
- import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync, readdirSync, statSync } from 'node:fs';
4
+ import { createReadStream, existsSync, readFileSync, mkdirSync, writeFileSync, rmSync, readdirSync, statSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { createInterface } from 'node:readline';
8
+ import { resolveCodexExecutable } from '../src/codex-executable.mjs';
9
+ import { addClaudeSessionEvent, createClaudeSessionAccumulator, finalizeClaudeSessionUsage, mergeClaudeUsage, parseClaudeStatsCache } from '../src/claude-usage.mjs';
8
10
 
9
- const VERSION = '0.1.1';
11
+ const VERSION = '0.2.0';
10
12
  const DEFAULT_API_URL = 'https://ai-native-profile.vercel.app';
11
13
  const configDir = join(homedir(), '.config', 'ai-native-profile');
12
14
  const configFile = join(configDir, 'config.json');
@@ -19,7 +21,15 @@ const paths = {
19
21
  };
20
22
 
21
23
  const sourceNames = { codex: 'Codex', claude_code: 'Claude Code', cursor: 'Cursor', gemini_cli: 'Gemini CLI', copilot_cli: 'GitHub Copilot CLI' };
22
- const detectedSources = () => Object.entries(paths).map(([source, candidates]) => ({ source, detected: candidates.some(existsSync) }));
24
+ const detectedSources = () => Object.entries(paths).map(([source, candidates]) => {
25
+ if (source !== 'codex') {
26
+ const detected = candidates.some(existsSync);
27
+ return { source, detected, detail:detected ? 'detected' : 'not detected' };
28
+ }
29
+ const executable = resolveCodexExecutable();
30
+ const localData = candidates.some(existsSync);
31
+ return { source, detected:Boolean(executable), detail:executable ? 'detected' : localData ? 'local data found; App Server unavailable' : 'not detected' };
32
+ });
23
33
  const loadConfig = () => { try { return JSON.parse(readFileSync(configFile, 'utf8')); } catch { return {}; } };
24
34
  const saveConfig = (value) => { mkdirSync(configDir, { recursive: true, mode: 0o700 }); writeFileSync(configFile, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); };
25
35
 
@@ -32,19 +42,32 @@ function canonicalJson(value) {
32
42
  return JSON.stringify(value);
33
43
  }
34
44
 
35
- function claudeAggregate() {
45
+ async function claudeAggregate() {
36
46
  const file = paths.claude_code.find((path) => path.endsWith('stats-cache.json') && existsSync(path));
37
- if (!file) return null;
38
- try {
39
- const stats = JSON.parse(readFileSync(file, 'utf8'));
40
- const tokenRows = new Map((stats.dailyModelTokens ?? []).map((row) => [row.date, Object.values(row.tokensByModel ?? {}).reduce((sum, value) => sum + Number(value || 0), 0)]));
41
- return (stats.dailyActivity ?? []).filter((row) => /^\d{4}-\d{2}-\d{2}$/.test(row.date ?? '')).map((row) => ({
42
- date: row.date, source: 'claude_code', category: 'coding',
43
- metrics: [
44
- ['sessions', row.sessionCount ?? 0], ['turns', row.messageCount ?? 0], ['tool_calls', row.toolCallCount ?? 0], ['tokens', tokenRows.get(row.date) ?? 0],
45
- ].filter(([, value]) => Number(value) > 0).map(([name, value]) => ({ name, value:Number(value), unit:'count', provenance:'local_exact', source:'claude_code', coverageStart:row.date, coverageEnd:row.date, competitiveEligible:true })),
46
- }));
47
- } catch { return null; }
47
+ let legacy = { daily: [], undatedCacheTokens:0 };
48
+ if (file) try { legacy = parseClaudeStatsCache(JSON.parse(readFileSync(file, 'utf8'))); } catch {}
49
+
50
+ const accumulator = createClaudeSessionAccumulator();
51
+ let malformedLines = 0;
52
+ const projectRoots = paths.claude_code.filter((path) => !path.endsWith('stats-cache.json'));
53
+ for (const sessionFile of localFiles(projectRoots).filter((path) => /\.jsonl$/i.test(path))) {
54
+ try {
55
+ const lines = createInterface({ input:createReadStream(sessionFile, { encoding:'utf8' }), crlfDelay:Infinity });
56
+ for await (const line of lines) {
57
+ if (!line.trim()) continue;
58
+ try { addClaudeSessionEvent(accumulator, JSON.parse(line)); }
59
+ catch { malformedLines += 1; }
60
+ }
61
+ } catch { malformedLines += 1; }
62
+ }
63
+ const session = finalizeClaudeSessionUsage(accumulator);
64
+ const daily = mergeClaudeUsage(session.daily, legacy.daily);
65
+ if (!daily.length) return null;
66
+ const notes = ['Exact retained session records include input, output, cache-write, and cache-read tokens; local retention cannot prove complete lifetime coverage.'];
67
+ if (legacy.daily.length) notes.push('Older stats-cache days include combined base input/output tokens only.');
68
+ if (legacy.undatedCacheTokens) notes.push(`${Math.round(legacy.undatedCacheTokens)} older cache tokens lack day-level attribution and are excluded from dated totals.`);
69
+ if (malformedLines) notes.push(`${malformedLines} malformed local record${malformedLines === 1 ? ' was' : 's were'} skipped.`);
70
+ return { daily, completeness:'partial', note:notes.join(' ') };
48
71
  }
49
72
 
50
73
  function localFiles(candidates, limit = 300) {
@@ -100,7 +123,13 @@ async function cursorAggregate() {
100
123
  }
101
124
 
102
125
  async function codexAggregate() {
103
- const child = spawn('codex', ['app-server'], { stdio: ['pipe', 'pipe', process.env.ANP_DEBUG === '1' ? 'inherit' : 'ignore'] });
126
+ const executable = resolveCodexExecutable();
127
+ if (!executable) throw new Error('Codex App Server is unavailable. Install the Codex CLI or set ANP_CODEX_PATH to its executable.');
128
+ const child = spawn(executable, ['app-server'], { stdio: ['pipe', 'pipe', process.env.ANP_DEBUG === '1' ? 'inherit' : 'ignore'] });
129
+ const started = new Promise((resolve, reject) => {
130
+ child.once('spawn', resolve);
131
+ child.once('error', (error) => reject(new Error(`Could not start Codex App Server: ${error.message}`)));
132
+ });
104
133
  const lines = createInterface({ input: child.stdout });
105
134
  const pending = new Map();
106
135
  lines.on('line', (line) => { try { const message = JSON.parse(line); if (typeof message.id === 'number') pending.get(message.id)?.(message); } catch {} });
@@ -110,6 +139,7 @@ async function codexAggregate() {
110
139
  child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
111
140
  });
112
141
  try {
142
+ await started;
113
143
  await request(1, 'initialize', { clientInfo: { name:'ai_native_profile', title:'AI Native Profile', version:VERSION } });
114
144
  child.stdin.write(`${JSON.stringify({ method:'initialized', params:{} })}\n`);
115
145
  const usage = await request(2, 'account/usage/read');
@@ -125,14 +155,19 @@ async function codexAggregate() {
125
155
 
126
156
  async function createBatch() {
127
157
  let codexDaily = [];
128
- try { codexDaily = await codexAggregate(); } catch (error) { if (process.env.ANP_DEBUG === '1') console.error(`Codex usage unavailable: ${error instanceof Error ? error.message : String(error)}`); }
129
- const daily = [...codexDaily, ...(claudeAggregate() ?? []), ...(await cursorAggregate()), ...genericLocalAggregate('gemini_cli', paths.gemini_cli), ...genericLocalAggregate('copilot_cli', paths.copilot_cli)];
158
+ const hasCodexState = paths.codex.some(existsSync);
159
+ if (hasCodexState || resolveCodexExecutable()) {
160
+ try { codexDaily = await codexAggregate(); }
161
+ catch (error) { console.error(`Codex skipped: ${error instanceof Error ? error.message : String(error)}`); }
162
+ }
163
+ const claude = await claudeAggregate();
164
+ const daily = [...codexDaily, ...(claude?.daily ?? []), ...(await cursorAggregate()), ...genericLocalAggregate('gemini_cli', paths.gemini_cli), ...genericLocalAggregate('copilot_cli', paths.copilot_cli)];
130
165
  const dates = daily.map((day) => day.date).sort();
131
166
  const sources = [...new Set(daily.map((day) => day.source))];
132
167
  const batch = {
133
168
  schemaVersion:'anp.activity.v1', batchId:randomUUID(), deviceId:loadConfig().deviceId ?? randomUUID(), generatedAt:new Date().toISOString(), collectorVersion:VERSION,
134
169
  daily,
135
- coverage: sources.map((source) => { const sourceDays = daily.filter((day) => day.source === source); const sourceDates = sourceDays.map((day) => day.date).sort(); const competitiveEligible = sourceDays.some((day) => day.metrics.some((metric) => metric.competitiveEligible)); return { source, coverageStart:sourceDates[0] ?? dates[0], coverageEnd:sourceDates.at(-1) ?? dates.at(-1), lastSyncedAt:new Date().toISOString(), completeness:source === 'codex' || source === 'claude_code' ? 'full' : 'partial', competitiveEligible, note:source === 'cursor' ? 'Estimated from supported local database metadata; excluded from rankings.' : source === 'gemini_cli' || source === 'copilot_cli' ? 'Derived from locally retained CLI telemetry; prompt content is never emitted.' : undefined }; }),
170
+ coverage: sources.map((source) => { const sourceDays = daily.filter((day) => day.source === source); const sourceDates = sourceDays.map((day) => day.date).sort(); const competitiveEligible = sourceDays.some((day) => day.metrics.some((metric) => metric.competitiveEligible)); return { source, coverageStart:sourceDates[0] ?? dates[0], coverageEnd:sourceDates.at(-1) ?? dates.at(-1), lastSyncedAt:new Date().toISOString(), completeness:source === 'codex' ? 'full' : source === 'claude_code' ? claude?.completeness ?? 'unknown' : 'partial', competitiveEligible, note:source === 'claude_code' ? claude?.note : source === 'cursor' ? 'Estimated from supported local database metadata; excluded from rankings.' : source === 'gemini_cli' || source === 'copilot_cli' ? 'Derived from locally retained CLI telemetry; prompt content is never emitted.' : undefined }; }),
136
171
  signature:'pending',
137
172
  };
138
173
  const secret = loadConfig().deviceToken ?? 'preview-only';
@@ -142,13 +177,53 @@ async function createBatch() {
142
177
 
143
178
  function printSources() {
144
179
  console.log('AI Native Profile source check\n');
145
- for (const item of detectedSources()) console.log(`${item.detected ? '●' : '○'} ${sourceNames[item.source].padEnd(20)} ${item.detected ? 'detected' : 'not detected'}`);
180
+ for (const item of detectedSources()) console.log(`${item.detected ? '●' : '○'} ${sourceNames[item.source].padEnd(20)} ${item.detail}`);
146
181
  console.log('\nOnly aggregate activity leaves this device. Run `anp preview` to inspect it.');
147
182
  }
148
183
 
184
+ function confirmFirstSync() {
185
+ if (process.argv.includes('--yes')) return Promise.resolve(true);
186
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return Promise.resolve(false);
187
+ const prompt = createInterface({ input:process.stdin, output:process.stdout });
188
+ return new Promise((resolve) => prompt.question('\nSync these aggregate activity counts now? [Y/n] ', (answer) => {
189
+ prompt.close();
190
+ resolve(!/^n(?:o)?$/i.test(answer.trim()));
191
+ }));
192
+ }
193
+
194
+ function printPrivacyPreview() {
195
+ console.log('\nMay sync: dates, provider/model IDs, token and activity counts, duration, coverage, and collector version.');
196
+ console.log('Never syncs: prompts, responses, source code, commands, paths, repositories, credentials, or environment variables.');
197
+ }
198
+
199
+ async function claimWebConnection(apiUrl, userCode) {
200
+ const response = await fetch(`${apiUrl.replace(/\/$/, '')}/api/v1/device/claim`, {
201
+ method:'POST',
202
+ headers:{ 'content-type':'application/json' },
203
+ body:JSON.stringify({ userCode, deviceName:process.env.USER ?? 'Developer device', collectorVersion:VERSION }),
204
+ });
205
+ const body = await response.json().catch(() => ({}));
206
+ if (!response.ok || !body.deviceToken || !body.deviceId) throw new Error(body.error ?? `Connection failed (${response.status}).`);
207
+ saveConfig({ apiUrl, deviceId:body.deviceId, deviceToken:body.deviceToken });
208
+ console.log('Connected to your AI Native Profile.');
209
+ printSources();
210
+ printPrivacyPreview();
211
+ if (await confirmFirstSync()) {
212
+ await sync();
213
+ console.log('Your card is updated. Return to the browser to see it.');
214
+ } else {
215
+ console.log('\nConnected without syncing. Run `anp preview` to inspect the payload, then `anp sync` when ready.');
216
+ }
217
+ }
218
+
149
219
  async function connect() {
150
220
  const apiUrl = option('api-url') ?? process.env.ANP_API_URL ?? DEFAULT_API_URL;
151
221
  if (!/^https?:\/\//.test(apiUrl)) throw new Error('The API URL must start with https:// or http://.');
222
+ const connectionCode = option('code');
223
+ if (connectionCode) {
224
+ await claimWebConnection(apiUrl, connectionCode);
225
+ return;
226
+ }
152
227
  const response = await fetch(`${apiUrl.replace(/\/$/, '')}/api/v1/device/pair`, { method:'POST', headers:{ 'content-type':'application/json' }, body:JSON.stringify({ deviceName:process.env.USER ?? 'Developer device', collectorVersion:VERSION }) });
153
228
  if (!response.ok) throw new Error(`Pairing failed (${response.status}).`);
154
229
  const pair = await response.json();
@@ -181,7 +256,7 @@ async function sync() {
181
256
  }
182
257
 
183
258
  function help() {
184
- console.log(`AI Native Profile collector ${VERSION}\n\nUsage: anp <command> [options]\n\n connect Pair this device with the cloud dashboard\n --api-url <url> overrides the hosted dashboard\n sources Detect supported coding tools\n preview Print the exact aggregate payload\n sync Send one aggregate batch\n watch Sync every 15 minutes until stopped\n doctor Check configuration and sources\n sessions Explain selected-session sharing\n share Publish a selected sanitized session\n unshare Revoke a shared session\n export Alias for preview\n disconnect Remove the local platform pairing\n`);
259
+ console.log(`AI Native Profile collector ${VERSION}\n\nUsage: anp <command> [options]\n\n connect Pair this device with the cloud dashboard\n --code <code> claims a command created by the signed-in website\n --api-url <url> overrides the hosted dashboard\n --yes approves the first aggregate sync without prompting\n sources Detect supported coding tools\n preview Print the exact aggregate payload\n sync Send one aggregate batch\n watch Sync every 15 minutes until stopped\n doctor Check configuration and sources\n sessions Explain selected-session sharing\n share Publish a selected sanitized session\n unshare Revoke a shared session\n export Alias for preview\n disconnect Remove the local platform pairing\n`);
185
260
  }
186
261
 
187
262
  function option(name) { const index = process.argv.indexOf(`--${name}`); return index >= 0 ? process.argv[index + 1] : undefined; }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "ai-native-profile",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Privacy-first collector for AI Native Profile",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": { "anp": "bin/anp.mjs", "ai-native-profile": "bin/anp.mjs" },
8
- "files": ["bin", "src", "README.md"],
8
+ "files": ["bin", "src/codex-app-server.ts", "src/codex-executable.mjs", "src/claude-usage.mjs", "README.md"],
9
9
  "engines": { "node": ">=22.13.0" },
10
10
  "repository": {
11
11
  "type": "git",
@@ -14,6 +14,6 @@
14
14
  },
15
15
  "homepage": "https://ai-native-profile.vercel.app",
16
16
  "bugs": { "url": "https://github.com/Gaurav890/ai-native-profile/issues" },
17
- "publishConfig": { "access": "public", "provenance": true },
17
+ "publishConfig": { "access": "public" },
18
18
  "keywords": ["developer-tools", "analytics", "activity", "profile", "privacy"]
19
19
  }
@@ -0,0 +1,139 @@
1
+ const datePattern = /^\d{4}-\d{2}-\d{2}$/;
2
+
3
+ function safeCount(value) {
4
+ const count = Number(value ?? 0);
5
+ return Number.isFinite(count) && count >= 0 ? count : 0;
6
+ }
7
+
8
+ function metric(name, value, date) {
9
+ return {
10
+ name,
11
+ value,
12
+ unit: 'count',
13
+ provenance: 'local_exact',
14
+ source: 'claude_code',
15
+ coverageStart: date,
16
+ coverageEnd: date,
17
+ competitiveEligible: true,
18
+ };
19
+ }
20
+
21
+ function usageTotal(usage) {
22
+ return safeCount(usage.inputTokens)
23
+ + safeCount(usage.outputTokens)
24
+ + safeCount(usage.cacheCreationInputTokens)
25
+ + safeCount(usage.cacheReadInputTokens);
26
+ }
27
+
28
+ export function createClaudeSessionAccumulator() {
29
+ return { messages: new Map(), sessionStarts: new Map(), warnings: [] };
30
+ }
31
+
32
+ export function addClaudeSessionEvent(accumulator, event) {
33
+ if (!event || typeof event !== 'object') return;
34
+ const sessionId = String(event.sessionId ?? event.session_id ?? '');
35
+ const timestamp = typeof event.timestamp === 'string' ? event.timestamp : '';
36
+ const date = timestamp.slice(0, 10);
37
+ if (sessionId && datePattern.test(date)) {
38
+ const existing = accumulator.sessionStarts.get(sessionId);
39
+ if (!existing || date < existing) accumulator.sessionStarts.set(sessionId, date);
40
+ }
41
+
42
+ const message = event.message;
43
+ const rawUsage = message?.usage;
44
+ if (!message || !rawUsage || !sessionId || !datePattern.test(date)) return;
45
+ const messageId = String(message.id ?? event.requestId ?? '');
46
+ if (!messageId) return;
47
+
48
+ const usage = {
49
+ inputTokens: safeCount(rawUsage.input_tokens),
50
+ outputTokens: safeCount(rawUsage.output_tokens),
51
+ cacheCreationInputTokens: safeCount(rawUsage.cache_creation_input_tokens),
52
+ cacheReadInputTokens: safeCount(rawUsage.cache_read_input_tokens),
53
+ };
54
+ const candidate = {
55
+ date,
56
+ timestamp,
57
+ model: typeof message.model === 'string' && message.model ? message.model : 'unknown',
58
+ toolCalls: Array.isArray(message.content) ? message.content.filter((block) => block?.type === 'tool_use').length : 0,
59
+ usage,
60
+ };
61
+ const key = `${sessionId}:${messageId}`;
62
+ const existing = accumulator.messages.get(key);
63
+ const candidateTotal = usageTotal(candidate.usage);
64
+ const existingTotal = existing ? usageTotal(existing.usage) : -1;
65
+ if (!existing || candidate.timestamp > existing.timestamp || (candidate.timestamp === existing.timestamp && candidateTotal > existingTotal)) {
66
+ accumulator.messages.set(key, candidate);
67
+ }
68
+ }
69
+
70
+ export function finalizeClaudeSessionUsage(accumulator) {
71
+ const rows = new Map();
72
+ const row = (date) => {
73
+ const existing = rows.get(date);
74
+ if (existing) return existing;
75
+ const created = { sessions: 0, turns: 0, toolCalls: 0, inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, models: {} };
76
+ rows.set(date, created);
77
+ return created;
78
+ };
79
+
80
+ for (const date of accumulator.sessionStarts.values()) row(date).sessions += 1;
81
+ for (const message of accumulator.messages.values()) {
82
+ const current = row(message.date);
83
+ current.turns += 1;
84
+ current.toolCalls += message.toolCalls;
85
+ current.inputTokens += message.usage.inputTokens;
86
+ current.outputTokens += message.usage.outputTokens;
87
+ current.cacheCreationInputTokens += message.usage.cacheCreationInputTokens;
88
+ current.cacheReadInputTokens += message.usage.cacheReadInputTokens;
89
+ current.models[message.model] = (current.models[message.model] ?? 0) + usageTotal(message.usage);
90
+ }
91
+
92
+ const daily = [...rows.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([date, current]) => {
93
+ const tokens = current.inputTokens + current.outputTokens + current.cacheCreationInputTokens + current.cacheReadInputTokens;
94
+ const values = [
95
+ ['sessions', current.sessions],
96
+ ['turns', current.turns],
97
+ ['tool_calls', current.toolCalls],
98
+ ['tokens', tokens],
99
+ ['input_tokens', current.inputTokens],
100
+ ['output_tokens', current.outputTokens],
101
+ ['cache_creation_input_tokens', current.cacheCreationInputTokens],
102
+ ['cache_read_input_tokens', current.cacheReadInputTokens],
103
+ ];
104
+ return {
105
+ date,
106
+ source: 'claude_code',
107
+ category: 'coding',
108
+ metrics: values.filter(([, value]) => value > 0).map(([name, value]) => metric(name, value, date)),
109
+ models: current.models,
110
+ };
111
+ });
112
+ return { daily, messageCount: accumulator.messages.size, warnings: accumulator.warnings };
113
+ }
114
+
115
+ export function parseClaudeStatsCache(stats) {
116
+ if (!stats || typeof stats !== 'object') return { daily: [], undatedCacheTokens: 0 };
117
+ const activityByDate = new Map((stats.dailyActivity ?? []).filter((item) => datePattern.test(item?.date ?? '')).map((item) => [item.date, item]));
118
+ const tokensByDate = new Map((stats.dailyModelTokens ?? []).filter((item) => datePattern.test(item?.date ?? '')).map((item) => [item.date, Object.values(item.tokensByModel ?? {}).reduce((sum, value) => sum + safeCount(value), 0)]));
119
+ const dates = [...new Set([...activityByDate.keys(), ...tokensByDate.keys()])].sort();
120
+ const daily = dates.map((date) => {
121
+ const activity = activityByDate.get(date) ?? {};
122
+ const values = [
123
+ ['sessions', safeCount(activity.sessionCount)],
124
+ ['turns', safeCount(activity.messageCount)],
125
+ ['tool_calls', safeCount(activity.toolCallCount)],
126
+ ['tokens', safeCount(tokensByDate.get(date))],
127
+ ];
128
+ return { date, source: 'claude_code', category: 'coding', metrics: values.filter(([, value]) => value > 0).map(([name, value]) => metric(name, value, date)) };
129
+ });
130
+ const modelUsage = Object.values(stats.modelUsage ?? {});
131
+ const undatedCacheTokens = modelUsage.reduce((sum, usage) => sum + safeCount(usage?.cacheReadInputTokens) + safeCount(usage?.cacheCreationInputTokens), 0);
132
+ return { daily, undatedCacheTokens };
133
+ }
134
+
135
+ export function mergeClaudeUsage(sessionDaily, legacyDaily) {
136
+ const merged = new Map(legacyDaily.map((day) => [day.date, day]));
137
+ for (const day of sessionDaily) merged.set(day.date, day);
138
+ return [...merged.values()].sort((left, right) => left.date.localeCompare(right.date));
139
+ }
@@ -1,10 +1,16 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
+ import { resolveCodexExecutable } from './codex-executable.mjs';
3
4
 
4
5
  type RpcResponse = { id?: number; result?: unknown; error?: { message?: string } };
5
6
 
6
- export async function readCodexAccountUsage(timeoutMs = 8_000): Promise<unknown> {
7
- const child = spawn('codex', ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'] });
7
+ export async function readCodexAccountUsage(timeoutMs = 8_000, executable = resolveCodexExecutable()): Promise<unknown> {
8
+ if (!executable) throw new Error('Codex App Server is unavailable. Install the Codex CLI or set ANP_CODEX_PATH to its executable.');
9
+ const child = spawn(executable, ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'] });
10
+ const started = new Promise<void>((resolve, reject) => {
11
+ child.once('spawn', resolve);
12
+ child.once('error', (error) => reject(new Error(`Could not start Codex App Server: ${error.message}`)));
13
+ });
8
14
  const lines = createInterface({ input: child.stdout });
9
15
  const pending = new Map<number, (response: RpcResponse) => void>();
10
16
  lines.on('line', (line) => {
@@ -24,7 +30,8 @@ export async function readCodexAccountUsage(timeoutMs = 8_000): Promise<unknown>
24
30
  child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
25
31
  });
26
32
  try {
27
- await request(1, 'initialize', { clientInfo: { name: 'ai_native_profile', title: 'AI Native Profile', version: '0.1.0' } });
33
+ await started;
34
+ await request(1, 'initialize', { clientInfo: { name: 'ai_native_profile', title: 'AI Native Profile', version: '0.2.0' } });
28
35
  child.stdin.write(`${JSON.stringify({ method: 'initialized', params: {} })}\n`);
29
36
  return await request(2, 'account/usage/read');
30
37
  } finally {
@@ -0,0 +1,34 @@
1
+ import { accessSync, constants } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { delimiter, join } from 'node:path';
4
+
5
+ /**
6
+ * @param {{ env?: Record<string, string | undefined>; platform?: NodeJS.Platform; home?: string }} [options]
7
+ */
8
+ export function codexExecutableCandidates({ env = process.env, platform = process.platform, home = homedir() } = {}) {
9
+ const names = platform === 'win32' ? ['codex.exe', 'codex.cmd', 'codex.bat', 'codex'] : ['codex'];
10
+ const pathCandidates = String(env.PATH ?? '').split(delimiter).filter(Boolean).flatMap((directory) => names.map((name) => join(directory, name)));
11
+ const appCandidates = platform === 'darwin' ? [
12
+ '/Applications/ChatGPT.app/Contents/Resources/codex',
13
+ '/Applications/Codex.app/Contents/Resources/codex',
14
+ join(home, 'Applications', 'ChatGPT.app', 'Contents', 'Resources', 'codex'),
15
+ join(home, 'Applications', 'Codex.app', 'Contents', 'Resources', 'codex'),
16
+ ] : [];
17
+ return [...new Set([env.ANP_CODEX_PATH, ...pathCandidates, ...appCandidates].filter(Boolean))];
18
+ }
19
+
20
+ /**
21
+ * @param {{ env?: Record<string, string | undefined>; platform?: NodeJS.Platform; home?: string; canExecute?: (candidate: string) => boolean }} [options]
22
+ */
23
+ export function resolveCodexExecutable(options = {}) {
24
+ const platform = options.platform ?? process.platform;
25
+ const canExecute = options.canExecute ?? ((candidate) => {
26
+ try {
27
+ accessSync(candidate, platform === 'win32' ? constants.F_OK : constants.X_OK);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ });
33
+ return codexExecutableCandidates(options).find(canExecute) ?? null;
34
+ }