ai-native-profile 0.1.3 → 0.2.1

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
@@ -27,26 +27,34 @@ Pair this computer with your cloud profile:
27
27
  npx --yes ai-native-profile@latest connect
28
28
  ```
29
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:
30
+ The command prints a short-lived verification URL and code. Open the URL, sign in with GitHub, and approve the device. The collector then shows the privacy boundary and asks before the first sync. You can also run a one-off sync later:
31
31
 
32
32
  ```bash
33
33
  npx --yes ai-native-profile@latest sync
34
34
  ```
35
35
 
36
- To keep syncing every 15 minutes while the command is running:
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
37
 
38
38
  ```bash
39
- npx --yes ai-native-profile@latest watch
39
+ npx --yes ai-native-profile@latest connect --code ABCD1234
40
40
  ```
41
41
 
42
- `watch` runs in the foreground and stops when the process stops. The collector never installs a hidden background service.
42
+ That command is already associated with the signed-in profile. It detects sources, displays the privacy boundary, asks before the first sync, and updates the open card without requiring the code to be entered again. After that first sync it offers automatic 15-minute refreshes; approving them installs a visible user-level macOS LaunchAgent or Linux systemd timer.
43
+
44
+ Enable persistent 15-minute updates later:
45
+
46
+ ```bash
47
+ npx --yes ai-native-profile@latest watch --install
48
+ ```
49
+
50
+ Check their status with `doctor`, or disable them with `watch --uninstall`. Plain `watch` remains available for a foreground-only loop that stops with the process. The scheduled collector is copied under `~/.config/ai-native-profile/collector`; no provider credentials or source data are copied.
43
51
 
44
52
  ## Supported coding sources
45
53
 
46
54
  | Source | Collection path | Trust label |
47
55
  |---|---|---|
48
56
  | 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 |
57
+ | Claude Code | Deduplicated local session usage plus non-overlapping statistics-cache history | Local exact for retained records; estimated daily splits for cache-inclusive statistics history; 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 |
@@ -83,13 +91,15 @@ Run `preview` whenever you want to inspect the exact aggregate payload. Pairing
83
91
  | `sources` | Detect supported local coding tools |
84
92
  | `preview` | Print the exact aggregate payload without sending it |
85
93
  | `sync` | Send one approved aggregate batch |
86
- | `watch` | Sync immediately, then every 15 minutes until stopped |
87
- | `doctor` | Check local sources and pairing status |
94
+ | `watch` | Sync every 15 minutes in the foreground |
95
+ | `watch --install` | Enable persistent 15-minute updates after Terminal closes |
96
+ | `watch --uninstall` | Disable persistent updates |
97
+ | `doctor` | Check local sources, pairing, and automatic-sync status |
88
98
  | `sessions` | List deliberately published session stories |
89
99
  | `share` | Preview or publish a manually supplied sanitized story |
90
100
  | `unshare` | Revoke a published story |
91
101
  | `export` | Alias for `preview` |
92
- | `disconnect` | Remove the local platform pairing |
102
+ | `disconnect` | Disable automatic updates and remove the local platform pairing |
93
103
 
94
104
  Use a self-hosted deployment instead of the managed service:
95
105
 
package/bin/anp.mjs CHANGED
@@ -1,15 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHmac, randomUUID } from 'node:crypto';
3
3
  import { spawn } from 'node:child_process';
4
- import { createReadStream, existsSync, readFileSync, mkdirSync, writeFileSync, rmSync, readdirSync, statSync } from 'node:fs';
4
+ import { createReadStream, existsSync, readFileSync, mkdirSync, writeFileSync, rmSync, statSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
- import { join } from 'node:path';
6
+ import { basename, dirname, join } from 'node:path';
7
7
  import { createInterface } from 'node:readline';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { backgroundSyncStatus, installBackgroundSync, uninstallBackgroundSync } from '../src/background-sync.mjs';
8
10
  import { resolveCodexExecutable } from '../src/codex-executable.mjs';
9
11
  import { addClaudeSessionEvent, createClaudeSessionAccumulator, finalizeClaudeSessionUsage, mergeClaudeUsage, parseClaudeStatsCache } from '../src/claude-usage.mjs';
12
+ import { discoverLocalDataFiles } from '../src/file-discovery.mjs';
10
13
 
11
- const VERSION = '0.1.3';
14
+ const VERSION = '0.2.1';
12
15
  const DEFAULT_API_URL = 'https://ai-native-profile.vercel.app';
16
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
13
17
  const configDir = join(homedir(), '.config', 'ai-native-profile');
14
18
  const configFile = join(configDir, 'config.json');
15
19
  const paths = {
@@ -50,12 +54,13 @@ async function claudeAggregate() {
50
54
  const accumulator = createClaudeSessionAccumulator();
51
55
  let malformedLines = 0;
52
56
  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))) {
57
+ const discovery = discoverLocalDataFiles(projectRoots);
58
+ for (const sessionFile of discovery.files.filter((path) => /\.jsonl$/i.test(path))) {
54
59
  try {
55
60
  const lines = createInterface({ input:createReadStream(sessionFile, { encoding:'utf8' }), crlfDelay:Infinity });
56
61
  for await (const line of lines) {
57
62
  if (!line.trim()) continue;
58
- try { addClaudeSessionEvent(accumulator, JSON.parse(line)); }
63
+ try { addClaudeSessionEvent(accumulator, JSON.parse(line), { sessionId:basename(sessionFile, '.jsonl') }); }
59
64
  catch { malformedLines += 1; }
60
65
  }
61
66
  } catch { malformedLines += 1; }
@@ -64,24 +69,14 @@ async function claudeAggregate() {
64
69
  const daily = mergeClaudeUsage(session.daily, legacy.daily);
65
70
  if (!daily.length) return null;
66
71
  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.`);
