metame-cli 1.5.19 → 1.5.20

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.
Files changed (40) hide show
  1. package/index.js +157 -80
  2. package/package.json +2 -2
  3. package/scripts/bin/bootstrap-worktree.sh +20 -0
  4. package/scripts/core/audit.js +190 -0
  5. package/scripts/core/handoff.js +780 -0
  6. package/scripts/core/handoff.test.js +1074 -0
  7. package/scripts/core/memory-model.js +183 -0
  8. package/scripts/core/memory-model.test.js +486 -0
  9. package/scripts/core/reactive-paths.js +44 -0
  10. package/scripts/core/reactive-paths.test.js +35 -0
  11. package/scripts/core/reactive-prompt.js +51 -0
  12. package/scripts/core/reactive-prompt.test.js +88 -0
  13. package/scripts/core/reactive-signal.js +40 -0
  14. package/scripts/core/reactive-signal.test.js +88 -0
  15. package/scripts/core/thread-chat-id.js +52 -0
  16. package/scripts/core/thread-chat-id.test.js +113 -0
  17. package/scripts/daemon-bridges.js +79 -35
  18. package/scripts/daemon-claude-engine.js +373 -444
  19. package/scripts/daemon-command-router.js +82 -8
  20. package/scripts/daemon-engine-runtime.js +7 -10
  21. package/scripts/daemon-reactive-lifecycle.js +100 -33
  22. package/scripts/daemon-session-commands.js +133 -43
  23. package/scripts/daemon-session-store.js +300 -82
  24. package/scripts/daemon-team-dispatch.js +16 -16
  25. package/scripts/daemon.js +21 -175
  26. package/scripts/deploy-manifest.js +90 -0
  27. package/scripts/docs/maintenance-manual.md +14 -11
  28. package/scripts/docs/pointer-map.md +13 -4
  29. package/scripts/feishu-adapter.js +31 -27
  30. package/scripts/hooks/intent-engine.js +6 -3
  31. package/scripts/hooks/intent-memory-recall.js +1 -0
  32. package/scripts/hooks/intent-perpetual.js +1 -1
  33. package/scripts/memory-extract.js +5 -97
  34. package/scripts/memory-gc.js +35 -90
  35. package/scripts/memory-migrate-v2.js +304 -0
  36. package/scripts/memory-nightly-reflect.js +40 -41
  37. package/scripts/memory.js +340 -859
  38. package/scripts/migrate-reactive-paths.js +122 -0
  39. package/scripts/signal-capture.js +4 -0
  40. package/scripts/sync-plugin.js +56 -0
package/index.js CHANGED
@@ -7,7 +7,8 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
9
  const { spawn, execSync } = require('child_process');
10
- const { sleepSync, findProcessesByPattern, icon } = require('./scripts/platform');
10
+ const { sleepSync, findProcessesByPattern, killProcessTree, icon } = require('./scripts/platform');
11
+ const { collectDeployGroups, collectSyntaxCheckFiles } = require('./scripts/deploy-manifest');
11
12
 
12
13
  // On Windows, resolve .cmd wrapper → actual Node.js entry and spawn node directly.
13
14
  // Completely bypasses cmd.exe, eliminating terminal flash.
@@ -254,6 +255,16 @@ function readRunningDaemonPid({ pidFile, lockFile }) {
254
255
  return null;
255
256
  }
256
257
 
258
+ function isPidAlive(pid) {
259
+ if (!pid || Number.isNaN(pid) || pid === process.pid) return false;
260
+ try {
261
+ process.kill(pid, 0);
262
+ return true;
263
+ } catch {
264
+ return false;
265
+ }
266
+ }
267
+
257
268
  // --- macOS launchd integration ---
258
269
  const LAUNCHD_LABEL = 'com.metame.npm-daemon';
259
270
  const LAUNCHD_PLIST = path.join(HOME_DIR, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
@@ -318,6 +329,99 @@ function launchdIsRunning() {
318
329
  } catch { return null; }
319
330
  }
