ai-native-profile 0.1.0 → 0.1.3

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 ADDED
@@ -0,0 +1,126 @@
1
+ # AI Native Profile Collector
2
+
3
+ [![npm version](https://img.shields.io/npm/v/ai-native-profile.svg)](https://www.npmjs.com/package/ai-native-profile)
4
+ [![license](https://img.shields.io/npm/l/ai-native-profile.svg)](https://github.com/Gaurav890/ai-native-profile/blob/main/LICENSE)
5
+
6
+ Connect local AI coding activity to a private [AI Native Profile](https://ai-native-profile.vercel.app), then choose what to publish on your profile or GitHub README.
7
+
8
+ The collector runs directly with `npx`; no global installation is required. It discovers supported tools, builds aggregate activity locally, lets you inspect the exact payload, and syncs only after you pair and approve the device.
9
+
10
+ ## Quick start
11
+
12
+ Check which coding tools are available:
13
+
14
+ ```bash
15
+ npx --yes ai-native-profile@latest sources
16
+ ```
17
+
18
+ Inspect the exact aggregate payload before sending anything:
19
+
20
+ ```bash
21
+ npx --yes ai-native-profile@latest preview
22
+ ```
23
+
24
+ Pair this computer with your cloud profile:
25
+
26
+ ```bash
27
+ npx --yes ai-native-profile@latest connect
28
+ ```
29
+
30
+ The command prints a short-lived verification URL and code. Open the URL, sign in with GitHub, and approve the device. Then run the first sync:
31
+
32
+ ```bash
33
+ npx --yes ai-native-profile@latest sync
34
+ ```
35
+
36
+ To keep syncing every 15 minutes while the command is running:
37
+
38
+ ```bash
39
+ npx --yes ai-native-profile@latest watch
40
+ ```
41
+
42
+ `watch` runs in the foreground and stops when the process stops. The collector never installs a hidden background service.
43
+
44
+ ## Supported coding sources
45
+
46
+ | Source | Collection path | Trust label |
47
+ |---|---|---|
48
+ | Codex | Official local App Server account usage | Official account source |
49
+ | Claude Code | Deduplicated local session usage plus non-overlapping statistics-cache history | Local exact for retained records; partial lifetime coverage |
50
+ | Cursor | Supported local database metadata | Estimated; excluded from rankings |
51
+ | Gemini CLI | Locally retained CLI telemetry | Locally derived |
52
+ | GitHub Copilot CLI | Locally retained CLI activity | Locally derived |
53
+
54
+ ChatGPT Web and Claude Web are connected separately through browser-local official export import or explicit MCP activity. This collector does not read browser conversations or provider cookies.
55
+
56
+ ## Privacy boundary
57
+
58
+ Normal synchronization may include:
59
+
60
+ - Dates and provider/model identifiers
61
+ - Token, session, turn, and tool-call counts
62
+ - Input, output, cache-write, and cache-read token counts when a provider reports them
63
+ - Duration, coverage, freshness, and provenance
64
+ - Collector version and an anonymous device identifier
65
+
66
+ It never includes:
67
+
68
+ - Prompts or responses
69
+ - Source code
70
+ - Tool arguments or outputs
71
+ - Shell commands
72
+ - Repository names or file paths
73
+ - Conversation titles or email addresses
74
+ - Cookies, provider credentials, access tokens, or environment variables
75
+
76
+ Run `preview` whenever you want to inspect the exact aggregate payload. Pairing credentials are stored locally in `~/.config/ai-native-profile/config.json` with user-only file permissions.
77
+
78
+ ## Commands
79
+
80
+ | Command | Purpose |
81
+ |---|---|
82
+ | `connect` | Pair this device with the hosted profile |
83
+ | `sources` | Detect supported local coding tools |
84
+ | `preview` | Print the exact aggregate payload without sending it |
85
+ | `sync` | Send one approved aggregate batch |
86
+ | `watch` | Sync immediately, then every 15 minutes until stopped |
87
+ | `doctor` | Check local sources and pairing status |
88
+ | `sessions` | List deliberately published session stories |
89
+ | `share` | Preview or publish a manually supplied sanitized story |
90
+ | `unshare` | Revoke a published story |
91
+ | `export` | Alias for `preview` |
92
+ | `disconnect` | Remove the local platform pairing |
93
+
94
+ Use a self-hosted deployment instead of the managed service:
95
+
96
+ ```bash
97
+ npx --yes ai-native-profile@latest connect --api-url https://your-domain.example
98
+ ```
99
+
100
+ ## Troubleshooting Codex discovery
101
+
102
+ 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:
103
+
104
+ ```bash
105
+ ANP_CODEX_PATH=/path/to/codex npx --yes ai-native-profile@latest preview
106
+ ```
107
+
108
+ 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.
109
+
110
+ ## What the evidence means
111
+
112
+ 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.
113
+
114
+ Conversation imports, MCP observations, manual stories, and estimated metrics never affect competitive scores.
115
+
116
+ ## Links
117
+
118
+ - [Create a profile](https://ai-native-profile.vercel.app)
119
+ - [Source code and issue tracker](https://github.com/Gaurav890/ai-native-profile)
120
+ - [Privacy boundary](https://github.com/Gaurav890/ai-native-profile/blob/main/docs/privacy.md)
121
+ - [Provider capability matrix](https://github.com/Gaurav890/ai-native-profile/blob/main/docs/providers.md)
122
+ - [Scoring methodology](https://github.com/Gaurav890/ai-native-profile/blob/main/docs/methodology.md)
123
+
124
+ ## License
125
+
126
+ MIT. Provider names are compatibility labels. AI Native Profile is provider-neutral and is not endorsed by the providers it supports.
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.0';
11
+ const VERSION = '0.1.3';
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,7 +177,7 @@ 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
 
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "ai-native-profile",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
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"],
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.1.3' } });
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
+ }