72
+ if (legacy.estimatedDates) notes.push(`${legacy.estimatedDates} stats-cache day${legacy.estimatedDates === 1 ? '' : 's'} include cache and base token components allocated by reported daily model activity; covered-period totals are preserved while those daily splits are estimated.`);
73
+ if (discovery.truncated) notes.push('Local history exceeded the 10,000-file safety limit; the result is partial.');
69
74
  if (malformedLines) notes.push(`${malformedLines} malformed local record${malformedLines === 1 ? ' was' : 's were'} skipped.`);
70
75
  return { daily, completeness:'partial', note:notes.join(' ') };
71
76
  }
72
77
 
73
- function localFiles(candidates, limit = 300) {
74
- const files = [];
75
- const visit = (path) => {
76
- if (files.length >= limit || !existsSync(path)) return;
77
- let info; try { info = statSync(path); } catch { return; }
78
- if (info.isFile()) { if (/\.(jsonl|json)$/i.test(path)) files.push(path); return; }
79
- if (!info.isDirectory()) return;
80
- let entries = []; try { entries = readdirSync(path); } catch { return; }
81
- for (const entry of entries) visit(join(path, entry));
82
- };
83
- for (const path of candidates) visit(path);
84
- return files;
78
+ function localFiles(candidates) {
79
+ return discoverLocalDataFiles(candidates).files;
85
80
  }
86
81
 
87
82
  function genericLocalAggregate(source, candidates) {
@@ -181,9 +176,80 @@ function printSources() {
181
176
  console.log('\nOnly aggregate activity leaves this device. Run `anp preview` to inspect it.');
182
177
  }
183
178
 
179
+ function confirmFirstSync() {
180
+ if (process.argv.includes('--yes')) return Promise.resolve(true);
181
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return Promise.resolve(false);
182
+ const prompt = createInterface({ input:process.stdin, output:process.stdout });
183
+ return new Promise((resolve) => prompt.question('\nSync these aggregate activity counts now? [Y/n] ', (answer) => {
184
+ prompt.close();
185
+ resolve(!/^n(?:o)?$/i.test(answer.trim()));
186
+ }));
187
+ }
188
+
189
+ function confirmAutomaticSync() {
190
+ if (process.argv.includes('--yes')) return Promise.resolve(true);
191
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return Promise.resolve(false);
192
+ const prompt = createInterface({ input:process.stdin, output:process.stdout });
193
+ return new Promise((resolve) => prompt.question('\nKeep this profile updated automatically every 15 minutes? [Y/n] ', (answer) => {
194
+ prompt.close();
195
+ resolve(!/^n(?:o)?$/i.test(answer.trim()));
196
+ }));
197
+ }
198
+
199
+ function printPrivacyPreview() {
200
+ console.log('\nMay sync: dates, provider/model IDs, token and activity counts, duration, coverage, and collector version.');
201
+ console.log('Never syncs: prompts, responses, source code, commands, paths, repositories, credentials, or environment variables.');
202
+ }
203
+
204
+ function enableAutomaticSync() {
205
+ const result = installBackgroundSync({ sourceRoot:packageRoot });
206
+ if (!result.installed) {
207
+ console.log(`\n${result.reason} Run \`anp watch\` when you want foreground updates.`);
208
+ return false;
209
+ }
210
+ saveConfig({ ...loadConfig(), backgroundSync: { enabled:true, platform:result.platform, intervalSeconds:result.intervalSeconds, installedAt:new Date().toISOString() } });
211
+ console.log('\nAutomatic sync is on. This computer will refresh your aggregates every 15 minutes, including after Terminal closes.');
212
+ return true;
213
+ }
214
+
215
+ async function finishConnection(connectedMessage) {
216
+ console.log(connectedMessage);
217
+ printSources();
218
+ printPrivacyPreview();
219
+ if (!(await confirmFirstSync())) {
220
+ console.log('\nConnected without syncing. Run `anp preview` to inspect the payload, then `anp sync` when ready.');
221
+ return;
222
+ }
223
+ await sync();
224
+ console.log('Your card is updated. Return to the browser to see it.');
225
+ if (await confirmAutomaticSync()) {
226
+ try { enableAutomaticSync(); }
227
+ catch (error) { console.error(`\nAutomatic sync could not be enabled: ${error instanceof Error ? error.message : String(error)}\nRun \`anp watch\` for foreground updates.`); }
228
+ } else {
229
+ console.log('\nAutomatic sync is off. Run `anp watch --install` later to enable it.');
230
+ }
231
+ }
232
+
233
+ async function claimWebConnection(apiUrl, userCode) {
234
+ const response = await fetch(`${apiUrl.replace(/\/$/, '')}/api/v1/device/claim`, {
235
+ method:'POST',
236
+ headers:{ 'content-type':'application/json' },
237
+ body:JSON.stringify({ userCode, deviceName:process.env.USER ?? 'Developer device', collectorVersion:VERSION }),
238
+ });
239
+ const body = await response.json().catch(() => ({}));
240
+ if (!response.ok || !body.deviceToken || !body.deviceId) throw new Error(body.error ?? `Connection failed (${response.status}).`);
241
+ saveConfig({ apiUrl, deviceId:body.deviceId, deviceToken:body.deviceToken });
242
+ await finishConnection('Connected to your AI Native Profile.');
243
+ }
244
+
184
245
  async function connect() {
185
246
  const apiUrl = option('api-url') ?? process.env.ANP_API_URL ?? DEFAULT_API_URL;
186
247
  if (!/^https?:\/\//.test(apiUrl)) throw new Error('The API URL must start with https:// or http://.');
248
+ const connectionCode = option('code');
249
+ if (connectionCode) {
250
+ await claimWebConnection(apiUrl, connectionCode);
251
+ return;
252
+ }
187
253
  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 }) });
