ai-native-profile 0.1.1 → 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 +12 -1
- package/bin/anp.mjs +55 -20
- 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
|
@@ -46,7 +46,7 @@ npx --yes ai-native-profile@latest watch
|
|
|
46
46
|
| Source | Collection path | Trust label |
|
|
47
47
|
|---|---|---|
|
|
48
48
|
| Codex | Official local App Server account usage | Official account source |
|
|
49
|
-
| Claude Code |
|
|
49
|
+
| Claude Code | Deduplicated local session usage plus non-overlapping statistics-cache history | Local exact for retained records; partial lifetime coverage |
|
|
50
50
|
| Cursor | Supported local database metadata | Estimated; excluded from rankings |
|
|
51
51
|
| Gemini CLI | Locally retained CLI telemetry | Locally derived |
|
|
52
52
|
| GitHub Copilot CLI | Locally retained CLI activity | Locally derived |
|
|
@@ -59,6 +59,7 @@ Normal synchronization may include:
|
|
|
59
59
|
|
|
60
60
|
- Dates and provider/model identifiers
|
|
61
61
|
- Token, session, turn, and tool-call counts
|
|
62
|
+
- Input, output, cache-write, and cache-read token counts when a provider reports them
|
|
62
63
|
- Duration, coverage, freshness, and provenance
|
|
63
64
|
- Collector version and an anonymous device identifier
|
|
64
65
|
|
|
@@ -96,6 +97,16 @@ Use a self-hosted deployment instead of the managed service:
|
|
|
96
97
|
npx --yes ai-native-profile@latest connect --api-url https://your-domain.example
|
|
97
98
|
```
|
|
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
|
+
|
|
99
110
|
## What the evidence means
|
|
100
111
|
|
|
101
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.
|
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.
|
|
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]) =>
|
|
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,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.
|
|
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.
|
|
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", "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.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
|
+
}
|