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.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/antigravity.mjs +212 -0
- package/bin/init.mjs +36 -0
- package/bin/oci-sniper.sh +69 -0
- package/bin/total-recall.mjs +229 -0
- package/docs/ARCHITECTURE.md +251 -0
- package/docs/IDE_INTEGRATION.md +112 -0
- package/package.json +71 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +195 -0
- package/scaffold/.agent/skills/total-recall/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +145 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +75 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +16 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +17 -0
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +187 -0
- package/scaffold/.agent/skills/total-recall/references/architecture-reference.md +81 -0
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +103 -0
- package/scaffold/.agent/skills/total-recall/references/ssss-reference.md +88 -0
- package/scaffold/.agent/skills/total-recall/references/troubleshooting.md +68 -0
- package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +83 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/SKILL.md +122 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +27 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/antigravity.txt +20 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/claude.txt +58 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/codex.txt +81 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/gemini.txt +44 -0
- package/scaffold/.agent/skills/total-recall/skills/research/SKILL.md +337 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/SKILL.md +281 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/assets/skill-template/SKILL.md +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-add.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-delete.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/learnings/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/references/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/create-skill.sh +65 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/enforce-skill-optimization.mjs +135 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs +124 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs +207 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/skill-watcher.mjs +64 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/subagents/audit-all-skills.md +62 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +27 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/evals/evals.json +17 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +117 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/scripts/validate-schema.mjs +54 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/subagents/audit-ssss-node.md +9 -0
- package/scaffold/.agent/skills/total-recall/subagents/total-recall-diagnostician.md +13 -0
- package/src/cli/agent-dir.mjs +105 -0
- package/src/cli/backup.mjs +313 -0
- package/src/cli/brain.mjs +224 -0
- package/src/cli/chat.mjs +74 -0
- package/src/cli/config.mjs +247 -0
- package/src/cli/connect.mjs +872 -0
- package/src/cli/daemon.mjs +228 -0
- package/src/cli/deploy-ui.mjs +1793 -0
- package/src/cli/deploy.mjs +1726 -0
- package/src/cli/doctor.mjs +254 -0
- package/src/cli/dream.mjs +80 -0
- package/src/cli/forget.mjs +92 -0
- package/src/cli/friction.mjs +21 -0
- package/src/cli/generate-pat.mjs +65 -0
- package/src/cli/hash-password.mjs +22 -0
- package/src/cli/help.mjs +153 -0
- package/src/cli/import-rules.mjs +102 -0
- package/src/cli/ingest/index.mjs +318 -0
- package/src/cli/ingest/parsers/chrome-bookmarks.mjs +238 -0
- package/src/cli/ingest/parsers/google-keep.mjs +147 -0
- package/src/cli/ingest/parsers/search-history.mjs +142 -0
- package/src/cli/ingest/parsers/youtube-history.mjs +181 -0
- package/src/cli/ingest/utils/dedup.mjs +69 -0
- package/src/cli/ingest/utils/takeout-walker.mjs +101 -0
- package/src/cli/ingest.mjs +102 -0
- package/src/cli/init.mjs +736 -0
- package/src/cli/integration-dispatcher.mjs +128 -0
- package/src/cli/lint.mjs +166 -0
- package/src/cli/map.mjs +194 -0
- package/src/cli/migrate.mjs +87 -0
- package/src/cli/rebuild.mjs +88 -0
- package/src/cli/recall.mjs +219 -0
- package/src/cli/relay.mjs +518 -0
- package/src/cli/remember.mjs +306 -0
- package/src/cli/research.mjs +381 -0
- package/src/cli/reset-password.mjs +75 -0
- package/src/cli/restore.mjs +135 -0
- package/src/cli/setup.mjs +616 -0
- package/src/cli/share.mjs +180 -0
- package/src/cli/skill.mjs +313 -0
- package/src/cli/snapshot.mjs +108 -0
- package/src/cli/start.mjs +63 -0
- package/src/cli/status.mjs +296 -0
- package/src/cli/sync.mjs +383 -0
- package/src/cli/uninstall.mjs +613 -0
- package/src/cli/upgrade.mjs +141 -0
- package/src/cli/wizard.html +3682 -0
- package/src/core/blackboard.mjs +54 -0
- package/src/core/clarity-rewriter.mjs +770 -0
- package/src/core/conclusion-writer.mjs +259 -0
- package/src/core/config.mjs +277 -0
- package/src/core/conflict-detector.mjs +536 -0
- package/src/core/crypto.mjs +107 -0
- package/src/core/daemon-control.mjs +158 -0
- package/src/core/daemon-loop.mjs +364 -0
- package/src/core/dream.mjs +347 -0
- package/src/core/drift-detector.mjs +143 -0
- package/src/core/embeddings.mjs +632 -0
- package/src/core/emergency-alerts.mjs +137 -0
- package/src/core/evolution.mjs +107 -0
- package/src/core/fact-seeker.mjs +1602 -0
- package/src/core/friction.mjs +95 -0
- package/src/core/import-rules.mjs +308 -0
- package/src/core/inference-engine.mjs +367 -0
- package/src/core/logger.mjs +75 -0
- package/src/core/memory-layers.mjs +96 -0
- package/src/core/migrate.mjs +116 -0
- package/src/core/notifications.mjs +77 -0
- package/src/core/operation-validator.mjs +272 -0
- package/src/core/optimizer.mjs +330 -0
- package/src/core/pattern_detector.mjs +113 -0
- package/src/core/post-mortem.mjs +413 -0
- package/src/core/protect-instructions.mjs +148 -0
- package/src/core/quick-capture.mjs +145 -0
- package/src/core/research-queue.mjs +431 -0
- package/src/core/research.mjs +461 -0
- package/src/core/runtime.mjs +515 -0
- package/src/core/sandbox.mjs +163 -0
- package/src/core/scheduler.mjs +569 -0
- package/src/core/schema.mjs +333 -0
- package/src/core/search.mjs +219 -0
- package/src/core/semantic-index.mjs +276 -0
- package/src/core/session-watcher.mjs +850 -0
- package/src/core/snapshot.mjs +133 -0
- package/src/core/source-adapters.mjs +1038 -0
- package/src/core/steering.mjs +137 -0
- package/src/core/surface.mjs +444 -0
- package/src/core/task_runner.mjs +125 -0
- package/src/core/tts.mjs +142 -0
- package/src/core/usage-tracker.mjs +349 -0
- package/src/core/vault-cache.mjs +76 -0
- package/src/core/vault.mjs +273 -0
- package/src/core/watchdog.mjs +236 -0
- package/src/server/api.mjs +944 -0
- package/src/server/auth.mjs +410 -0
- package/src/server/index.mjs +868 -0
- package/src/server/keys.mjs +181 -0
- package/src/server/rest.mjs +1652 -0
- package/src/server/route-manifest.json +77 -0
- package/src/server/routes/_shared.mjs +93 -0
- package/src/server/routes/keys.mjs +55 -0
- package/src/server/routes/memory.mjs +249 -0
- package/src/server/routes/sessions.mjs +336 -0
- package/src/server/routes/share.mjs +138 -0
- package/src/server/tools.mjs +618 -0
- package/templates/Caddyfile +37 -0
- package/templates/com.totalrecall.backup.plist +42 -0
- package/templates/com.totalrecall.daemon.plist +40 -0
- package/templates/com.totalrecall.duckdns.plist +23 -0
- package/templates/com.totalrecall.server.plist +46 -0
- package/templates/default-config/budget.yml +6 -0
- package/templates/default-config/runtime.yml +11 -0
- package/templates/default-config/security.yml +53 -0
- package/templates/default-config/voice.yml +34 -0
- package/templates/obsidian-queries/conflicts.md +26 -0
- package/templates/obsidian-queries/daily.md +17 -0
- package/templates/obsidian-queries/index.md +38 -0
- package/templates/obsidian-queries/skills.md +14 -0
- package/templates/onboarding-interview.md +72 -0
- package/templates/skill_engineering.md +40 -0
- package/templates/total-recall-daemon.service +21 -0
- 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();
|