188
254
  if (!response.ok) throw new Error(`Pairing failed (${response.status}).`);
189
255
  const pair = await response.json();
@@ -198,7 +264,7 @@ async function connect() {
198
264
  const tokenBody = await tokenResponse.json().catch(() => ({}));
199
265
  if (tokenResponse.ok && tokenBody.deviceToken) {
200
266
  saveConfig({ apiUrl, deviceId:pair.deviceId, deviceToken:tokenBody.deviceToken });
201
- console.log('Connected. Automatic sync remains off until you run `anp watch`.');
267
+ await finishConnection('Connected to your AI Native Profile.');
202
268
  return;
203
269
  }
204
270
  throw new Error(tokenBody.error ?? `Pairing failed (${tokenResponse.status}).`);
@@ -216,7 +282,7 @@ async function sync() {
216
282
  }
217
283
 
218
284
  function help() {
219
- 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`);
285
+ 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 sync and automatic updates 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 --install enables persistent 15-minute updates\n --uninstall disables persistent updates\n doctor Check configuration, sources, and automatic sync\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 Disable automatic sync and remove the local pairing\n`);
220
286
  }
221
287
 
222
288
  function option(name) { const index = process.argv.indexOf(`--${name}`); return index >= 0 ? process.argv[index + 1] : undefined; }
