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,273 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import matter from 'gray-matter';
4
+ import { logger } from './logger.mjs';
5
+
6
+ /**
7
+ * Atomic file write using write-then-rename to prevent partial corruption.
8
+ */
9
+ export function atomicWrite(filePath, content) {
10
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
11
+ fs.writeFileSync(tmpPath, content, 'utf8');
12
+ fs.renameSync(tmpPath, filePath);
13
+ }
14
+
15
+ /**
16
+ * Deep-strip undefined values from an object so js-yaml (via gray-matter)
17
+ * never encounters `[object Undefined]` and crashes the process.
18
+ */
19
+ function stripUndefined(obj) {
20
+ if (obj === null || obj === undefined || typeof obj !== 'object') return obj;
21
+ if (Array.isArray(obj)) return obj.map(stripUndefined);
22
+ const clean = {};
23
+ for (const [k, v] of Object.entries(obj)) {
24
+ if (v !== undefined) {
25
+ clean[k] = typeof v === 'object' ? stripUndefined(v) : v;
26
+ }
27
+ }
28
+ return clean;
29
+ }
30
+
31
+ /**
32
+ * Safe wrapper around matter.stringify that prevents js-yaml crashes.
33
+ * Use this instead of matter.stringify() everywhere in the codebase.
34
+ *
35
+ * @param {string} content The markdown body
36
+ * @param {object} data The YAML frontmatter object
37
+ * @returns {string} The serialized markdown with frontmatter
38
+ */
39
+ export function safeStringify(content, data) {
40
+ if (data && data.type === 'memory') {
41
+ const now = new Date().toISOString();
42
+ if (!data.x_temporal_context) {
43
+ data.x_temporal_context = data.updated || data.created || now;
44
+ }
45
+ if (!data.x_citations) {
46
+ data.x_citations = [{
47
+ source: data.source?.type || 'unknown',
48
+ title: data.title || 'Untitled Memory',
49
+ url: data.source?.session_id ? `session://${data.source.session_id}` : 'unknown',
50
+ published: data.x_temporal_context,
51
+ relevance: 1.0,
52
+ accessed: now
53
+ }];
54
+ }
55
+ }
56
+ return matter.stringify(content ?? '', stripUndefined(data ?? {}));
57
+ }
58
+
59
+ /**
60
+ * Walk directory recursively and find all .md files.
61
+ */
62
+ export function walkMd(dir) {
63
+ let results = [];
64
+ if (!fs.existsSync(dir)) return results;
65
+
66
+ const list = fs.readdirSync(dir);
67
+ for (const file of list) {
68
+ const fullPath = path.join(dir, file);
69
+ const stat = fs.statSync(fullPath);
70
+ if (stat && stat.isDirectory()) {
71
+ results = results.concat(walkMd(fullPath));
72
+ } else if (fullPath.endsWith('.md')) {
73
+ results.push(fullPath);
74
+ }
75
+ }
76
+ return results;
77
+ }
78
+
79
+ /**
80
+ * Load all memory nodes from the vault.
81
+ */
82
+ export function loadNodes(vaultDir) {
83
+ const nodes = [];
84
+ const files = walkMd(vaultDir);
85
+ for (const file of files) {
86
+ try {
87
+ const raw = fs.readFileSync(file, 'utf8');
88
+ const { data, content } = matter(raw);
89
+ if (data.type === 'memory') {
90
+ nodes.push({ ...data, body: content.trim() });
91
+ }
92
+ } catch (err) {
93
+ logger.warn('vault', `Failed to parse ${file}`, { err: err.message });
94
+ }
95
+ }
96
+ return nodes;
97
+ }
98
+
99
+ /**
100
+ * Load merged memory nodes from both global and project vaults.
101
+ * Project nodes override global nodes with the same slug.
102
+ * Each node is tagged with `_layer: 'global' | 'project'`.
103
+ *
104
+ * @param {string} globalVaultDir - Path to global memory-vault/
105
+ * @param {string|null} projectVaultDir - Path to project memory-vault/ (null if no project brain)
106
+ * @returns {object[]} Merged array of nodes
107
+ */
108
+ export function loadMergedNodes(globalVaultDir, projectVaultDir) {
109
+ // Load global nodes
110
+ const globalNodes = loadNodes(globalVaultDir).map(n => ({ ...n, _layer: 'global' }));
111
+
112
+ if (!projectVaultDir || !fs.existsSync(projectVaultDir)) {
113
+ return globalNodes;
114
+ }
115
+
116
+ // Load project nodes
117
+ const projectNodes = loadNodes(projectVaultDir).map(n => ({ ...n, _layer: 'project' }));
118
+
119
+ // Merge: project wins on slug conflict
120
+ const nodeMap = new Map();
121
+ for (const node of globalNodes) {
122
+ nodeMap.set(node.slug, node);
123
+ }
124
+ for (const node of projectNodes) {
125
+ nodeMap.set(node.slug, node); // project overrides global
126
+ }
127
+
128
+ return Array.from(nodeMap.values());
129
+ }
130
+
131
+
132
+ /**
133
+ * Write a memory node to the vault.
134
+ */
135
+ const SAFE_NAME = /^[a-zA-Z0-9_-]+$/;
136
+
137
+ export function writeNode(node, vaultDir) {
138
+ if (!SAFE_NAME.test(node.slug)) throw new Error(`Invalid slug: ${node.slug}`);
139
+ if (!SAFE_NAME.test(node.category)) throw new Error(`Invalid category: ${node.category}`);
140
+ const targetDir = path.join(vaultDir, node.category);
141
+ const filePath = path.join(targetDir, `${node.slug}.md`);
142
+
143
+ const dirPath = path.dirname(filePath);
144
+ if (!fs.existsSync(dirPath)) {
145
+ fs.mkdirSync(dirPath, { recursive: true });
146
+ }
147
+
148
+ const { body, ...frontmatter } = node;
149
+
150
+ if (frontmatter.type === 'memory') {
151
+ const now = new Date().toISOString();
152
+ if (!frontmatter.x_temporal_context) {
153
+ frontmatter.x_temporal_context = frontmatter.updated || frontmatter.created || now;
154
+ }
155
+ if (!frontmatter.x_citations) {
156
+ frontmatter.x_citations = [{
157
+ source: frontmatter.source?.type || 'unknown',
158
+ title: frontmatter.title || 'Untitled Memory',
159
+ url: frontmatter.source?.session_id ? `session://${frontmatter.source.session_id}` : 'unknown',
160
+ published: frontmatter.x_temporal_context,
161
+ relevance: 1.0,
162
+ accessed: now
163
+ }];
164
+ }
165
+ }
166
+
167
+ const raw = safeStringify(body || '', frontmatter);
168
+ atomicWrite(filePath, raw);
169
+ }
170
+
171
+ /**
172
+ * Delete a memory node by slug.
173
+ * Finds the node via loadNodes, then removes its .md file.
174
+ * Returns true if deleted, false if the node was not found.
175
+ *
176
+ * @param {string} slug
177
+ * @param {string} vaultDir
178
+ * @returns {boolean}
179
+ */
180
+ export function deleteNode(slug, vaultDir) {
181
+ const nodes = loadNodes(vaultDir);
182
+ const node = nodes.find(n => n.slug === slug);
183
+ if (!node) return false;
184
+ const filePath = path.join(vaultDir, node.category, `${node.slug}.md`);
185
+ if (fs.existsSync(filePath)) {
186
+ fs.rmSync(filePath);
187
+ return true;
188
+ }
189
+ return false;
190
+ }
191
+
192
+
193
+ /**
194
+ * Creates a fully populated memory node from an API payload.
195
+ */
196
+ export function createMemoryNode({ slug, title, category, content }) {
197
+ const now = new Date().toISOString();
198
+ return {
199
+ type: 'memory',
200
+ slug,
201
+ title,
202
+ category,
203
+ body: content,
204
+ status: 'active',
205
+ confidence: 1.0,
206
+ importance: 3,
207
+ created: now,
208
+ updated: now,
209
+ last_accessed: now,
210
+ source: {
211
+ type: 'api-client',
212
+ session_id: 'api-external',
213
+ evidence_count: 1
214
+ },
215
+ supersedes: [],
216
+ superseded_by: null,
217
+ contradicts: [],
218
+ tags: [],
219
+ related: [],
220
+ routes_to_skills: [],
221
+ sentiment_polarity: 'descriptive',
222
+ sentiment_target: 'api-external',
223
+ modality: 'should',
224
+ subject: 'api-external',
225
+ predicate: 'provided_information',
226
+ object: 'api-external',
227
+ decay: {
228
+ half_life_days: 30,
229
+ access_count: 1
230
+ },
231
+ schema_version: 2,
232
+ x_temporal_context: now,
233
+ x_citations: [{
234
+ source: 'api-client',
235
+ title: title || 'External API Ingest',
236
+ url: 'api://external',
237
+ published: now,
238
+ relevance: 1.0,
239
+ accessed: now
240
+ }]
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Load all skill manifests from the skills directory.
246
+ */
247
+ export function loadSkills(skillsDir) {
248
+ const skills = [];
249
+ if (!fs.existsSync(skillsDir)) return skills;
250
+
251
+ for (const skillName of fs.readdirSync(skillsDir)) {
252
+ const skillPath = path.join(skillsDir, skillName, 'SKILL.md');
253
+ if (fs.existsSync(skillPath)) {
254
+ try {
255
+ const raw = fs.readFileSync(skillPath, 'utf8');
256
+ const { data, content } = matter(raw);
257
+ // Accept either `type: skill` or files that simply have a `name` field
258
+ // (which is the convention for all real SKILL.md files)
259
+ if (data.type === 'skill' || data.name) {
260
+ skills.push({
261
+ ...data,
262
+ name: data.name || skillName,
263
+ body: content.trim(),
264
+ filepath: skillPath,
265
+ });
266
+ }
267
+ } catch (err) {
268
+ logger.warn('vault', `Failed to parse ${skillPath}`, { err: err.message });
269
+ }
270
+ }
271
+ }
272
+ return skills;
273
+ }
@@ -0,0 +1,236 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import readline from 'node:readline';
4
+ import { logger, logEvents, LOG_DIR, getLogFile } from './logger.mjs';
5
+ import { brainDir } from './config.mjs';
6
+
7
+ const QUARANTINE_FILE = path.join(brainDir, 'config', 'quarantine.json');
8
+
9
+ let state = {
10
+ sandboxFailures: 0,
11
+ authFailures: {},
12
+ tokenSpikes: 0,
13
+ latencyBaseline: 1000,
14
+ latencySamples: [],
15
+ blockedIps: new Set(),
16
+ writeHalt: false
17
+ };
18
+
19
+ const saveQuarantine = () => {
20
+ try {
21
+ fs.mkdirSync(path.dirname(QUARANTINE_FILE), { recursive: true });
22
+ fs.writeFileSync(QUARANTINE_FILE, JSON.stringify({ blockedIps: Array.from(state.blockedIps) }), 'utf8');
23
+ } catch (e) {
24
+ logger.error('watchdog', `Failed to save quarantine state: ${e.message}`);
25
+ }
26
+ };
27
+
28
+ const loadQuarantine = () => {
29
+ if (fs.existsSync(QUARANTINE_FILE)) {
30
+ try {
31
+ const data = JSON.parse(fs.readFileSync(QUARANTINE_FILE, 'utf8'));
32
+ if (data.blockedIps) {
33
+ state.blockedIps = new Set(data.blockedIps);
34
+ }
35
+ } catch (e) {
36
+ logger.error('watchdog', `Failed to load quarantine state: ${e.message}`);
37
+ }
38
+ }
39
+ };
40
+ loadQuarantine();
41
+
42
+ export const watchdog = {
43
+ recordSandboxFailure: () => {
44
+ state.sandboxFailures++;
45
+ logger.warn('watchdog', `Sandbox failure recorded. Count: ${state.sandboxFailures}`);
46
+ if (state.sandboxFailures >= 3) {
47
+ logger.error('watchdog', 'Sandbox circuit breaker triggered (≥3 failures). Quarantining sandbox.');
48
+ }
49
+ },
50
+ resetSandboxFailures: () => {
51
+ state.sandboxFailures = 0;
52
+ },
53
+ isSandboxQuarantined: () => state.sandboxFailures >= 3,
54
+
55
+ recordAuthFailure: (ip) => {
56
+ state.authFailures[ip] = (state.authFailures[ip] || 0) + 1;
57
+ logger.warn('watchdog', `Auth failure for IP ${ip}. Count: ${state.authFailures[ip]}`);
58
+ if (state.authFailures[ip] >= 9999) {
59
+ if (!state.blockedIps.has(ip)) {
60
+ logger.error('watchdog', `Auth lockout triggered for IP ${ip}. Blocking.`);
61
+ state.blockedIps.add(ip);
62
+ saveQuarantine();
63
+ }
64
+ }
65
+ },
66
+ resetAuthFailures: (ip) => {
67
+ if (state.authFailures[ip]) {
68
+ state.authFailures[ip] = 0;
69
+ }
70
+ },
71
+ isIpBlocked: (ip) => state.blockedIps.has(ip),
72
+
73
+ recordTokens: (count) => {
74
+ if (count > 8000) {
75
+ state.tokenSpikes++;
76
+ logger.warn('watchdog', `Token spike detected: ${count}. Count: ${state.tokenSpikes}`);
77
+ if (state.tokenSpikes >= 3) {
78
+ logger.error('watchdog', 'Exfiltration monitor triggered. Suspending routing.');
79
+ }
80
+ } else {
81
+ state.tokenSpikes = 0;
82
+ }
83
+ },
84
+ isRoutingSuspended: () => state.tokenSpikes >= 3,
85
+
86
+ recordLatency: (ms) => {
87
+ if (state.latencySamples.length > 10) state.latencySamples.shift();
88
+ state.latencySamples.push(ms);
89
+ const avg = state.latencySamples.reduce((a, b) => a + b, 0) / state.latencySamples.length;
90
+ state.latencyBaseline = avg;
91
+
92
+ if (ms > state.latencyBaseline * 2 && state.latencyBaseline > 500) {
93
+ logger.warn('watchdog', `Latency anomaly triggered: ${ms}ms vs baseline ${state.latencyBaseline.toFixed(0)}ms. Flushing KV cache.`);
94
+ }
95
+ },
96
+
97
+ checkDiskSpace: () => {
98
+ try {
99
+ const stat = fs.statfsSync(brainDir);
100
+ const percentFree = stat.bavail / stat.blocks;
101
+ const percentUsed = 1 - percentFree;
102
+
103
+ if (percentUsed > 0.95) {
104
+ if (!state.writeHalt) {
105
+ logger.error('watchdog', 'Disk space >95% used. Halting writes.');
106
+ state.writeHalt = true;
107
+ }
108
+ } else if (percentUsed > 0.80) {
109
+ logger.warn('watchdog', 'Disk space >80% used. Log rotation advised.');
110
+ state.writeHalt = false;
111
+ } else {
112
+ state.writeHalt = false;
113
+ }
114
+ } catch (e) {
115
+ logger.error('watchdog', `Disk check failed: ${e.message}`);
116
+ }
117
+ },
118
+ isWriteHalted: () => state.writeHalt
119
+ };
120
+
121
+ // ─── Log event monitor (PRD §9.3) ───────────────────────────────────────────
122
+ //
123
+ // Subscribe to in-process log events and automatically fire circuit breakers
124
+ // when subsystems report errors. This satisfies "watchdog wired to log file
125
+ // monitor" — every logger.* call emits a `log` event that arrives here.
126
+
127
+ const LOG_PATTERNS = [
128
+ // Sandbox: any error from sandbox subsystem → record failure
129
+ {
130
+ match: (e) => e.subsystem === 'sandbox' && e.level === 'error',
131
+ handler: () => watchdog.recordSandboxFailure()
132
+ },
133
+ // Auth: failed login from auth subsystem → record by IP
134
+ {
135
+ match: (e) => e.subsystem === 'auth' && e.level === 'warn' && /failed|invalid|denied/i.test(e.message || ''),
136
+ handler: (e) => {
137
+ if (e.ip) watchdog.recordAuthFailure(e.ip);
138
+ }
139
+ },
140
+ // Latency: any subsystem can record latency via meta
141
+ {
142
+ match: (e) => typeof e.latency_ms === 'number',
143
+ handler: (e) => watchdog.recordLatency(e.latency_ms)
144
+ },
145
+ // Tokens: any subsystem can record token usage via meta
146
+ {
147
+ match: (e) => typeof e.tokens === 'number',
148
+ handler: (e) => watchdog.recordTokens(e.tokens)
149
+ }
150
+ ];
151
+
152
+ function onLogEntry(entry) {
153
+ // Never react to our own emissions to avoid recursion / amplification.
154
+ if (entry.subsystem === 'watchdog') return;
155
+ for (const { match, handler } of LOG_PATTERNS) {
156
+ try {
157
+ if (match(entry)) handler(entry);
158
+ } catch (err) {
159
+ // Don't let a broken handler crash the daemon.
160
+ }
161
+ }
162
+ }
163
+
164
+ let monitorAttached = false;
165
+ export function attachLogMonitor() {
166
+ if (monitorAttached) return;
167
+ monitorAttached = true;
168
+ logEvents.on('log', onLogEntry);
169
+ logger.info('watchdog', 'Log event monitor attached. Circuit breakers wired.');
170
+ }
171
+
172
+ export function detachLogMonitor() {
173
+ if (!monitorAttached) return;
174
+ logEvents.off('log', onLogEntry);
175
+ monitorAttached = false;
176
+ }
177
+
178
+ // ─── Log file tail (out-of-process daemon scenario) ─────────────────────────
179
+ //
180
+ // When the daemon runs in a different process from the server, in-process
181
+ // events won't reach it. `attachLogTail()` polls today's log file and feeds
182
+ // each new JSONL line through the same pattern matcher.
183
+
184
+ let tailAttached = false;
185
+ let tailTimer = null;
186
+ let tailState = { file: null, offset: 0 };
187
+
188
+ async function pumpTail() {
189
+ const file = getLogFile();
190
+ try {
191
+ if (file !== tailState.file) {
192
+ tailState = { file, offset: 0 };
193
+ }
194
+ if (!fs.existsSync(file)) return;
195
+ const stat = fs.statSync(file);
196
+ if (stat.size <= tailState.offset) return;
197
+ const stream = fs.createReadStream(file, { start: tailState.offset, end: stat.size });
198
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
199
+ for await (const line of rl) {
200
+ if (!line.trim()) continue;
201
+ try {
202
+ const entry = JSON.parse(line);
203
+ onLogEntry(entry);
204
+ } catch (_) {
205
+ // Skip malformed lines.
206
+ }
207
+ }
208
+ tailState.offset = stat.size;
209
+ } catch (err) {
210
+ logger.error('watchdog', `Log tail error: ${err.message}`);
211
+ }
212
+ }
213
+
214
+ export function attachLogTail(intervalMs = 2000) {
215
+ if (tailAttached) return;
216
+ tailAttached = true;
217
+ tailState = { file: getLogFile(), offset: 0 };
218
+ try {
219
+ if (fs.existsSync(tailState.file)) {
220
+ tailState.offset = fs.statSync(tailState.file).size;
221
+ }
222
+ } catch (_) {}
223
+ tailTimer = setInterval(() => { pumpTail(); }, intervalMs);
224
+ tailTimer.unref?.();
225
+ logger.info('watchdog', `Log file tail attached (every ${intervalMs}ms).`);
226
+ }
227
+
228
+ export function detachLogTail() {
229
+ if (!tailAttached) return;
230
+ if (tailTimer) clearInterval(tailTimer);
231
+ tailTimer = null;
232
+ tailAttached = false;
233
+ }
234
+
235
+ // Periodic disk check
236
+ setInterval(watchdog.checkDiskSpace, 60000).unref();