aicp-tracker 1.0.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/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Alexey Pavlenko
2
+
3
+ All rights reserved.
4
+
5
+ This software is proprietary and confidential.
6
+
7
+ Permission is granted to install and use this software solely for the purpose
8
+ of interacting with the AICP Tracker service, and only with a valid and active subscription.
9
+
10
+ You may NOT:
11
+ - copy, modify, merge, publish, distribute, sublicense, or sell the software
12
+ - use the software in any competing product or service
13
+ - reverse engineer, decompile, or attempt to extract source code or algorithms
14
+ - remove or alter any proprietary notices
15
+
16
+ The software is provided "as is", without warranty of any kind.
17
+
18
+ Access to the source code (if provided) is for review purposes only and does
19
+ not grant any rights beyond what is explicitly stated in this license.
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const [,, cmd, ...args] = process.argv;
5
+
6
+ switch (cmd) {
7
+ case 'start': require('../src/daemon').start(); break;
8
+ case 'stop': require('../src/daemon').stop(); break;
9
+ case 'status': require('../src/daemon').status(); break;
10
+ case 'setup': require('./setup'); break;
11
+ case 'flush': require('../src/wal').flush(); break;
12
+ default:
13
+ console.log(`
14
+ aicp-tracker <command>
15
+
16
+ Commands:
17
+ start Start the background tracker (sends usage every 5 min)
18
+ stop Stop the background tracker
19
+ status Show tracker status and WAL queue depth
20
+ setup Re-run the configuration wizard
21
+ flush Force-send all pending WAL records now
22
+ `);
23
+ }
package/bin/setup.js ADDED
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Runs on `npm install` (postinstall). Skipped in CI environments.
5
+ if (process.env.CI || process.env.npm_config_yes) process.exit(0);
6
+
7
+ const { prompt } = require('enquirer');
8
+ const { execSync } = require('child_process');
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
12
+ const config = require('../src/config');
13
+ const daemon = require('../src/daemon');
14
+
15
+ const API_URL = 'http://147.5.102.208:3000';
16
+ const TASK_NAME = 'AI Code Pulse Tracker';
17
+
18
+ const PLANS = [
19
+ 'Pro (monthly)',
20
+ 'Pro (yearly)',
21
+ 'Max',
22
+ 'Team (standard)',
23
+ 'Team (premium)',
24
+ 'Enterprise',
25
+ ];
26
+
27
+ // ── Auto-start installation (runs silently, non-fatal) ────────────────────────
28
+
29
+ function installAutoStart() {
30
+ const nodePath = process.execPath;
31
+ const scriptPath = path.resolve(__dirname, 'aicp-tracker.js');
32
+
33
+ try {
34
+ if (process.platform === 'win32') {
35
+ // Windows Task Scheduler: run at every login, current user only
36
+ execSync(
37
+ `schtasks /create /tn "${TASK_NAME}" /tr "\\"${nodePath}\\" \\"${scriptPath}\\" start" /sc ONLOGON /f`,
38
+ { stdio: 'ignore' }
39
+ );
40
+
41
+ } else if (process.platform === 'darwin') {
42
+ // macOS launchd: load on login
43
+ const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
44
+ const plistPath = path.join(plistDir, 'com.aicp-tracker.plist');
45
+ fs.mkdirSync(plistDir, { recursive: true });
46
+ fs.writeFileSync(plistPath, `<?xml version="1.0" encoding="UTF-8"?>
47
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
48
+ <plist version="1.0">
49
+ <dict>
50
+ <key>Label</key><string>com.aicp-tracker</string>
51
+ <key>ProgramArguments</key>
52
+ <array>
53
+ <string>${nodePath}</string>
54
+ <string>${scriptPath}</string>
55
+ <string>start</string>
56
+ </array>
57
+ <key>RunAtLoad</key><true/>
58
+ <key>KeepAlive</key><false/>
59
+ </dict>
60
+ </plist>`);
61
+ execSync(`launchctl load "${plistPath}"`, { stdio: 'ignore' });
62
+
63
+ } else {
64
+ // Linux: systemd user service
65
+ const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
66
+ const unitPath = path.join(unitDir, 'aicp-tracker.service');
67
+ fs.mkdirSync(unitDir, { recursive: true });
68
+ fs.writeFileSync(unitPath, `[Unit]
69
+ Description=AI Code Pulse Tracker
70
+
71
+ [Service]
72
+ Type=oneshot
73
+ ExecStart=${nodePath} ${scriptPath} start
74
+ RemainAfterExit=yes
75
+
76
+ [Install]
77
+ WantedBy=default.target
78
+ `);
79
+ execSync('systemctl --user daemon-reload && systemctl --user enable --now aicp-tracker', { stdio: 'ignore' });
80
+ }
81
+
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ // ── Registration ──────────────────────────────────────────────────────────────
89
+
90
+ async function register(email) {
91
+ const res = await fetch(`${API_URL}/api/v1/register`, {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ email }),
95
+ signal: AbortSignal.timeout(15_000),
96
+ });
97
+ if (!res.ok) throw new Error(`Registration failed: HTTP ${res.status}`);
98
+ const json = await res.json();
99
+ if (!json.apiKey) throw new Error('No API key returned from server');
100
+ return json.apiKey;
101
+ }
102
+
103
+ // ── Main ──────────────────────────────────────────────────────────────────────
104
+
105
+ async function main() {
106
+ console.log('\n\x1b[1m AI Code Pulse Tracker — first-time setup\x1b[0m\n');
107
+
108
+ let existing = null;
109
+ try { existing = config.load(); } catch {}
110
+ if (existing?.vcsUrl) {
111
+ console.log(` Already configured (${existing.vcsUrl}). Re-running setup will overwrite it.\n`);
112
+ }
113
+
114
+ const answers = await prompt([
115
+ {
116
+ type: 'input',
117
+ name: 'vcsUrl',
118
+ message: 'VCS organisation URL (e.g. https://bitbucket.org/my-workspace or https://github.com/my-org)',
119
+ initial: existing?.vcsUrl || '',
120
+ validate: v => /^https?:\/\/(bitbucket\.org|github\.com)\/.+/.test(v.trim())
121
+ ? true
122
+ : 'Must be https://bitbucket.org/<slug> or https://github.com/<slug>',
123
+ },
124
+ {
125
+ type: 'input',
126
+ name: 'email',
127
+ message: 'Your email used in Bitbucket / GitHub',
128
+ initial: existing?.email || '',
129
+ validate: v => v.includes('@') ? true : 'Enter a valid email address',
130
+ },
131
+ {
132
+ type: 'select',
133
+ name: 'plan',
134
+ message: 'Your Claude Code plan',
135
+ choices: PLANS,
136
+ initial: existing?.plan ? PLANS.indexOf(existing.plan) : 0,
137
+ },
138
+ ]);
139
+
140
+ console.log('\n Registering with AI Code Pulse server…');
141
+ const apiKey = await register(answers.email.trim());
142
+
143
+ config.save({
144
+ vcsUrl: answers.vcsUrl.trim(),
145
+ email: answers.email.trim(),
146
+ plan: answers.plan,
147
+ apiUrl: API_URL,
148
+ apiKey,
149
+ });
150
+
151
+ console.log(' \x1b[32m✔\x1b[0m Configuration saved');
152
+
153
+ console.log(' Installing auto-start (runs at every login)…');
154
+ const ok = installAutoStart();
155
+ console.log(ok
156
+ ? ' \x1b[32m✔\x1b[0m Auto-start installed'
157
+ : ' \x1b[33m⚠\x1b[0m Auto-start could not be installed — run `aicp-tracker start` manually'
158
+ );
159
+
160
+ console.log(' Starting tracker…');
161
+ daemon.start();
162
+ console.log(' \x1b[32m✔\x1b[0m Tracker running. Usage will be sent every 5 minutes.\n');
163
+ }
164
+
165
+ main().catch(e => {
166
+ console.warn('\n [aicp-tracker] Setup skipped:', e.message, '\n Run `aicp-tracker setup` to configure later.\n');
167
+ });
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "aicp-tracker",
3
+ "version": "1.0.0",
4
+ "description": "AI Code Pulse — Claude Code usage tracker for JIRA cost attribution",
5
+ "main": "src/daemon.js",
6
+ "bin": {
7
+ "aicp-tracker": "./bin/aicp-tracker.js"
8
+ },
9
+ "scripts": {
10
+ "postinstall": "node bin/setup.js"
11
+ },
12
+ "dependencies": {
13
+ "enquirer": "^2.4.1"
14
+ },
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "license": "UNLICENSED",
19
+ "private": false
20
+ }
package/src/config.js ADDED
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const CONFIG_DIR = path.join(os.homedir(), '.aicp-tracker');
8
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
9
+
10
+ function ensureDir() {
11
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
12
+ }
13
+
14
+ function load() {
15
+ if (!fs.existsSync(CONFIG_FILE)) return null;
16
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
17
+ }
18
+
19
+ function save(cfg) {
20
+ ensureDir();
21
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), 'utf8');
22
+ }
23
+
24
+ function require_() {
25
+ const cfg = load();
26
+ if (!cfg) throw new Error('Not configured. Run: aicp-tracker setup');
27
+ return cfg;
28
+ }
29
+
30
+ module.exports = { CONFIG_FILE, CONFIG_DIR, load, save, require: require_ };
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ // Spawned as a detached background process by daemon.js start()
4
+
5
+ const path = require('path');
6
+ const fs = require('fs');
7
+ const { CONFIG_DIR } = require('./config');
8
+ const { tick } = require('./daemon');
9
+
10
+ const PID_FILE = path.join(CONFIG_DIR, 'daemon.pid');
11
+ fs.writeFileSync(PID_FILE, String(process.pid), 'utf8');
12
+
13
+ process.on('SIGTERM', () => {
14
+ try { fs.unlinkSync(PID_FILE); } catch {}
15
+ process.exit(0);
16
+ });
17
+
18
+ async function loop() {
19
+ try { await tick(); } catch (e) { console.error('[daemon-worker] tick error:', e.message); }
20
+ setTimeout(loop, 5 * 60 * 1000);
21
+ }
22
+
23
+ loop();
package/src/daemon.js ADDED
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const { findJsonlFiles } = require('./log-scanner');
7
+ const { parseNewLines } = require('./log-parser');
8
+ const wal = require('./wal');
9
+ const { sendPending } = require('./sender');
10
+ const { CONFIG_DIR } = require('./config');
11
+
12
+ const PID_FILE = path.join(CONFIG_DIR, 'daemon.pid');
13
+ const INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
14
+
15
+ async function tick() {
16
+ const files = findJsonlFiles();
17
+ let total = 0;
18
+ for (const f of files) {
19
+ const records = parseNewLines(f);
20
+ if (records.length) {
21
+ wal.append(records);
22
+ total += records.length;
23
+ }
24
+ }
25
+ if (total > 0) console.log(`[daemon] Parsed ${total} new usage records from ${files.length} files`);
26
+ await sendPending();
27
+ }
28
+
29
+ function writePid() {
30
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
31
+ fs.writeFileSync(PID_FILE, String(process.pid), 'utf8');
32
+ }
33
+
34
+ function readPid() {
35
+ try { return parseInt(fs.readFileSync(PID_FILE, 'utf8'), 10); } catch { return null; }
36
+ }
37
+
38
+ function start() {
39
+ const existingPid = readPid();
40
+ if (existingPid) {
41
+ try {
42
+ process.kill(existingPid, 0); // check if running
43
+ console.log(`[daemon] Already running (pid ${existingPid})`);
44
+ return;
45
+ } catch {}
46
+ }
47
+
48
+ // Detach and run as background process
49
+ const child = require('child_process').spawn(
50
+ process.execPath,
51
+ [path.join(__dirname, 'daemon-worker.js')],
52
+ { detached: true, stdio: 'ignore' }
53
+ );
54
+ child.unref();
55
+ console.log(`[daemon] Started (pid ${child.pid})`);
56
+ }
57
+
58
+ function stop() {
59
+ const pid = readPid();
60
+ if (!pid) { console.log('[daemon] Not running'); return; }
61
+ try {
62
+ process.kill(pid, 'SIGTERM');
63
+ fs.unlinkSync(PID_FILE);
64
+ console.log(`[daemon] Stopped (pid ${pid})`);
65
+ } catch (e) {
66
+ console.warn('[daemon] Could not stop:', e.message);
67
+ }
68
+ }
69
+
70
+ function status() {
71
+ const pid = readPid();
72
+ const running = pid ? (() => { try { process.kill(pid, 0); return true; } catch { return false; } })() : false;
73
+ console.log(`[daemon] Status: ${running ? `running (pid ${pid})` : 'stopped'}`);
74
+ console.log(`[daemon] WAL pending: ${wal.pendingCount()} records`);
75
+ }
76
+
77
+ // When required directly (e.g. aicp-tracker start calls this)
78
+ module.exports = { start, stop, status, tick };
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const state = require('./state');
5
+
6
+ // Tool names whose input.file_path / input.path we extract
7
+ const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'Glob', 'NotebookEdit', 'MultiEdit']);
8
+
9
+ function parseNewLines(filePath) {
10
+ const offset = state.getOffset(filePath);
11
+ let stat;
12
+ try { stat = fs.statSync(filePath); } catch { return []; }
13
+ if (stat.size <= offset) return [];
14
+
15
+ const fd = fs.openSync(filePath, 'r');
16
+ const buf = Buffer.alloc(stat.size - offset);
17
+ const read = fs.readSync(fd, buf, 0, buf.length, offset);
18
+ fs.closeSync(fd);
19
+ state.setOffset(filePath, offset + read);
20
+
21
+ const records = [];
22
+ const lines = buf.slice(0, read).toString('utf8').split('\n');
23
+
24
+ for (const raw of lines) {
25
+ const line = raw.trim();
26
+ if (!line) continue;
27
+ let entry;
28
+ try { entry = JSON.parse(line); } catch { continue; }
29
+
30
+ if (entry.type !== 'assistant' && entry.message?.role !== 'assistant') continue;
31
+ const usage = entry.message?.usage;
32
+ if (!usage) continue;
33
+
34
+ let webSearchRequests = 0;
35
+ let webFetchRequests = 0;
36
+ const filePaths = new Set();
37
+ const content = entry.message?.content;
38
+
39
+ if (Array.isArray(content)) {
40
+ for (const block of content) {
41
+ if (block.type !== 'tool_use') continue;
42
+ if (block.name === 'web_search' || block.name === 'WebSearch') webSearchRequests++;
43
+ if (block.name === 'web_fetch' || block.name === 'WebFetch') webFetchRequests++;
44
+ if (FILE_TOOLS.has(block.name)) {
45
+ const fp = block.input?.file_path || block.input?.path;
46
+ if (fp && typeof fp === 'string') filePaths.add(fp);
47
+ }
48
+ }
49
+ }
50
+
51
+ records.push({
52
+ sessionId: entry.sessionId || null,
53
+ uuid: entry.uuid || null,
54
+ parentUuid: entry.parentUuid || null,
55
+ timestamp: entry.timestamp || new Date().toISOString(),
56
+ gitBranch: entry.gitBranch || null,
57
+ model: entry.message?.model || null,
58
+
59
+ input_tokens: usage.input_tokens || 0,
60
+ cache_creation_input_tokens: usage.cache_creation_input_tokens || 0,
61
+ cache_read_input_tokens: usage.cache_read_input_tokens || 0,
62
+ output_tokens: usage.output_tokens || 0,
63
+ ephemeral_user_input_tokens: usage.ephemeral_user_input_tokens || 0,
64
+ ephemeral_assistant_input_tokens: usage.ephemeral_assistant_input_tokens || 0,
65
+ ephemeral_tool_input_tokens: usage.ephemeral_tool_input_tokens || 0,
66
+
67
+ web_search_requests: webSearchRequests,
68
+ web_fetch_requests: webFetchRequests,
69
+ file_paths: [...filePaths],
70
+
71
+ // Claude Code logs do not include per-token USD pricing from Anthropic's API.
72
+ // Enterprise billing data must come from Anthropic's billing API separately.
73
+ enterprise_usd_per_token: null,
74
+
75
+ service_tier: usage.service_tier || null,
76
+ speed: usage.cache_creation_input_tokens > 0 ? 'fast' : 'normal',
77
+ });
78
+ }
79
+
80
+ return records;
81
+ }
82
+
83
+ module.exports = { parseNewLines };
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ // Finds all Claude Code session jsonl files under ~/.claude/projects/
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+
9
+ const CLAUDE_DIR = path.join(os.homedir(), '.claude', 'projects');
10
+
11
+ function findJsonlFiles() {
12
+ const results = [];
13
+ if (!fs.existsSync(CLAUDE_DIR)) return results;
14
+
15
+ for (const projectHash of fs.readdirSync(CLAUDE_DIR)) {
16
+ const projectDir = path.join(CLAUDE_DIR, projectHash);
17
+ if (!fs.statSync(projectDir).isDirectory()) continue;
18
+ for (const file of fs.readdirSync(projectDir)) {
19
+ if (file.endsWith('.jsonl')) {
20
+ results.push(path.join(projectDir, file));
21
+ }
22
+ }
23
+ }
24
+
25
+ return results;
26
+ }
27
+
28
+ module.exports = { findJsonlFiles };
package/src/sender.js ADDED
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ const config = require('./config');
4
+ const wal = require('./wal');
5
+
6
+ /**
7
+ * Groups an array of raw usage records into the nested API payload:
8
+ * organization → useremail → date → [records]
9
+ */
10
+ function buildPayload(cfg, records) {
11
+ const org = cfg.vcsUrl.replace(/^https?:\/\/[^/]+\//, ''); // extract slug
12
+ const email = cfg.email;
13
+ const payload = {};
14
+
15
+ for (const rec of records) {
16
+ const date = rec.timestamp ? rec.timestamp.slice(0, 10) : new Date().toISOString().slice(0, 10);
17
+ payload[org] = payload[org] || {};
18
+ payload[org][email] = payload[org][email] || {};
19
+ payload[org][email][date] = payload[org][email][date] || [];
20
+ payload[org][email][date].push({
21
+ input_tokens: rec.input_tokens,
22
+ cache_creation_input_tokens: rec.cache_creation_input_tokens,
23
+ cache_read_input_tokens: rec.cache_read_input_tokens,
24
+ output_tokens: rec.output_tokens,
25
+ ephemeral_user_input_tokens: rec.ephemeral_user_input_tokens,
26
+ ephemeral_assistant_input_tokens: rec.ephemeral_assistant_input_tokens,
27
+ ephemeral_tool_input_tokens: rec.ephemeral_tool_input_tokens,
28
+ web_search_requests: rec.web_search_requests,
29
+ web_fetch_requests: rec.web_fetch_requests,
30
+ service_tier: rec.service_tier,
31
+ speed: rec.speed,
32
+ model: rec.model,
33
+ file_paths: rec.file_paths || [],
34
+ enterprise_usd_per_token: rec.enterprise_usd_per_token || null,
35
+ sessionId: rec.sessionId,
36
+ uuid: rec.uuid,
37
+ parentUuid: rec.parentUuid,
38
+ gitBranch: rec.gitBranch,
39
+ });
40
+ }
41
+
42
+ return payload;
43
+ }
44
+
45
+ async function sendPending() {
46
+ const pending = wal.getPending();
47
+ if (!pending.length) return;
48
+
49
+ let cfg;
50
+ try { cfg = config.require(); } catch (e) { console.warn('[sender]', e.message); return; }
51
+
52
+ const payload = buildPayload(cfg, pending);
53
+ const uuids = pending.map(r => r.uuid).filter(Boolean);
54
+
55
+ try {
56
+ const res = await fetch(`${cfg.apiUrl}/api/v1/ingest`, {
57
+ method: 'POST',
58
+ headers: {
59
+ 'Content-Type': 'application/json',
60
+ 'X-API-Key': cfg.apiKey,
61
+ },
62
+ body: JSON.stringify({ plan: cfg.plan, data: payload }),
63
+ signal: AbortSignal.timeout(30_000),
64
+ });
65
+
66
+ if (!res.ok) {
67
+ const text = await res.text().catch(() => '');
68
+ throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
69
+ }
70
+
71
+ wal.markSent(uuids);
72
+ console.log(`[sender] Sent ${pending.length} records (${uuids.length} with UUIDs)`);
73
+ } catch (e) {
74
+ wal.markFailed(uuids);
75
+ console.warn('[sender] Send failed (will retry next cycle):', e.message);
76
+ }
77
+ }
78
+
79
+ module.exports = { sendPending };
package/src/state.js ADDED
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ // Tracks the byte offset read so far in each jsonl file so we never re-parse old lines.
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { CONFIG_DIR } = require('./config');
8
+
9
+ const STATE_FILE = path.join(CONFIG_DIR, 'state.json');
10
+
11
+ let _state = null;
12
+
13
+ function load() {
14
+ if (_state) return _state;
15
+ try {
16
+ _state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
17
+ } catch {
18
+ _state = { offsets: {} };
19
+ }
20
+ return _state;
21
+ }
22
+
23
+ function save() {
24
+ fs.writeFileSync(STATE_FILE, JSON.stringify(_state, null, 2), 'utf8');
25
+ }
26
+
27
+ function getOffset(filePath) {
28
+ return load().offsets[filePath] || 0;
29
+ }
30
+
31
+ function setOffset(filePath, offset) {
32
+ load().offsets[filePath] = offset;
33
+ save();
34
+ }
35
+
36
+ module.exports = { getOffset, setOffset };
package/src/wal.js ADDED
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ // Write-Ahead Log — records are written atomically to disk before any send attempt.
4
+ // Status: 'pending' | 'sent' | 'failed'
5
+ // On each cycle: retry all pending + failed, mark sent, prune old sent records.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { CONFIG_DIR } = require('./config');
10
+
11
+ const WAL_FILE = path.join(CONFIG_DIR, 'wal.json');
12
+ const SENT_TTL = 7 * 24 * 60 * 60 * 1000; // keep sent records for 7 days
13
+
14
+ function load() {
15
+ try { return JSON.parse(fs.readFileSync(WAL_FILE, 'utf8')); } catch { return { records: [] }; }
16
+ }
17
+
18
+ function _save(wal) {
19
+ const tmp = WAL_FILE + '.tmp';
20
+ fs.writeFileSync(tmp, JSON.stringify(wal, null, 2), 'utf8');
21
+ fs.renameSync(tmp, WAL_FILE); // atomic on POSIX; best-effort on Windows
22
+ }
23
+
24
+ function append(records) {
25
+ const wal = load();
26
+ const now = Date.now();
27
+ for (const rec of records) {
28
+ wal.records.push({ ...rec, _walStatus: 'pending', _walAddedAt: now, _walAttempts: 0 });
29
+ }
30
+ _save(wal);
31
+ }
32
+
33
+ function getPending() {
34
+ const wal = load();
35
+ return wal.records.filter(r => r._walStatus === 'pending' || r._walStatus === 'failed');
36
+ }
37
+
38
+ function markSent(uuids) {
39
+ const set = new Set(uuids);
40
+ const wal = load();
41
+ const now = Date.now();
42
+ for (const r of wal.records) {
43
+ if (set.has(r.uuid)) { r._walStatus = 'sent'; r._walSentAt = now; }
44
+ }
45
+ // Prune records sent more than 7 days ago
46
+ wal.records = wal.records.filter(r => !(r._walStatus === 'sent' && now - r._walSentAt > SENT_TTL));
47
+ _save(wal);
48
+ }
49
+
50
+ function markFailed(uuids) {
51
+ const set = new Set(uuids);
52
+ const wal = load();
53
+ for (const r of wal.records) {
54
+ if (set.has(r.uuid)) { r._walStatus = 'failed'; r._walAttempts = (r._walAttempts || 0) + 1; }
55
+ }
56
+ _save(wal);
57
+ }
58
+
59
+ function pendingCount() {
60
+ return getPending().length;
61
+ }
62
+
63
+ async function flush() {
64
+ const count = pendingCount();
65
+ if (count === 0) { console.log('WAL: nothing pending.'); return; }
66
+ console.log(`WAL: ${count} pending records. Run \`aicp-tracker start\` to flush, or wait for next cycle.`);
67
+ }
68
+
69
+ module.exports = { append, getPending, markSent, markFailed, pendingCount, flush };