@@ -239,11 +305,13 @@ try {
239
305
  else if (command === 'preview' || command === 'export') console.log(JSON.stringify(await createBatch(), null, 2));
240
306
  else if (command === 'connect') await connect();
241
307
  else if (command === 'sync') await sync();
308
+ else if (command === 'watch' && process.argv.includes('--install')) { if (!loadConfig().deviceToken) throw new Error('Connect this device first.'); enableAutomaticSync(); }
309
+ else if (command === 'watch' && process.argv.includes('--uninstall')) { uninstallBackgroundSync(); saveConfig({ ...loadConfig(), backgroundSync:{ enabled:false, disabledAt:new Date().toISOString() } }); console.log('Automatic sync is off.'); }
242
310
  else if (command === 'watch') { await sync(); console.log('Watching every 15 minutes. Press Ctrl+C to stop.'); setInterval(() => sync().catch((error) => console.error(error.message)), 15 * 60 * 1000); }
243
- else if (command === 'doctor') { printSources(); const config = loadConfig(); console.log(`\nCloud pairing: ${config.deviceId ? 'configured' : 'not configured'}`); }
311
+ else if (command === 'doctor') { printSources(); const config = loadConfig(); const automatic = backgroundSyncStatus(); console.log(`\nCloud pairing: ${config.deviceId ? 'configured' : 'not configured'}`); console.log(`Automatic sync: ${automatic.installed ? 'on (every 15 minutes)' : automatic.supported ? 'off' : 'unsupported on this operating system'}`); }
244
312
  else if (command === 'sessions') await listSessions();
245
313
  else if (command === 'share') await shareSession();
246
314
  else if (command === 'unshare') await unshareSession();
247
- else if (command === 'disconnect') { rmSync(configFile, { force:true }); console.log('Removed the local platform pairing. Provider sign-ins and source data were not changed.'); }
315
+ else if (command === 'disconnect') { uninstallBackgroundSync(); rmSync(configFile, { force:true }); console.log('Disabled automatic sync and removed the local platform pairing. Provider sign-ins and source data were not changed.'); }
248
316
  else { console.error(`Unknown command: ${command}\n`); help(); process.exitCode = 1; }
249
317
  } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "ai-native-profile",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
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/codex-app-server.ts", "src/codex-executable.mjs", "src/claude-usage.mjs", "README.md"],
8
+ "files": ["bin", "src/background-sync.mjs", "src/codex-app-server.ts", "src/codex-executable.mjs", "src/claude-usage.mjs", "src/file-discovery.mjs", "README.md"],
9
9
  "engines": { "node": ">=22.13.0" },
