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 +20 -1
- package/bin/anp.mjs +96 -21
- package/package.json +3 -3
- package/src/claude-usage.mjs +139 -0
- package/src/codex-app-server.ts +10 -3
- package/src/codex-executable.mjs +34 -0
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 |
|
|
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.
|
|
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]) =>
|
|
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
|
-
|
|
38
|
-
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
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'
|
|
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.
|
|
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.
|
|
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"
|
|
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
|
+
}
|
package/src/codex-app-server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|