total-recall-brain 3.1.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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/bin/antigravity.mjs +212 -0
  4. package/bin/init.mjs +36 -0
  5. package/bin/oci-sniper.sh +69 -0
  6. package/bin/total-recall.mjs +229 -0
  7. package/docs/ARCHITECTURE.md +251 -0
  8. package/docs/IDE_INTEGRATION.md +112 -0
  9. package/package.json +71 -0
  10. package/scaffold/.agent/skills/total-recall/SKILL.md +195 -0
  11. package/scaffold/.agent/skills/total-recall/evals/evals.json +20 -0
  12. package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +145 -0
  13. package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +75 -0
  14. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +16 -0
  15. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +17 -0
  16. package/scaffold/.agent/skills/total-recall/references/api-reference.md +187 -0
  17. package/scaffold/.agent/skills/total-recall/references/architecture-reference.md +81 -0
  18. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +103 -0
  19. package/scaffold/.agent/skills/total-recall/references/ssss-reference.md +88 -0
  20. package/scaffold/.agent/skills/total-recall/references/troubleshooting.md +68 -0
  21. package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +83 -0
  22. package/scaffold/.agent/skills/total-recall/skills/cli-agents/SKILL.md +122 -0
  23. package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +27 -0
  24. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/antigravity.txt +20 -0
  25. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/claude.txt +58 -0
  26. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/codex.txt +81 -0
  27. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/gemini.txt +44 -0
  28. package/scaffold/.agent/skills/total-recall/skills/research/SKILL.md +337 -0
  29. package/scaffold/.agent/skills/total-recall/skills/skill/SKILL.md +281 -0
  30. package/scaffold/.agent/skills/total-recall/skills/skill/assets/skill-template/SKILL.md +20 -0
  31. package/scaffold/.agent/skills/total-recall/skills/skill/evals/evals.json +20 -0
  32. package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-add.sh +14 -0
  33. package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-delete.sh +14 -0
  34. package/scaffold/.agent/skills/total-recall/skills/skill/learnings/.gitkeep +0 -0
  35. package/scaffold/.agent/skills/total-recall/skills/skill/references/.gitkeep +0 -0
  36. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/.gitkeep +0 -0
  37. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/create-skill.sh +65 -0
  38. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/enforce-skill-optimization.mjs +135 -0
  39. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs +124 -0
  40. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs +207 -0
  41. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/skill-watcher.mjs +64 -0
  42. package/scaffold/.agent/skills/total-recall/skills/skill/subagents/audit-all-skills.md +62 -0
  43. package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +27 -0
  44. package/scaffold/.agent/skills/total-recall/skills/ssss/evals/evals.json +17 -0
  45. package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +117 -0
  46. package/scaffold/.agent/skills/total-recall/skills/ssss/scripts/validate-schema.mjs +54 -0
  47. package/scaffold/.agent/skills/total-recall/skills/ssss/subagents/audit-ssss-node.md +9 -0
  48. package/scaffold/.agent/skills/total-recall/subagents/total-recall-diagnostician.md +13 -0
  49. package/src/cli/agent-dir.mjs +105 -0
  50. package/src/cli/backup.mjs +313 -0
  51. package/src/cli/brain.mjs +224 -0
  52. package/src/cli/chat.mjs +74 -0
  53. package/src/cli/config.mjs +247 -0
  54. package/src/cli/connect.mjs +872 -0
  55. package/src/cli/daemon.mjs +228 -0
  56. package/src/cli/deploy-ui.mjs +1793 -0
  57. package/src/cli/deploy.mjs +1726 -0
  58. package/src/cli/doctor.mjs +254 -0
  59. package/src/cli/dream.mjs +80 -0
  60. package/src/cli/forget.mjs +92 -0
  61. package/src/cli/friction.mjs +21 -0
  62. package/src/cli/generate-pat.mjs +65 -0
  63. package/src/cli/hash-password.mjs +22 -0
  64. package/src/cli/help.mjs +153 -0
  65. package/src/cli/import-rules.mjs +102 -0
  66. package/src/cli/ingest/index.mjs +318 -0
  67. package/src/cli/ingest/parsers/chrome-bookmarks.mjs +238 -0
  68. package/src/cli/ingest/parsers/google-keep.mjs +147 -0
  69. package/src/cli/ingest/parsers/search-history.mjs +142 -0
  70. package/src/cli/ingest/parsers/youtube-history.mjs +181 -0
  71. package/src/cli/ingest/utils/dedup.mjs +69 -0
  72. package/src/cli/ingest/utils/takeout-walker.mjs +101 -0
  73. package/src/cli/ingest.mjs +102 -0
  74. package/src/cli/init.mjs +736 -0
  75. package/src/cli/integration-dispatcher.mjs +128 -0
  76. package/src/cli/lint.mjs +166 -0
  77. package/src/cli/map.mjs +194 -0
  78. package/src/cli/migrate.mjs +87 -0
  79. package/src/cli/rebuild.mjs +88 -0
  80. package/src/cli/recall.mjs +219 -0
  81. package/src/cli/relay.mjs +518 -0
  82. package/src/cli/remember.mjs +306 -0
  83. package/src/cli/research.mjs +381 -0
  84. package/src/cli/reset-password.mjs +75 -0
  85. package/src/cli/restore.mjs +135 -0
  86. package/src/cli/setup.mjs +616 -0
  87. package/src/cli/share.mjs +180 -0
  88. package/src/cli/skill.mjs +313 -0
  89. package/src/cli/snapshot.mjs +108 -0
  90. package/src/cli/start.mjs +63 -0
  91. package/src/cli/status.mjs +296 -0
  92. package/src/cli/sync.mjs +383 -0
  93. package/src/cli/uninstall.mjs +613 -0
  94. package/src/cli/upgrade.mjs +141 -0
  95. package/src/cli/wizard.html +3682 -0
  96. package/src/core/blackboard.mjs +54 -0
  97. package/src/core/clarity-rewriter.mjs +770 -0
  98. package/src/core/conclusion-writer.mjs +259 -0
  99. package/src/core/config.mjs +277 -0
  100. package/src/core/conflict-detector.mjs +536 -0
  101. package/src/core/crypto.mjs +107 -0
  102. package/src/core/daemon-control.mjs +158 -0
  103. package/src/core/daemon-loop.mjs +364 -0
  104. package/src/core/dream.mjs +347 -0
  105. package/src/core/drift-detector.mjs +143 -0
  106. package/src/core/embeddings.mjs +632 -0
  107. package/src/core/emergency-alerts.mjs +137 -0
  108. package/src/core/evolution.mjs +107 -0
  109. package/src/core/fact-seeker.mjs +1602 -0
  110. package/src/core/friction.mjs +95 -0
  111. package/src/core/import-rules.mjs +308 -0
  112. package/src/core/inference-engine.mjs +367 -0
  113. package/src/core/logger.mjs +75 -0
  114. package/src/core/memory-layers.mjs +96 -0
  115. package/src/core/migrate.mjs +116 -0
  116. package/src/core/notifications.mjs +77 -0
  117. package/src/core/operation-validator.mjs +272 -0
  118. package/src/core/optimizer.mjs +330 -0
  119. package/src/core/pattern_detector.mjs +113 -0
  120. package/src/core/post-mortem.mjs +413 -0
  121. package/src/core/protect-instructions.mjs +148 -0
  122. package/src/core/quick-capture.mjs +145 -0
  123. package/src/core/research-queue.mjs +431 -0
  124. package/src/core/research.mjs +461 -0
  125. package/src/core/runtime.mjs +515 -0
  126. package/src/core/sandbox.mjs +163 -0
  127. package/src/core/scheduler.mjs +569 -0
  128. package/src/core/schema.mjs +333 -0
  129. package/src/core/search.mjs +219 -0
  130. package/src/core/semantic-index.mjs +276 -0
  131. package/src/core/session-watcher.mjs +850 -0
  132. package/src/core/snapshot.mjs +133 -0
  133. package/src/core/source-adapters.mjs +1038 -0
  134. package/src/core/steering.mjs +137 -0
  135. package/src/core/surface.mjs +444 -0
  136. package/src/core/task_runner.mjs +125 -0
  137. package/src/core/tts.mjs +142 -0
  138. package/src/core/usage-tracker.mjs +349 -0
  139. package/src/core/vault-cache.mjs +76 -0
  140. package/src/core/vault.mjs +273 -0
  141. package/src/core/watchdog.mjs +236 -0
  142. package/src/server/api.mjs +944 -0
  143. package/src/server/auth.mjs +410 -0
  144. package/src/server/index.mjs +868 -0
  145. package/src/server/keys.mjs +181 -0
  146. package/src/server/rest.mjs +1652 -0
  147. package/src/server/route-manifest.json +77 -0
  148. package/src/server/routes/_shared.mjs +93 -0
  149. package/src/server/routes/keys.mjs +55 -0
  150. package/src/server/routes/memory.mjs +249 -0
  151. package/src/server/routes/sessions.mjs +336 -0
  152. package/src/server/routes/share.mjs +138 -0
  153. package/src/server/tools.mjs +618 -0
  154. package/templates/Caddyfile +37 -0
  155. package/templates/com.totalrecall.backup.plist +42 -0
  156. package/templates/com.totalrecall.daemon.plist +40 -0
  157. package/templates/com.totalrecall.duckdns.plist +23 -0
  158. package/templates/com.totalrecall.server.plist +46 -0
  159. package/templates/default-config/budget.yml +6 -0
  160. package/templates/default-config/runtime.yml +11 -0
  161. package/templates/default-config/security.yml +53 -0
  162. package/templates/default-config/voice.yml +34 -0
  163. package/templates/obsidian-queries/conflicts.md +26 -0
  164. package/templates/obsidian-queries/daily.md +17 -0
  165. package/templates/obsidian-queries/index.md +38 -0
  166. package/templates/obsidian-queries/skills.md +14 -0
  167. package/templates/onboarding-interview.md +72 -0
  168. package/templates/skill_engineering.md +40 -0
  169. package/templates/total-recall-daemon.service +21 -0
  170. package/templates/total-recall-server.service +20 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Total Recall — Text-to-Speech (Kokoro-82M)