10
10
  "repository": {
11
11
  "type": "git",
@@ -0,0 +1,179 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ export const BACKGROUND_INTERVAL_SECONDS = 15 * 60;
7
+ export const BACKGROUND_LABEL = 'dev.ainativeprofile.sync';
8
+
9
+ function xml(value) {
10
+ return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
11
+ }
12
+
13
+ function systemdQuote(value) {
14
+ return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
15
+ }
16
+
17
+ export function backgroundSyncPaths({ homeDir = homedir(), platform = process.platform } = {}) {
18
+ const configDir = join(homeDir, '.config', 'ai-native-profile');
19
+ const collectorRoot = join(configDir, 'collector');
20
+ if (platform === 'darwin') {
21
+ return {
22
+ configDir,
23
+ collectorRoot,
24
+ definition: join(homeDir, 'Library', 'LaunchAgents', `${BACKGROUND_LABEL}.plist`),
25
+ log: join(configDir, 'sync.log'),
26
+ };
27
+ }
28
+ if (platform === 'linux') {
29
+ const systemdDir = join(homeDir, '.config', 'systemd', 'user');
30
+ return {
31
+ configDir,
32
+ collectorRoot,
33
+ definition: join(systemdDir, 'ai-native-profile-sync.timer'),
34
+ serviceDefinition: join(systemdDir, 'ai-native-profile-sync.service'),
35
+ log: null,
36
+ };
37
+ }
38
+ return { configDir, collectorRoot, definition: null, serviceDefinition: null, log: null };
39
+ }
40
+
41
+ export function renderLaunchAgent({ nodePath, cliPath, logPath, intervalSeconds = BACKGROUND_INTERVAL_SECONDS }) {
42
+ return `<?xml version="1.0" encoding="UTF-8"?>
43
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
44
+ <plist version="1.0">
45
+ <dict>
46
+ <key>Label</key><string>${BACKGROUND_LABEL}</string>
47
+ <key>ProgramArguments</key>
48
+ <array><string>${xml(nodePath)}</string><string>${xml(cliPath)}</string><string>sync</string><string>--background</string></array>
49
+ <key>RunAtLoad</key><true/>
50
+ <key>StartInterval</key><integer>${intervalSeconds}</integer>
51
+ <key>ProcessType</key><string>Background</string>
52
+ <key>StandardOutPath</key><string>${xml(logPath)}</string>
53
+ <key>StandardErrorPath</key><string>${xml(logPath)}</string>
54
+ </dict>
55
+ </plist>
56
+ `;
57
+ }
58
+
59
+ export function renderSystemdUnits({ nodePath, cliPath, intervalSeconds = BACKGROUND_INTERVAL_SECONDS }) {
60
+ const service = `[Unit]
61
+ Description=Refresh AI Native Profile aggregates
62
+
63
+ [Service]
64
+ Type=oneshot
65
+ ExecStart=${systemdQuote(nodePath)} ${systemdQuote(cliPath)} sync --background
66
+ `;
67
+ const timer = `[Unit]
68
+ Description=Refresh AI Native Profile every 15 minutes
69
+
70
+ [Timer]
71
+ OnBootSec=2min
72
+ OnUnitActiveSec=${intervalSeconds}s
73
+ Persistent=true
74
+
75
+ [Install]
76
+ WantedBy=timers.target
77
+ `;
78
+ return { service, timer };
79
+ }
80
+
81
+ function run(command, args, { allowFailure = false, runner = spawnSync } = {}) {
82
+ const result = runner(command, args, { encoding: 'utf8', stdio: 'pipe' });
83
+ if (!allowFailure && (result.error || result.status !== 0)) {
84
+ const detail = result.error?.message ?? String(result.stderr || result.stdout || `exit ${result.status}`).trim();
85
+ throw new Error(`${command} failed: ${detail}`);
86
+ }
87
+ return result;
88
+ }
89
+
90
+ function installCollectorBundle({ sourceRoot, collectorRoot }) {
91
+ const files = [
92
+ ['bin/anp.mjs', 'bin/anp.mjs'],
93
+ ['src/background-sync.mjs', 'src/background-sync.mjs'],
94
+ ['src/claude-usage.mjs', 'src/claude-usage.mjs'],
95
+ ['src/codex-executable.mjs', 'src/codex-executable.mjs'],
96
+ ['src/file-discovery.mjs', 'src/file-discovery.mjs'],
97
+ ];
98
+ for (const [source, destination] of files) {
99
+ const target = join(collectorRoot, destination);
100
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
101
+ copyFileSync(join(sourceRoot, source), target);
102
+ }
103
+ return join(collectorRoot, 'bin', 'anp.mjs');
104
+ }
105
+
106
+ export function installBackgroundSync({
107
+ sourceRoot,
108
+ homeDir = homedir(),
109
+ platform = process.platform,
110
+ nodePath = process.execPath,
111
+ uid = typeof process.getuid === 'function' ? process.getuid() : null,
112
+ runner = spawnSync,
113
+ } = {}) {
114
+ if (!sourceRoot) throw new Error('The collector source directory is required.');
115
+ if (!['darwin', 'linux'].includes(platform)) {
116
+ return { installed: false, platform, reason: 'Automatic background sync currently supports macOS and Linux.' };
117
+ }
118
+ const paths = backgroundSyncPaths({ homeDir, platform });
119
+ const cliPath = installCollectorBundle({ sourceRoot, collectorRoot: paths.collectorRoot });
120
+ mkdirSync(paths.configDir, { recursive: true, mode: 0o700 });
121
+
122
+ if (platform === 'darwin') {
123
+ if (uid === null) throw new Error('Could not determine the current macOS user.');
124
+ mkdirSync(dirname(paths.definition), { recursive: true });
125
+ writeFileSync(paths.definition, renderLaunchAgent({ nodePath, cliPath, logPath: paths.log }), { mode: 0o600 });
126
+ const domain = `gui/${uid}`;
127
+ run('launchctl', ['bootout', `${domain}/${BACKGROUND_LABEL}`], { allowFailure: true, runner });
128
+ run('launchctl', ['bootstrap', domain, paths.definition], { runner });
129
+ return { installed: true, platform, intervalSeconds: BACKGROUND_INTERVAL_SECONDS, definition: paths.definition };
130
+ }
131
+
132
+ if (platform === 'linux') {
133
+ mkdirSync(dirname(paths.definition), { recursive: true, mode: 0o700 });
134
+ const units = renderSystemdUnits({ nodePath, cliPath });
135
+ writeFileSync(paths.serviceDefinition, units.service, { mode: 0o600 });
136
+ writeFileSync(paths.definition, units.timer, { mode: 0o600 });
137
+ run('systemctl', ['--user', 'daemon-reload'], { runner });
138
+ run('systemctl', ['--user', 'enable', '--now', basename(paths.definition)], { runner });
139
+ return { installed: true, platform, intervalSeconds: BACKGROUND_INTERVAL_SECONDS, definition: paths.definition };
140
+ }
141
+ }
142
+
143
+ export function uninstallBackgroundSync({
144
+ homeDir = homedir(),
145
+ platform = process.platform,
146
+ uid = typeof process.getuid === 'function' ? process.getuid() : null,
147
+ runner = spawnSync,
148
+ } = {}) {
149
+ const paths = backgroundSyncPaths({ homeDir, platform });
150
+ if (platform === 'darwin' && paths.definition) {
151
+ if (uid !== null) run('launchctl', ['bootout', `gui/${uid}/${BACKGROUND_LABEL}`], { allowFailure: true, runner });
152
+ rmSync(paths.definition, { force: true });
153
+ } else if (platform === 'linux' && paths.definition) {
154
+ run('systemctl', ['--user', 'disable', '--now', basename(paths.definition)], { allowFailure: true, runner });
155
+ rmSync(paths.definition, { force: true });
156
+ rmSync(paths.serviceDefinition, { force: true });
157
+ run('systemctl', ['--user', 'daemon-reload'], { allowFailure: true, runner });
158
+ }
159
+ rmSync(paths.collectorRoot, { recursive: true, force: true });
160
+ return { installed: false, platform };
161
+ }
162
+
163
+ export function backgroundSyncStatus({ homeDir = homedir(), platform = process.platform } = {}) {
164
+ const paths = backgroundSyncPaths({ homeDir, platform });
165
+ if (!paths.definition) return { supported: false, installed: false, platform };
166
+ return {
167
+ supported: true,
168
+ installed: existsSync(paths.definition),
169
+ platform,
170
+ definition: paths.definition,
171
+ intervalSeconds: BACKGROUND_INTERVAL_SECONDS,
172
+ };
173
+ }
174
+
175
+ export function readBackgroundLog({ homeDir = homedir(), platform = process.platform } = {}) {
176
+ const { log } = backgroundSyncPaths({ homeDir, platform });
177
+ if (!log || !existsSync(log)) return '';
178
+ return readFileSync(log, 'utf8');
179
+ }
@@ -5,16 +5,29 @@ function safeCount(value) {
5
5
  return Number.isFinite(count) && count >= 0 ? count : 0;
6
6
  }
7
7
 
8
- function metric(name, value, date) {
8
+ function metric(name, value, date, provenance = 'local_exact') {
9
9
  return {
10
10
  name,
11
11
  value,
12
12
  unit: 'count',
13
- provenance: 'local_exact',
13
+ provenance,
14
14
  source: 'claude_code',
15
15
  coverageStart: date,
16
16
  coverageEnd: date,
17
- competitiveEligible: true,
17
+ competitiveEligible: provenance !== 'estimated',
18
+ };
19
+ }
20
+
21
+ function usageValue(usage, snakeName, camelName) {
22
+ return safeCount(usage?.[snakeName] ?? usage?.[camelName]);
23
+ }
24
+
25
+ function normalizeUsage(usage) {
26
+ return {
27
+ inputTokens: usageValue(usage, 'input_tokens', 'inputTokens'),
28
+ outputTokens: usageValue(usage, 'output_tokens', 'outputTokens'),
29
+ cacheCreationInputTokens: usageValue(usage, 'cache_creation_input_tokens', 'cacheCreationInputTokens'),
30
+ cacheReadInputTokens: usageValue(usage, 'cache_read_input_tokens', 'cacheReadInputTokens'),
18
31
  };
19
32
  }
20
33
 
@@ -29,10 +42,13 @@ export function createClaudeSessionAccumulator() {
29
42
  return { messages: new Map(), sessionStarts: new Map(), warnings: [] };
30
43
  }
31
44
 
32
- export function addClaudeSessionEvent(accumulator, event) {
45
+ export function addClaudeSessionEvent(accumulator, event, context = {}) {
33
46
  if (!event || typeof event !== 'object') return;
34
- const sessionId = String(event.sessionId ?? event.session_id ?? '');
35
- const timestamp = typeof event.timestamp === 'string' ? event.timestamp : '';
47
+ const sessionId = String(event.sessionId ?? event.session_id ?? context.sessionId ?? '');
48
+ const rawTimestamp = event.timestamp ?? event.created_at ?? event.createdAt ?? event.message?.timestamp;
49
+ const timestamp = typeof rawTimestamp === 'string'
50
+ ? rawTimestamp
51
+ : Number.isFinite(rawTimestamp) ? new Date(rawTimestamp).toISOString() : '';
36
52
  const date = timestamp.slice(0, 10);
37
53
  if (sessionId && datePattern.test(date)) {
38
54
  const existing = accumulator.sessionStarts.get(sessionId);
@@ -42,15 +58,10 @@ export function addClaudeSessionEvent(accumulator, event) {
42
58
  const message = event.message;
43
59
  const rawUsage = message?.usage;
44
60
  if (!message || !rawUsage || !sessionId || !datePattern.test(date)) return;
45
- const messageId = String(message.id ?? event.requestId ?? '');
61
+ const messageId = String(message.id ?? event.requestId ?? event.request_id ?? event.uuid ?? context.recordId ?? '');
46
62
  if (!messageId) return;
47
63
 
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
- };
64
+ const usage = normalizeUsage(rawUsage);
54
65
  const candidate = {
55
66
  date,
56
67
  timestamp,
@@ -112,24 +123,101 @@ export function finalizeClaudeSessionUsage(accumulator) {
112
123
  return { daily, messageCount: accumulator.messages.size, warnings: accumulator.warnings };
113
124
  }
114
125
 
126
+ function allocateInteger(total, weights) {
127
+ const entries = [...weights.entries()].filter(([, value]) => value > 0);
128
+ if (!entries.length || total <= 0) return new Map();
129
+ const weightTotal = entries.reduce((sum, [, value]) => sum + value, 0);
130
+ const rows = entries.map(([key, weight]) => {
131
+ const exact = total * weight / weightTotal;
132
+ return { key, value: Math.floor(exact), fraction: exact - Math.floor(exact) };
133
+ });
134
+ let remainder = Math.round(total) - rows.reduce((sum, row) => sum + row.value, 0);
135
+ rows.sort((left, right) => right.fraction - left.fraction || String(left.key).localeCompare(String(right.key)));
136
+ for (let index = 0; remainder > 0; index = (index + 1) % rows.length, remainder -= 1) rows[index].value += 1;
137
+ return new Map(rows.map(({ key, value }) => [key, value]));
138
+ }
139
+
140
+ function addAllocation(target, allocation) {
141
+ for (const [date, value] of allocation) target.set(date, (target.get(date) ?? 0) + value);
142
+ }
143
+
115
144
  export function parseClaudeStatsCache(stats) {
116
- if (!stats || typeof stats !== 'object') return { daily: [], undatedCacheTokens: 0 };
145
+ if (!stats || typeof stats !== 'object') return { daily: [], estimatedCacheTokens: 0, undatedCacheTokens: 0, estimatedDates: 0 };
117
146
  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) => {
147
+ const modelWeights = new Map();
148
+ for (const item of stats.dailyModelTokens ?? []) {
149
+ if (!datePattern.test(item?.date ?? '')) continue;
150
+ for (const [model, value] of Object.entries(item.tokensByModel ?? {})) {
151
+ const weights = modelWeights.get(model) ?? new Map();
152
+ weights.set(item.date, safeCount(value));
153
+ modelWeights.set(model, weights);
154
+ }
155
+ }
156
+ const activityWeights = new Map([...activityByDate].map(([date, activity]) => [date, Math.max(1, safeCount(activity.messageCount))]));
157
+ const dates = new Set([...activityByDate.keys(), ...[...modelWeights.values()].flatMap((weights) => [...weights.keys()])]);
158
+ const componentsByDate = new Map();
159
+ const modelsByDate = new Map();
160
+ const componentRow = (date) => {
161
+ const current = componentsByDate.get(date) ?? { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, combined: 0, estimated: false };
162
+ componentsByDate.set(date, current);
163
+ dates.add(date);
164
+ return current;
165
+ };
166
+ const addModelAllocation = (model, allocation) => {
167
+ for (const [date, value] of allocation) {
168
+ const models = modelsByDate.get(date) ?? {};
169
+ models[model] = (models[model] ?? 0) + value;
170
+ modelsByDate.set(date, models);
171
+ }
172
+ };
173
+ let estimatedCacheTokens = 0;
174
+ const seenModels = new Set();
175
+ for (const [model, rawUsage] of Object.entries(stats.modelUsage ?? {})) {
176
+ seenModels.add(model);
177
+ const usage = normalizeUsage(rawUsage);
178
+ const weights = modelWeights.get(model)?.size ? modelWeights.get(model) : activityWeights;
179
+ if (!weights.size) continue;
180
+ const input = allocateInteger(usage.inputTokens, weights);
181
+ const output = allocateInteger(usage.outputTokens, weights);
182
+ const cacheCreation = allocateInteger(usage.cacheCreationInputTokens, weights);
183
+ const cacheRead = allocateInteger(usage.cacheReadInputTokens, weights);
184
+ const combined = usage.inputTokens + usage.outputTokens > 0 ? new Map() : new Map(weights);
185
+ for (const [date, value] of input) { componentRow(date).input += value; componentRow(date).estimated = true; }
186
+ for (const [date, value] of output) { componentRow(date).output += value; componentRow(date).estimated = true; }
187
+ for (const [date, value] of cacheCreation) { componentRow(date).cacheCreation += value; componentRow(date).estimated = true; }
188
+ for (const [date, value] of cacheRead) { componentRow(date).cacheRead += value; componentRow(date).estimated = true; }
189
+ for (const [date, value] of combined) { componentRow(date).combined += value; componentRow(date).estimated = true; }
190
+ const modelTotal = new Map();
191
+ addAllocation(modelTotal, input); addAllocation(modelTotal, output); addAllocation(modelTotal, cacheCreation); addAllocation(modelTotal, cacheRead); addAllocation(modelTotal, combined);
192
+ addModelAllocation(model, modelTotal);
193
+ estimatedCacheTokens += usage.cacheCreationInputTokens + usage.cacheReadInputTokens;
194
+ }
195
+ for (const [model, weights] of modelWeights) {
196
+ if (seenModels.has(model)) continue;
197
+ for (const [date, value] of weights) {
198
+ componentRow(date).combined += value;
199
+ addModelAllocation(model, new Map([[date, value]]));
200
+ }
201
+ }
202
+ const orderedDates = [...dates].sort();
203
+ const daily = orderedDates.map((date) => {
121
204
  const activity = activityByDate.get(date) ?? {};
205
+ const components = componentsByDate.get(date) ?? { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, combined: 0, estimated: false };
206
+ const tokens = components.input + components.output + components.cacheCreation + components.cacheRead + components.combined;
207
+ const tokenProvenance = components.estimated ? 'estimated' : 'local_exact';
122
208
  const values = [
123
- ['sessions', safeCount(activity.sessionCount)],
124
- ['turns', safeCount(activity.messageCount)],
125
- ['tool_calls', safeCount(activity.toolCallCount)],
126
- ['tokens', safeCount(tokensByDate.get(date))],
209
+ ['sessions', safeCount(activity.sessionCount), 'local_exact'],
210
+ ['turns', safeCount(activity.messageCount), 'local_exact'],
211
+ ['tool_calls', safeCount(activity.toolCallCount), 'local_exact'],
212
+ ['tokens', tokens, tokenProvenance],
213
+ ['input_tokens', components.input, tokenProvenance],
214
+ ['output_tokens', components.output, tokenProvenance],
215
+ ['cache_creation_input_tokens', components.cacheCreation, tokenProvenance],
216
+ ['cache_read_input_tokens', components.cacheRead, tokenProvenance],
127
217
  ];
128
- return { date, source: 'claude_code', category: 'coding', metrics: values.filter(([, value]) => value > 0).map(([name, value]) => metric(name, value, date)) };
218
+ return { date, source: 'claude_code', category: 'coding', metrics: values.filter(([, value]) => value > 0).map(([name, value, provenance]) => metric(name, value, date, provenance)), models: modelsByDate.get(date) };
129
219
  });
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 };
220
+ return { daily, estimatedCacheTokens, undatedCacheTokens: estimatedCacheTokens, estimatedDates: daily.filter((day) => day.metrics.some((item) => item.name === 'tokens' && item.provenance === 'estimated')).length };
133
221
  }
134
222
 
135
223
  export function mergeClaudeUsage(sessionDaily, legacyDaily) {
@@ -31,7 +31,7 @@ export async function readCodexAccountUsage(timeoutMs = 8_000, executable = reso
31
31
  });
32
32
  try {
33
33
  await started;
34
- await request(1, 'initialize', { clientInfo: { name: 'ai_native_profile', title: 'AI Native Profile', version: '0.1.3' } });
34
+ await request(1, 'initialize', { clientInfo: { name: 'ai_native_profile', title: 'AI Native Profile', version: '0.2.1' } });
35
35
  child.stdin.write(`${JSON.stringify({ method: 'initialized', params: {} })}\n`);
36
36
  return await request(2, 'account/usage/read');
37
37
  } finally {
@@ -0,0 +1,29 @@
1
+ import { existsSync, lstatSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export function discoverLocalDataFiles(candidates, { maxFiles = 10_000 } = {}) {
5
+ const files = [];
6
+ const stack = [...candidates].reverse();
7
+ let truncated = false;
8
+
9
+ while (stack.length) {
10
+ const path = stack.pop();
11
+ if (!existsSync(path)) continue;
12
+ let info;
13
+ try { info = lstatSync(path); } catch { continue; }
14
+ if (info.isSymbolicLink()) continue;
15
+ if (info.isFile()) {
16
+ if (/\.(jsonl|json)$/i.test(path)) {
17
+ if (files.length >= maxFiles) { truncated = true; break; }
18
+ files.push(path);
19
+ }
20
+ continue;
21
+ }
22
+ if (!info.isDirectory()) continue;
23
+ let entries;
24
+ try { entries = readdirSync(path).sort((left, right) => left.localeCompare(right)); } catch { continue; }
25
+ for (let index = entries.length - 1; index >= 0; index -= 1) stack.push(join(path, entries[index]));
26
+ }
27
+
28
+ return { files, truncated };
29
+ }