320
331
 
332
+ function cleanDaemonRuntimeState({ pidFile, lockFile }) {
333
+ try {
334
+ if (fs.existsSync(pidFile)) {
335
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
336
+ if (!isPidAlive(pid)) fs.unlinkSync(pidFile);
337
+ }
338
+ } catch {
339
+ try { fs.unlinkSync(pidFile); } catch { /* ignore */ }
340
+ }
341
+ try {
342
+ if (fs.existsSync(lockFile)) {
343
+ const lock = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
344
+ const pid = parseInt(lock && lock.pid, 10);
345
+ if (!isPidAlive(pid)) fs.unlinkSync(lockFile);
346
+ }
347
+ } catch {
348
+ try { fs.unlinkSync(lockFile); } catch { /* ignore */ }
349
+ }
350
+ }
351
+
352
+ function terminateDaemonProcesses({
353
+ pidFile,
354
+ lockFile,
355
+ preservePid = null,
356
+ }) {
357
+ const targets = new Set();
358
+ for (const pid of findProcessesByPattern('node.*daemon\\.js')) {
359
+ if (pid && pid !== process.pid && pid !== preservePid) targets.add(pid);
360
+ }
361
+ for (const file of [pidFile, lockFile]) {
362
+ if (!fs.existsSync(file)) continue;
363
+ try {
364
+ const raw = fs.readFileSync(file, 'utf8').trim();
365
+ const pid = file === pidFile
366
+ ? parseInt(raw, 10)
367
+ : parseInt((JSON.parse(raw || '{}') || {}).pid, 10);
368
+ if (pid && pid !== process.pid && pid !== preservePid && isPidAlive(pid)) targets.add(pid);
369
+ } catch { /* ignore invalid metadata */ }
370
+ }
371
+
372
+ if (!targets.size) {
373
+ cleanDaemonRuntimeState({ pidFile, lockFile });
374
+ return [];
375
+ }
376
+
377
+ for (const pid of targets) killProcessTree(pid, 'SIGTERM');
378
+ sleepSync(1500);
379
+ for (const pid of targets) {
380
+ if (isPidAlive(pid)) killProcessTree(pid, 'SIGKILL');
381
+ }
382
+ sleepSync(500);
383
+ cleanDaemonRuntimeState({ pidFile, lockFile });
384
+ return [...targets];
385
+ }
386
+
387
+ function ensureLaunchdDaemonRunning({
388
+ daemonScript,
389
+ daemonLog,
390
+ pidFile,
391
+ lockFile,
392
+ restart = false,
393
+ }) {
394
+ ensureLaunchdPlist({ daemonScript, daemonLog });
395
+ let launchdPid = launchdIsRunning();
396
+
397
+ if (launchdPid) {
398
+ terminateDaemonProcesses({ pidFile, lockFile, preservePid: launchdPid });
399
+ } else {
400
+ terminateDaemonProcesses({ pidFile, lockFile });
401
+ }
402
+
403
+ try {
404
+ execSync(`launchctl bootstrap gui/$(id -u) ${LAUNCHD_PLIST} 2>/dev/null`);
405
+ } catch { /* already bootstrapped */ }
406
+
407
+ try {
408
+ execSync(`launchctl kickstart ${restart ? '-k ' : ''}gui/$(id -u)/${LAUNCHD_LABEL}`);
409
+ } catch {
410
+ // Recover from a stale/removed launchd service entry.
411
+ try { execSync(`launchctl bootout gui/$(id -u)/${LAUNCHD_LABEL} 2>/dev/null`); } catch { /* ignore */ }
412
+ terminateDaemonProcesses({ pidFile, lockFile });
413
+ execSync(`launchctl bootstrap gui/$(id -u) ${LAUNCHD_PLIST}`);
414
+ execSync(`launchctl kickstart gui/$(id -u)/${LAUNCHD_LABEL}`);
415
+ }
416
+
417
+ sleepSync(1500);
418
+ launchdPid = launchdIsRunning();
419
+ if (!launchdPid) {
420
+ launchdPid = readRunningDaemonPid({ pidFile, lockFile });
421
+ }
422
+ return launchdPid || null;
423
+ }
424
+
321
425
  function requestDaemonRestart({
322
426
  reason = 'manual-restart',
323
427
  daemonPidFile = path.join(METAME_DIR, 'daemon.pid'),
@@ -374,20 +478,11 @@ function requestDaemonRestart({
374
478
  // Auto-deploy bundled scripts to ~/.metame/
375
479
  // IMPORTANT: daemon.yaml is USER CONFIG — never overwrite it. Only daemon-default.yaml (template) is synced.
376
480
  const scriptsDir = path.join(__dirname, 'scripts');
377
- // Auto-detect ALL runtime scripts: daemon-*.js + all other non-test, non-utility .js/.yaml/.sh files.
378
- // This prevents "missing module" crashes when new files are added without updating a manual list.
379
481
  const EXCLUDED_SCRIPTS = new Set(['sync-readme.js', 'test_daemon.js', 'daemon.yaml']);
380
- const BUNDLED_SCRIPTS = (() => {
381
- try {
382
- return fs.readdirSync(scriptsDir).filter((f) => {
383
- if (EXCLUDED_SCRIPTS.has(f)) return false;
384
- if (/\.test\.js$/.test(f)) return false;
385
- return /\.(js|yaml|sh)$/.test(f);
386
- });
387
- } catch {
388
- return [];
389
- }
390
- })();
482
+ const SCRIPT_DEPLOY_GROUPS = collectDeployGroups(fs, path, scriptsDir, {
483
+ excludedScripts: EXCLUDED_SCRIPTS,
484
+ includeNestedDirs: ['core'],
485
+ });
391
486
 
392
487
  // Protect daemon.yaml: create backup before any sync operation
393
488
  const DAEMON_YAML_BACKUP = path.join(METAME_DIR, 'daemon.yaml.bak');
@@ -423,15 +518,14 @@ if (_isInWorktree) {
423
518
  // Catches bad merges and careless agent edits BEFORE they can crash the daemon.
424
519
  const { execSync: _execSync } = require('child_process');
425
520
  const syntaxErrors = [];
426
- for (const f of BUNDLED_SCRIPTS) {
427
- if (!f.endsWith('.js')) continue;
428
- const fp = path.join(scriptsDir, f);
521
+ for (const fp of collectSyntaxCheckFiles(path, SCRIPT_DEPLOY_GROUPS)) {
429
522
  if (!fs.existsSync(fp)) continue;
430
523
  try {
431
524
  _execSync(`"${process.execPath}" -c "${fp}"`, { timeout: 5000, stdio: 'pipe', windowsHide: true });
432
525
  } catch (e) {
526
+ const label = path.relative(scriptsDir, fp);
433
527
  const msg = (e.stderr ? e.stderr.toString().trim() : e.message).split('\n')[0];
434
- syntaxErrors.push(`${f}: ${msg}`);
528
+ syntaxErrors.push(`${label}: ${msg}`);
435
529
  }
436
530
  }
437
531
 
@@ -441,7 +535,11 @@ if (syntaxErrors.length > 0) {
441
535
  for (const err of syntaxErrors) console.error(` ${err}`);
442
536
  console.error('Fix the errors before deploying. Daemon continues running with old code.');
443
537
  } else {
444
- scriptsUpdated = syncDirFiles(scriptsDir, METAME_DIR, { fileList: BUNDLED_SCRIPTS });
538
+ scriptsUpdated = SCRIPT_DEPLOY_GROUPS.reduce((updated, group) => {
539
+ const destDir = group.destSubdir ? path.join(METAME_DIR, group.destSubdir) : METAME_DIR;
540
+ const changed = syncDirFiles(group.srcDir, destDir, { fileList: group.fileList });
541
+ return updated || changed;
542
+ }, false);
445
543
  if (scriptsUpdated) {
446
544
  console.log(`${icon("pkg")} Scripts synced to ~/.metame/.`);
447
545
  }
@@ -454,6 +552,21 @@ const binUpdated = syncDirFiles(path.join(__dirname, 'scripts', 'bin'), path.joi
454
552
  // Hooks: Claude Code event hooks (Stop, PostToolUse, etc.)
455
553
  const hooksUpdated = syncDirFiles(path.join(__dirname, 'scripts', 'hooks'), path.join(METAME_DIR, 'hooks'));
456
554
 
555
+ // Migrate legacy reactive flat paths to per-project directory structure
556
+ try {
557
+ const { migrate } = require('./scripts/migrate-reactive-paths');
558
+ const migrationReport = migrate(METAME_DIR);
559
+ if (migrationReport.migrated.length > 0) {
560
+ console.log(`${icon("pkg")} Migrated ${migrationReport.migrated.length} reactive file(s) to per-project dirs.`);
561
+ }
562
+ if (migrationReport.errors.length > 0) {
563
+ console.log(`${icon("warn")} Reactive migration had ${migrationReport.errors.length} error(s).`);
564
+ }
565
+ } catch (e) {
566
+ // Non-critical: migration failure should not block deploy
567
+ console.log(`${icon("warn")} Reactive path migration skipped: ${e.message}`);
568
+ }
569
+
457
570
  const daemonCodeUpdated = scriptsUpdated || binUpdated || hooksUpdated;
458
571
  const shouldAutoRestartAfterDeploy = (() => {
459
572
  const [cmd] = process.argv.slice(2);
@@ -2164,39 +2277,12 @@ WantedBy=default.target
2164
2277
  }
2165
2278
 
2166
2279
  if (process.platform === 'darwin') {
2167
- // macOS: delegate to launchd for auto-restart and boot persistence
2168
- // Kill any orphan daemon processes NOT managed by launchd
2169
- try {
2170
- const pids = findProcessesByPattern('node.*daemon\\.js');
2171
- const launchdPid = launchdIsRunning();
2172
- for (const n of pids) {
2173
- if (n !== launchdPid) {
2174
- try { process.kill(n, 'SIGTERM'); } catch { /* */ }
2175
- }
2176
- }
2177
- } catch { /* ignore */ }
2178
- // Clean stale lock/pid from orphan processes
2179
- if (fs.existsSync(DAEMON_LOCK)) {
2180
- try {
2181
- const lock = JSON.parse(fs.readFileSync(DAEMON_LOCK, 'utf8'));
2182
- const pid = parseInt(lock && lock.pid, 10);
2183
- if (pid) { process.kill(pid, 0); } // throws if dead
2184
- } catch {
2185
- // Owner is dead — clean stale files
2186
- try { fs.unlinkSync(DAEMON_PID); } catch { /* */ }
2187
- try { fs.unlinkSync(DAEMON_LOCK); } catch { /* */ }
2188
- }
2189
- }
2190
- ensureLaunchdPlist({ daemonScript: DAEMON_SCRIPT, daemonLog: DAEMON_LOG });
2191
- try {
2192
- execSync(`launchctl bootstrap gui/$(id -u) ${LAUNCHD_PLIST} 2>/dev/null`);
2193
- } catch { /* already bootstrapped */ }
2194
- // kickstart ensures the process is actually running now
2195
- try {
2196
- execSync(`launchctl kickstart gui/$(id -u)/${LAUNCHD_LABEL}`);
2197
- } catch { /* already running */ }
2198
- sleepSync(1500);
2199
- const pid = launchdIsRunning();
2280
+ const pid = ensureLaunchdDaemonRunning({
2281
+ daemonScript: DAEMON_SCRIPT,
2282
+ daemonLog: DAEMON_LOG,
2283
+ pidFile: DAEMON_PID,
2284
+ lockFile: DAEMON_LOCK,
2285
+ });
2200
2286
  if (pid) {
2201
2287
  console.log(`${icon("ok")} MetaMe daemon started via launchd (PID: ${pid})`);
2202
2288
  } else {
@@ -2241,17 +2327,7 @@ WantedBy=default.target
2241
2327
  try {
2242
2328
  execSync(`launchctl bootout gui/$(id -u)/${LAUNCHD_LABEL} 2>/dev/null`);
2243
2329
  } catch { /* not loaded */ }
2244
- // Also kill any orphan daemon processes not managed by launchd
2245
- try {
2246
- const pids = findProcessesByPattern('node.*daemon\\.js');
2247
- for (const n of pids) {
2248
- try { process.kill(n, 'SIGTERM'); } catch { /* */ }
2249
- }
2250
- if (pids.length) sleepSync(2000);
2251
- for (const n of pids) {
2252
- try { process.kill(n, 'SIGKILL'); } catch { /* already gone */ }
2253
- }
2254
- } catch { /* */ }
2330
+ terminateDaemonProcesses({ pidFile: DAEMON_PID, lockFile: DAEMON_LOCK });
2255
2331
  try { fs.unlinkSync(DAEMON_PID); } catch { /* */ }
2256
2332
  try { fs.unlinkSync(DAEMON_LOCK); } catch { /* */ }
2257
2333
  console.log(`${icon("ok")} Daemon stopped. launchd auto-restart disabled.`);
@@ -2295,23 +2371,17 @@ WantedBy=default.target
2295
2371
  }
2296
2372
 
2297
2373
  if (process.platform === 'darwin') {
2298
- // macOS: use launchctl kickstart -k (kills + restarts in one atomic op)
2299
- ensureLaunchdPlist({ daemonScript: DAEMON_SCRIPT, daemonLog: DAEMON_LOG });
2300
- try {
2301
- execSync(`launchctl bootstrap gui/$(id -u) ${LAUNCHD_PLIST} 2>/dev/null`);
2302
- } catch { /* already bootstrapped */ }
2303
- try {
2304
- execSync(`launchctl kickstart -k gui/$(id -u)/${LAUNCHD_LABEL}`);
2305
- sleepSync(1500);
2306
- const pid = launchdIsRunning();
2307
- if (pid) {
2308
- console.log(`${icon("ok")} Daemon restarted via launchd (PID: ${pid})`);
2309
- } else {
2310
- console.log(`${icon("ok")} Daemon restart requested via launchd...`);
2311
- }
2312
- } catch (e) {
2313
- console.error(`${icon("fail")} launchctl kickstart failed: ${e.message}`);
2314
- process.exit(1);
2374
+ const pid = ensureLaunchdDaemonRunning({
2375
+ daemonScript: DAEMON_SCRIPT,
2376
+ daemonLog: DAEMON_LOG,
2377
+ pidFile: DAEMON_PID,
2378
+ lockFile: DAEMON_LOCK,
2379
+ restart: true,
2380
+ });
2381
+ if (pid) {
2382
+ console.log(`${icon("ok")} Daemon restarted via launchd (PID: ${pid})`);
2383
+ } else {
2384
+ console.log(`${icon("ok")} Daemon restart requested via launchd...`);
2315
2385
  }
2316
2386
  process.exit(0);
2317
2387
  }
@@ -2369,6 +2439,13 @@ WantedBy=default.target
2369
2439
  }
2370
2440
  } catch { /* lock stale or invalid */ }
2371
2441
  }
2442
+ if (!isRunning && process.platform === 'darwin' && fs.existsSync(LAUNCHD_PLIST)) {
2443
+ const pid = launchdIsRunning();
2444
+ if (pid) {
2445
+ isRunning = true;
2446
+ runningPid = pid;
2447
+ }
2448
+ }
2372
2449
 
2373
2450
  console.log(`${icon("bot")} MetaMe Daemon: ${isRunning ? icon("green") + ' Running' : icon("red") + ' Stopped'}`);
2374
2451
  if (state.started_at) console.log(` Started: ${state.started_at}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.5.19",
3
+ "version": "1.5.20",
4
4
  "description": "The Cognitive Profile Layer for Claude Code. Knows how you think, not just what you said.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "test:daemon-status": "node --test scripts/daemon-restart-status.test.js",
22
22
  "start": "node index.js",
23
23
  "push": "bash scripts/bin/push-clean.sh",
24
- "sync:plugin": "node -e \"const fs=require('fs'),path=require('path');const ex=new Set(['sync-readme.js','test_daemon.js','daemon.yaml']);const files=fs.readdirSync('scripts').filter(f=>!ex.has(f)&&!/\\.test\\.js$/.test(f)&&/\\.(js|yaml|sh)$/.test(f));files.forEach(f=>fs.copyFileSync('scripts/'+f,'plugin/scripts/'+f));\" && mkdir -p plugin/scripts/hooks && cp scripts/hooks/*.js plugin/scripts/hooks/ && echo 'Plugin scripts synced'",
24
+ "sync:plugin": "node scripts/sync-plugin.js",
25
25
  "sync:readme": "node scripts/sync-readme.js",
26
26
  "restart:daemon": "node index.js stop 2>/dev/null; sleep 1; node index.js start 2>/dev/null || echo '鈿狅笍 Daemon not running or restart failed'",
27
27
  "prepublishOnly": "node -e \"const fs=require('fs');['scripts/daemon.yaml','plugin/scripts/daemon.yaml'].forEach(f=>{if(fs.existsSync(f)){const c=fs.readFileSync(f,'utf8');if(/bot_token:\\s*[^n]|app_secret:\\s*[^n]|enabled:\\s*true/.test(c)){console.error('ABORT: Real credentials found in '+f);process.exit(1)}}})\"",
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
5
+ cd "$ROOT"
6
+
7
+ if ! command -v node >/dev/null 2>&1; then
8
+ echo "Node.js is required" >&2
9
+ exit 1
10
+ fi
11
+
12
+ if [[ -f package-lock.json ]]; then
13
+ echo "Using npm ci in $(pwd)"
14
+ npm ci --no-audit --no-fund
15
+ else
16
+ echo "package-lock.json missing, using npm install in $(pwd)"
17
+ npm install --no-audit --no-fund
18
+ fi
19
+
20
+ node -e "require('js-yaml'); require('./scripts/resolve-yaml'); console.log('bootstrap-ok')"
@@ -0,0 +1,190 @@
1
+ 'use strict';
2
+
3
+ function createAudit(deps) {
4
+ const {
5
+ fs,
6
+ logFile,
7
+ stateFile,
8
+ stdout = process.stdout,
9
+ stderr = process.stderr,
10
+ usageRetentionDaysDefault = 30,
11
+ } = deps;
12
+
13
+ let logMaxSize = 1048576;
14
+ let cachedState = null;
15
+
16
+ function ensureUsageShape(state) {
17
+ if (!state.usage || typeof state.usage !== 'object') state.usage = {};
18
+ if (!state.usage.categories || typeof state.usage.categories !== 'object') state.usage.categories = {};
19
+ if (!state.usage.daily || typeof state.usage.daily !== 'object') state.usage.daily = {};
20
+ const keepDays = Number(state.usage.retention_days);
21
+ state.usage.retention_days = Number.isFinite(keepDays) && keepDays >= 7
22
+ ? Math.floor(keepDays)
23
+ : usageRetentionDaysDefault;
24
+ }
25
+
26
+ function ensureStateShape(state) {
27
+ if (!state || typeof state !== 'object') return {
28
+ pid: null,
29
+ budget: { date: null, tokens_used: 0 },
30
+ tasks: {},
31
+ sessions: {},
32
+ started_at: null,
33
+ usage: { retention_days: usageRetentionDaysDefault, categories: {}, daily: {} },
34
+ };
35
+ if (!state.budget || typeof state.budget !== 'object') state.budget = { date: null, tokens_used: 0 };
36
+ if (typeof state.budget.tokens_used !== 'number') state.budget.tokens_used = Number(state.budget.tokens_used) || 0;
37
+ if (!Object.prototype.hasOwnProperty.call(state.budget, 'date')) state.budget.date = null;
38
+ if (!state.tasks || typeof state.tasks !== 'object') state.tasks = {};
39
+ if (!state.sessions || typeof state.sessions !== 'object') state.sessions = {};
40
+ ensureUsageShape(state);
41
+ return state;
42
+ }
43
+
44
+ function pruneDailyUsage(usage, todayIso) {
45
+ const keepDays = usage.retention_days || usageRetentionDaysDefault;
46
+ const cutoff = new Date(`${todayIso}T00:00:00.000Z`);
47
+ cutoff.setUTCDate(cutoff.getUTCDate() - (keepDays - 1));
48
+ const cutoffIso = cutoff.toISOString().slice(0, 10);
49
+ for (const day of Object.keys(usage.daily || {})) {
50
+ if (day < cutoffIso) delete usage.daily[day];
51
+ }
52
+ }
53
+
54
+ function readStateFromDisk() {
55
+ try {
56
+ const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
57
+ return ensureStateShape(state);
58
+ } catch {
59
+ return ensureStateShape({
60
+ pid: null,
61
+ budget: { date: null, tokens_used: 0 },
62
+ tasks: {},
63
+ sessions: {},
64
+ started_at: null,
65
+ });
66
+ }
67
+ }
68
+
69
+ function refreshLogMaxSize(cfg) {
70
+ logMaxSize = (cfg && cfg.daemon && cfg.daemon.log_max_size) || 1048576;
71
+ }
72
+
73
+ function log(level, msg) {
74
+ const ts = new Date().toISOString();
75
+ const line = `[${ts}] [${level}] ${msg}\n`;
76
+ try {
77
+ if (fs.existsSync(logFile)) {
78
+ const stat = fs.statSync(logFile);
79
+ if (stat.size > logMaxSize) {
80
+ const bakFile = logFile + '.bak';
81
+ if (fs.existsSync(bakFile)) fs.unlinkSync(bakFile);
82
+ fs.renameSync(logFile, bakFile);
83
+ }
84
+ }
85
+ fs.appendFileSync(logFile, line, 'utf8');
86
+ } catch {
87
+ stderr.write(line);
88
+ }
89
+ if (stdout && !stdout.isTTY && typeof stdout.write === 'function') {
90
+ stdout.write(line);
91
+ }
92
+ }
93
+
94
+ function loadState() {
95
+ if (!cachedState) cachedState = readStateFromDisk();
96
+ return cachedState;
97
+ }
98
+
99
+ function saveState(state) {
100
+ const next = ensureStateShape(state);
101
+ if (cachedState && cachedState !== next) {
102
+ const current = ensureStateShape(cachedState);
103
+
104
+ const currentBudgetDate = String(current.budget.date || '');
105
+ const nextBudgetDate = String(next.budget.date || '');
106
+ const currentBudgetTokens = Math.max(0, Math.floor(Number(current.budget.tokens_used) || 0));
107
+ const nextBudgetTokens = Math.max(0, Math.floor(Number(next.budget.tokens_used) || 0));
108
+ if (currentBudgetDate && (!nextBudgetDate || currentBudgetDate > nextBudgetDate)) {
109
+ next.budget.date = currentBudgetDate;
110
+ next.budget.tokens_used = currentBudgetTokens;
111
+ } else if (currentBudgetDate && currentBudgetDate === nextBudgetDate) {
112
+ next.budget.tokens_used = Math.max(currentBudgetTokens, nextBudgetTokens);
113
+ }
114
+
115
+ const currentKeepDays = Number(current.usage.retention_days) || usageRetentionDaysDefault;
116
+ const nextKeepDays = Number(next.usage.retention_days) || usageRetentionDaysDefault;
117
+ next.usage.retention_days = Math.max(currentKeepDays, nextKeepDays);
118
+
119
+ for (const [category, curMeta] of Object.entries(current.usage.categories || {})) {
120
+ if (!next.usage.categories[category] || typeof next.usage.categories[category] !== 'object') {
121
+ next.usage.categories[category] = {};
122
+ }
123
+ const curTotal = Math.max(0, Math.floor(Number(curMeta && curMeta.total) || 0));
124
+ const nextTotal = Math.max(0, Math.floor(Number(next.usage.categories[category].total) || 0));
125
+ if (curTotal > nextTotal) next.usage.categories[category].total = curTotal;
126
+
127
+ const curUpdated = String(curMeta && curMeta.updated_at || '');
128
+ const nextUpdated = String(next.usage.categories[category].updated_at || '');
129
+ if (curUpdated && curUpdated > nextUpdated) next.usage.categories[category].updated_at = curUpdated;
130
+ }
131
+
132
+ for (const [day, curDayUsageRaw] of Object.entries(current.usage.daily || {})) {
133
+ const curDayUsage = (curDayUsageRaw && typeof curDayUsageRaw === 'object') ? curDayUsageRaw : {};
134
+ if (!next.usage.daily[day] || typeof next.usage.daily[day] !== 'object') {
135
+ next.usage.daily[day] = {};
136
+ }
137
+ const nextDayUsage = next.usage.daily[day];
138
+ for (const [key, curValue] of Object.entries(curDayUsage)) {
139
+ const curNum = Math.max(0, Math.floor(Number(curValue) || 0));
140
+ const nextNum = Math.max(0, Math.floor(Number(nextDayUsage[key]) || 0));
141
+ if (curNum > nextNum) nextDayUsage[key] = curNum;
142
+ }
143
+ const categorySum = Object.entries(nextDayUsage)
144
+ .filter(([key]) => key !== 'total')
145
+ .reduce((sum, [, value]) => sum + Math.max(0, Math.floor(Number(value) || 0)), 0);
146
+ nextDayUsage.total = Math.max(Math.max(0, Math.floor(Number(nextDayUsage.total) || 0)), categorySum);
147
+ }
148
+
149
+ const currentUsageUpdated = String(current.usage.updated_at || '');
150
+ const nextUsageUpdated = String(next.usage.updated_at || '');
151
+ if (currentUsageUpdated && currentUsageUpdated > nextUsageUpdated) {
152
+ next.usage.updated_at = currentUsageUpdated;
153
+ }
154
+
155
+ if (current.sessions && typeof current.sessions === 'object') {
156
+ if (!next.sessions || typeof next.sessions !== 'object') next.sessions = {};
157
+ for (const [key, curSession] of Object.entries(current.sessions)) {
158
+ if (!next.sessions[key]) {
159
+ next.sessions[key] = curSession;
160
+ } else {
161
+ const curActive = Number(curSession && curSession.last_active) || 0;
162
+ const nextActive = Number(next.sessions[key] && next.sessions[key].last_active) || 0;
163
+ if (curActive > nextActive) next.sessions[key] = curSession;
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ cachedState = next;
170
+ try {
171
+ fs.writeFileSync(stateFile, JSON.stringify(next, null, 2), 'utf8');
172
+ } catch (e) {
173
+ log('ERROR', `Failed to save state: ${e.message}`);
174
+ }
175
+ }
176
+
177
+ return {
178
+ refreshLogMaxSize,
179
+ log,
180
+ loadState,
181
+ saveState,
182
+ ensureUsageShape,
183
+ ensureStateShape,
184
+ pruneDailyUsage,
185
+ };
186
+ }
187
+
188
+ module.exports = {
189
+ createAudit,
190
+ };