3
+ *
4
+ * Kokoro-82M is a tiny (~82M parameter) high-quality multilingual TTS model.
5
+ * The reference implementation runs in Python; the recommended deployment is
6
+ * `kokoro-fastapi` or a similar HTTP wrapper running on the host.
7
+ *
8
+ * This module is the kernel-side façade. It does NOT bundle Python. Instead
9
+ * it speaks HTTP to a local Kokoro service whose URL lives in voice.yml.
10
+ * Operators wire Kokoro into systemd; the OS just routes through it.
11
+ *
12
+ * Configuration (~/.agent/config/voice.yml):
13
+ * tts:
14
+ * engine: kokoro # or "disabled"
15
+ * endpoint: http://127.0.0.1:8880/v1/audio/speech
16
+ * voice: af_heart # any Kokoro voice id
17
+ * format: wav # wav | mp3 | flac | opus | pcm
18
+ * speed: 1.0
19
+ *
20
+ * Behavior:
21
+ * - When `engine: disabled` or no config exists → `synthesize()` throws
22
+ * `TtsNotConfiguredError`. The HTTP layer surfaces this as 503 so the
23
+ * frontend can fall back to browser speechSynthesis.
24
+ * - When configured → POSTs to `endpoint` using the OpenAI-compatible
25
+ * `/v1/audio/speech` contract (Kokoro-fastapi default) and streams the
26
+ * audio buffer back.
27
+ */
28
+
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import yaml from 'yaml';
32
+ import { logger } from './logger.mjs';
33
+ import { brainDir } from './config.mjs';
34
+
35
+ const VOICE_CONFIG = path.join(brainDir, 'config', 'voice.yml');
36
+
37
+ const DEFAULT_CONFIG = {
38
+ tts: {
39
+ engine: 'disabled',
40
+ endpoint: 'http://127.0.0.1:8880/v1/audio/speech',
41
+ voice: 'af_heart',
42
+ format: 'wav',
43
+ speed: 1.0
44
+ }
45
+ };
46
+
47
+ export class TtsNotConfiguredError extends Error {
48
+ constructor(message = 'Kokoro TTS is not configured. See ~/.agent/config/voice.yml.') {
49
+ super(message);
50
+ this.name = 'TtsNotConfiguredError';
51
+ this.code = 'TTS_NOT_CONFIGURED';
52
+ }
53
+ }
54
+
55
+ export function loadVoiceConfig(file = VOICE_CONFIG) {
56
+ if (!fs.existsSync(file)) return DEFAULT_CONFIG;
57
+ try {
58
+ const parsed = yaml.parse(fs.readFileSync(file, 'utf8')) || {};
59
+ return { tts: { ...DEFAULT_CONFIG.tts, ...(parsed.tts || {}) } };
60
+ } catch (err) {
61
+ logger.error('tts', `Failed to parse voice.yml: ${err.message}`);
62
+ return DEFAULT_CONFIG;
63
+ }
64
+ }
65
+
66
+ const MIME_BY_FORMAT = {
67
+ wav: 'audio/wav',
68
+ mp3: 'audio/mpeg',
69
+ flac: 'audio/flac',
70
+ opus: 'audio/opus',
71
+ pcm: 'audio/L16'
72
+ };
73
+
74
+ /**
75
+ * Synthesize `text` to audio bytes using the configured Kokoro endpoint.
76
+ *
77
+ * @returns {Promise<{ buffer: Buffer, mimeType: string, format: string }>}
78
+ * @throws {TtsNotConfiguredError} when Kokoro is disabled or unreachable.
79
+ */
80
+ export async function synthesize(text, opts = {}) {
81
+ if (typeof text !== 'string' || !text.trim()) {
82
+ throw new Error('synthesize(): text must be a non-empty string');
83
+ }
84
+
85
+ const cfg = loadVoiceConfig().tts;
86
+ if (cfg.engine === 'disabled' || !cfg.endpoint) {
87
+ throw new TtsNotConfiguredError();
88
+ }
89
+
90
+ const voice = opts.voice || cfg.voice;
91
+ const format = opts.format || cfg.format;
92
+ const speed = opts.speed ?? cfg.speed;
93
+
94
+ const body = {
95
+ model: 'kokoro',
96
+ input: text,
97
+ voice,
98
+ response_format: format,
99
+ speed
100
+ };
101
+
102
+ const start = Date.now();
103
+ let res;
104
+ try {
105
+ res = await fetch(cfg.endpoint, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body: JSON.stringify(body)
109
+ });
110
+ } catch (err) {
111
+ // Kokoro service down or unreachable — treat as not-configured so the
112
+ // frontend can fall back rather than displaying a crash.
113
+ logger.warn('tts', `Kokoro endpoint unreachable: ${err.message}`);
114
+ throw new TtsNotConfiguredError(`Kokoro endpoint unreachable: ${err.message}`);
115
+ }
116
+
117
+ const latency_ms = Date.now() - start;
118
+
119
+ if (!res.ok) {
120
+ const detail = await res.text().catch(() => '');
121
+ logger.error('tts', `Kokoro returned ${res.status}: ${detail.slice(0, 200)}`, { latency_ms });
122
+ throw new Error(`Kokoro TTS failed: HTTP ${res.status}`);
123
+ }
124
+
125
+ const arrayBuffer = await res.arrayBuffer();
126
+ const buffer = Buffer.from(arrayBuffer);
127
+ logger.info('tts', `synthesized ${buffer.length} bytes (${format}, voice=${voice})`, {
128
+ latency_ms,
129
+ bytes: buffer.length
130
+ });
131
+
132
+ return {
133
+ buffer,
134
+ mimeType: MIME_BY_FORMAT[format] || 'application/octet-stream',
135
+ format
136
+ };
137
+ }
138
+
139
+ export function isTtsEnabled() {
140
+ const cfg = loadVoiceConfig().tts;
141
+ return cfg.engine !== 'disabled' && !!cfg.endpoint;
142
+ }
@@ -0,0 +1,349 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import yaml from 'yaml';
5
+ import { logger } from './logger.mjs';
6
+ import { brainDir } from './config.mjs';
7
+
8
+ // Standard pricing constants ($ per million tokens)
9
+ export const PRICING = {
10
+ claude: {
11
+ input: 3.00 / 1000000,
12
+ output: 15.00 / 1000000,
13
+ cacheRead: 0.30 / 1000000,
14
+ cacheCreation: 3.75 / 1000000
15
+ },
16
+ gemini: {
17
+ input: 0.075 / 1000000,
18
+ output: 0.30 / 1000000
19
+ },
20
+ codex: {
21
+ input: 2.50 / 1000000,
22
+ output: 10.00 / 1000000
23
+ }
24
+ };
25
+
26
+ /**
27
+ * Recursively find files in a directory.
28
+ */
29
+ function walkDir(dir, callback) {
30
+ if (!fs.existsSync(dir)) return;
31
+ try {
32
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
33
+ for (const entry of entries) {
34
+ const fullPath = path.join(dir, entry.name);
35
+ if (entry.isDirectory()) {
36
+ walkDir(fullPath, callback);
37
+ } else if (entry.isFile()) {
38
+ callback(fullPath);
39
+ }
40
+ }
41
+ } catch (err) {
42
+ logger.warn('usage-tracker', `Error walking directory ${dir}: ${err.message}`);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Calculates local token and dollar cost usage across Gemini, Claude, and Codex.
48
+ * Tracks usage in the last 24 hours (daily) and last 7 days (weekly).
49
+ *
50
+ * @returns {object} Combined daily and weekly metrics and breakdown per agent.
51
+ */
52
+ export function calculateCurrentCost() {
53
+ const now = new Date();
54
+
55
+ const dailyLimitMs = 24 * 60 * 60 * 1000;
56
+ const weeklyLimitMs = 7 * 24 * 60 * 60 * 1000;
57
+
58
+ // Format local date string as YYYY-MM-DD
59
+ const year = now.getFullYear();
60
+ const month = String(now.getMonth() + 1).padStart(2, '0');
61
+ const day = String(now.getDate()).padStart(2, '0');
62
+ const todayStr = `${year}-${month}-${day}`;
63
+
64
+ const gemini = {
65
+ dailyInput: 0,
66
+ dailyOutput: 0,
67
+ weeklyInput: 0,
68
+ weeklyOutput: 0
69
+ };
70
+
71
+ const claude = {
72
+ dailyCost: 0,
73
+ weeklyCost: 0
74
+ };
75
+
76
+ const codex = {
77
+ dailyInput: 0,
78
+ dailyOutput: 0,
79
+ weeklyInput: 0,
80
+ weeklyOutput: 0
81
+ };
82
+
83
+ // 1. Gemini Ingestion
84
+ const geminiTmpBase = path.join(os.homedir(), '.gemini', 'tmp');
85
+ if (fs.existsSync(geminiTmpBase)) {
86
+ try {
87
+ const dirs = fs.readdirSync(geminiTmpBase).filter(d => {
88
+ const full = path.join(geminiTmpBase, d, 'chats');
89
+ try {
90
+ return fs.existsSync(full) && fs.statSync(full).isDirectory();
91
+ } catch {
92
+ return false;
93
+ }
94
+ });
95
+
96
+ for (const dir of dirs) {
97
+ const chatsDir = path.join(geminiTmpBase, dir, 'chats');
98
+ try {
99
+ const files = fs.readdirSync(chatsDir).filter(f => f.endsWith('.jsonl'));
100
+ for (const file of files) {
101
+ const filePath = path.join(chatsDir, file);
102
+ try {
103
+ const stat = fs.statSync(filePath);
104
+ const ageMs = now.getTime() - stat.mtime.getTime();
105
+
106
+ // Skip files older than 7 days entirely to avoid reading large logs
107
+ if (ageMs > weeklyLimitMs) continue;
108
+
109
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
110
+ for (const line of lines) {
111
+ try {
112
+ const entry = JSON.parse(line);
113
+ if (entry.tokens) {
114
+ const timestamp = entry.timestamp ? new Date(entry.timestamp) : stat.mtime;
115
+ const entryAgeMs = now.getTime() - timestamp.getTime();
116
+
117
+ const input = entry.tokens.input || 0;
118
+ const output = entry.tokens.output || 0;
119
+
120
+ if (entryAgeMs <= dailyLimitMs) {
121
+ gemini.dailyInput += input;
122
+ gemini.dailyOutput += output;
123
+ }
124
+ if (entryAgeMs <= weeklyLimitMs) {
125
+ gemini.weeklyInput += input;
126
+ gemini.weeklyOutput += output;
127
+ }
128
+ }
129
+ } catch { /* malformed line */ }
130
+ }
131
+ } catch (err) { /* unreadable file */ }
132
+ }
133
+ } catch { /* unreadable directory */ }
134
+ }
135
+ } catch (err) {
136
+ logger.warn('usage-tracker', `Failed to read Gemini tmp chats: ${err.message}`);
137
+ }
138
+ }
139
+
140
+ // 2. Claude Ingestion
141
+ const statsFile = path.join(os.homedir(), '.claude', 'stats-cache.json');
142
+ if (fs.existsSync(statsFile)) {
143
+ try {
144
+ const data = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
145
+ const modelStats = {};
146
+ const fallbackCostPerToken = 5.00 / 1000000; // conservative blended rate
147
+
148
+ // Calculate historical cost per token for each model
149
+ const usageSrc = data.modelUsage || data.models;
150
+ if (usageSrc) {
151
+ for (const [model, stats] of Object.entries(usageSrc)) {
152
+ const input = stats.inputTokens || 0;
153
+ const output = stats.outputTokens || 0;
154
+ const cacheRead = stats.cacheReadInputTokens || 0;
155
+ const cacheCreation = stats.cacheCreationInputTokens || 0;
156
+
157
+ const totalTokens = input + output + cacheRead + cacheCreation;
158
+ if (totalTokens > 0) {
159
+ const totalCost = (input * PRICING.claude.input) +
160
+ (output * PRICING.claude.output) +
161
+ (cacheRead * PRICING.claude.cacheRead) +
162
+ (cacheCreation * PRICING.claude.cacheCreation);
163
+ modelStats[model] = {
164
+ costPerToken: totalCost / totalTokens
165
+ };
166
+ }
167
+ }
168
+ }
169
+
170
+ // Parse daily activity
171
+ if (Array.isArray(data.dailyModelTokens)) {
172
+ for (const entry of data.dailyModelTokens) {
173
+ if (!entry.date) continue;
174
+
175
+ const entryDate = new Date(`${entry.date}T12:00:00`); // parse cleanly in local/system time
176
+ const diffMs = now.getTime() - entryDate.getTime();
177
+ const isToday = entry.date === todayStr;
178
+ const isThisWeek = diffMs <= weeklyLimitMs;
179
+
180
+ if (isToday || isThisWeek) {
181
+ let entryCost = 0;
182
+ if (entry.tokensByModel) {
183
+ for (const [model, tokens] of Object.entries(entry.tokensByModel)) {
184
+ const costPerToken = modelStats[model]?.costPerToken || fallbackCostPerToken;
185
+ entryCost += tokens * costPerToken;
186
+ }
187
+ }
188
+ if (isToday) {
189
+ claude.dailyCost += entryCost;
190
+ }
191
+ if (isThisWeek) {
192
+ claude.weeklyCost += entryCost;
193
+ }
194
+ }
195
+ }
196
+ }
197
+ } catch (err) {
198
+ logger.warn('usage-tracker', `Failed to read Claude stats-cache.json: ${err.message}`);
199
+ }
200
+ }
201
+
202
+ // 3. Codex Ingestion
203
+ const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
204
+ if (fs.existsSync(codexSessionsDir)) {
205
+ try {
206
+ walkDir(codexSessionsDir, (filePath) => {
207
+ if (!filePath.endsWith('.jsonl')) return;
208
+ try {
209
+ const stat = fs.statSync(filePath);
210
+ const ageMs = now.getTime() - stat.mtime.getTime();
211
+ if (ageMs > weeklyLimitMs) return;
212
+
213
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
214
+ for (const line of lines) {
215
+ try {
216
+ const entry = JSON.parse(line);
217
+ if (entry.usage) {
218
+ const timestamp = entry.timestamp ? new Date(entry.timestamp) : stat.mtime;
219
+ const entryAgeMs = now.getTime() - timestamp.getTime();
220
+
221
+ const input = entry.usage.prompt_tokens || entry.usage.input_tokens || 0;
222
+ const output = entry.usage.completion_tokens || entry.usage.output_tokens || 0;
223
+
224
+ if (entryAgeMs <= dailyLimitMs) {
225
+ codex.dailyInput += input;
226
+ codex.dailyOutput += output;
227
+ }
228
+ if (entryAgeMs <= weeklyLimitMs) {
229
+ codex.weeklyInput += input;
230
+ codex.weeklyOutput += output;
231
+ }
232
+ }
233
+ } catch { /* line error */ }
234
+ }
235
+ } catch { /* file error */ }
236
+ });
237
+ } catch (err) {
238
+ logger.warn('usage-tracker', `Failed to scan Codex sessions: ${err.message}`);
239
+ }
240
+ }
241
+
242
+ // Cost conversion
243
+ const geminiDailyCost = (gemini.dailyInput * PRICING.gemini.input) + (gemini.dailyOutput * PRICING.gemini.output);
244
+ const geminiWeeklyCost = (gemini.weeklyInput * PRICING.gemini.input) + (gemini.weeklyOutput * PRICING.gemini.output);
245
+
246
+ const codexDailyCost = (codex.dailyInput * PRICING.codex.input) + (codex.dailyOutput * PRICING.codex.output);
247
+ const codexWeeklyCost = (codex.weeklyInput * PRICING.codex.input) + (codex.weeklyOutput * PRICING.codex.output);
248
+
249
+ const dailyUsd = geminiDailyCost + claude.dailyCost + codexDailyCost;
250
+ const weeklyUsd = geminiWeeklyCost + claude.weeklyCost + codexWeeklyCost;
251
+
252
+ return {
253
+ timestamp: now.toISOString(),
254
+ dailyUsd,
255
+ weeklyUsd,
256
+ breakdown: {
257
+ gemini: {
258
+ dailyUsd: geminiDailyCost,
259
+ weeklyUsd: geminiWeeklyCost,
260
+ dailyTokens: gemini.dailyInput + gemini.dailyOutput,
261
+ weeklyTokens: gemini.weeklyInput + gemini.weeklyOutput
262
+ },
263
+ claude: {
264
+ dailyUsd: claude.dailyCost,
265
+ weeklyUsd: claude.weeklyCost
266
+ },
267
+ codex: {
268
+ dailyUsd: codexDailyCost,
269
+ weeklyUsd: codexWeeklyCost,
270
+ dailyTokens: codex.dailyInput + codex.dailyOutput,
271
+ weeklyTokens: codex.weeklyInput + codex.weeklyOutput
272
+ }
273
+ }
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Safely load and parse budget.yml config.
279
+ *
280
+ * @param {string} configPath
281
+ * @returns {object} Budget config structure.
282
+ */
283
+ export function loadBudgetConfig(configPath) {
284
+ if (!fs.existsSync(configPath)) return {};
285
+ try {
286
+ const raw = fs.readFileSync(configPath, 'utf8');
287
+ return yaml.parse(raw) || {};
288
+ } catch (err) {
289
+ logger.warn('usage-tracker', `Failed to parse budget config at ${configPath}: ${err.message}`);
290
+ return {};
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Checks if the current token/dollar spend exceeds safety limits in budget.yml.
296
+ * Throws a budget exhaustion error if caps are breached.
297
+ *
298
+ * @param {string} configPath - Path to the budget.yml config file.
299
+ */
300
+ export function checkBudgetSafety(configPath) {
301
+ try {
302
+ let resolvedPath = configPath;
303
+ if (!resolvedPath || !fs.existsSync(resolvedPath)) {
304
+ // Fallback 1: workspace root config
305
+ const workspacePath = path.join(process.cwd(), 'config', 'budget.yml');
306
+ if (fs.existsSync(workspacePath)) {
307
+ resolvedPath = workspacePath;
308
+ } else {
309
+ // Fallback 2: agentDir config
310
+ const agentHomePath = path.join(brainDir, 'config', 'budget.yml');
311
+ if (fs.existsSync(agentHomePath)) {
312
+ resolvedPath = agentHomePath;
313
+ }
314
+ }
315
+ }
316
+
317
+ if (!resolvedPath || !fs.existsSync(resolvedPath)) {
318
+ logger.warn('usage-tracker', `Budget config not found, bypassing safety check.`);
319
+ return;
320
+ }
321
+
322
+ const config = loadBudgetConfig(resolvedPath);
323
+ if (!config.budget || config.budget.enabled === false) {
324
+ return;
325
+ }
326
+
327
+ const { daily_cap_usd, weekly_cap_usd } = config.budget;
328
+ const usage = calculateCurrentCost();
329
+
330
+ if (daily_cap_usd !== undefined && usage.dailyUsd > daily_cap_usd) {
331
+ const errorMsg = `Budget safety limit reached! Daily spend of $${usage.dailyUsd.toFixed(4)} exceeds the cap of $${daily_cap_usd.toFixed(4)}. Dispatch blocked.`;
332
+ logger.error('usage-tracker', errorMsg);
333
+ throw new Error(errorMsg);
334
+ }
335
+
336
+ if (weekly_cap_usd !== undefined && usage.weeklyUsd > weekly_cap_usd) {
337
+ const errorMsg = `Budget safety limit reached! Weekly spend of $${usage.weeklyUsd.toFixed(4)} exceeds the cap of $${weekly_cap_usd.toFixed(4)}. Dispatch blocked.`;
338
+ logger.error('usage-tracker', errorMsg);
339
+ throw new Error(errorMsg);
340
+ }
341
+
342
+ logger.info('usage-tracker', `Spend within limits. Daily: $${usage.dailyUsd.toFixed(4)} / $${daily_cap_usd?.toFixed(4) || 'N/A'}. Weekly: $${usage.weeklyUsd.toFixed(4)} / $${weekly_cap_usd?.toFixed(4) || 'N/A'}.`);
343
+ } catch (err) {
344
+ if (err.message.includes('Budget safety limit reached')) {
345
+ throw err;
346
+ }
347
+ logger.warn('usage-tracker', `Error executing safety check: ${err.message}. Safeguard bypassed.`);
348
+ }
349
+ }
@@ -0,0 +1,76 @@
1
+ import fs from 'fs';
2
+ import { loadNodes } from './vault.mjs';
3
+ import { logger } from './logger.mjs';
4
+
5
+ let cachedNodes = null;
6
+ let watcher = null;
7
+ let currentVaultDir = null;
8
+
9
+ /**
10
+ * Retrieves memory nodes from the in-memory cache if fresh, otherwise loads them from disk.
11
+ * Automatically wires a file watcher on first load to track external changes.
12
+ *
13
+ * @param {string} vaultDir - Path to the memory vault directory
14
+ * @returns {Array} List of memory node objects
15
+ */
16
+ export function getNodes(vaultDir) {
17
+ currentVaultDir = vaultDir;
18
+ if (cachedNodes !== null) {
19
+ logger.debug('Vault cache hit');
20
+ return cachedNodes;
21
+ }
22
+ logger.info('Vault cache miss, loading nodes from disk', { vaultDir });
23
+ cachedNodes = loadNodes(vaultDir);
24
+ startWatcher(vaultDir);
25
+ return cachedNodes;
26
+ }
27
+
28
+ /**
29
+ * Invalidates the in-memory vault cache, forcing the next read to load from disk.
30
+ */
31
+ export function invalidate() {
32
+ logger.info('Vault cache invalidated');
33
+ cachedNodes = null;
34
+ }
35
+
36
+ /**
37
+ * Starts a filesystem watcher on the vault directory to automatically invalidate the cache
38
+ * when external edits occur.
39
+ *
40
+ * @param {string} vaultDir - Path to the vault directory to watch
41
+ */
42
+ function startWatcher(vaultDir) {
43
+ if (watcher) return;
44
+ if (!fs.existsSync(vaultDir)) {
45
+ try {
46
+ fs.mkdirSync(vaultDir, { recursive: true });
47
+ } catch (err) {
48
+ logger.error('Failed to create vault directory for watcher', { error: err.message });
49
+ return;
50
+ }
51
+ }
52
+
53
+ logger.info('Starting filesystem watcher for vault cache', { vaultDir });
54
+ try {
55
+ watcher = fs.watch(vaultDir, { recursive: true }, (eventType, filename) => {
56
+ if (filename && filename.endsWith('.md')) {
57
+ logger.info(`Vault filesystem change detected (${eventType}: ${filename}), invalidating cache`);
58
+ invalidate();
59
+ }
60
+ });
61
+ // Unref the watcher so it does not prevent Node from exiting cleanly
62
+ watcher.unref();
63
+ } catch (err) {
64
+ logger.warn('Failed to start vault fs watcher, cache will rely on explicit invalidations', { error: err.message });
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Explicitly boots the filesystem watcher for the vault directory.
70
+ *
71
+ * @param {string} vaultDir - Path to the vault directory to watch
72
+ */
73
+ export function start(vaultDir) {
74
+ currentVaultDir = vaultDir;
75
+ startWatcher(vaultDir);
76
+ }