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,1602 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import matter from 'gray-matter';
4
+ import crypto from 'crypto';
5
+ import os from 'os';
6
+ import { callLocalRuntime, cleanAndParseJSON } from './runtime.mjs';
7
+ import { loadNodes, writeNode, atomicWrite, safeStringify } from './vault.mjs';
8
+ import { logger } from './logger.mjs';
9
+ import { addToQueue } from './research-queue.mjs';
10
+ import { persistTaskToDisk } from './scheduler.mjs';
11
+ import {
12
+ loadResearchConfig,
13
+ checkSourceAvailability,
14
+ webSearch,
15
+ arxivSearch,
16
+ npmSearch,
17
+ githubSearch,
18
+ wikipediaFetch,
19
+ duckduckgoInstant,
20
+ smartFetch,
21
+ } from './source-adapters.mjs';
22
+
23
+ /**
24
+ * Get dynamic timezone-aware local date and time string.
25
+ */
26
+ export function getLocalizedDateTime() {
27
+ return new Date().toLocaleString('en-US', {
28
+ weekday: 'long',
29
+ year: 'numeric',
30
+ month: 'long',
31
+ day: 'numeric',
32
+ hour: '2-digit',
33
+ minute: '2-digit',
34
+ second: '2-digit',
35
+ timeZoneName: 'short'
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Format ISO date string into a human-readable beautiful date.
41
+ */
42
+ export function formatBeautifulDate(isoString) {
43
+ if (!isoString) return 'unknown';
44
+ try {
45
+ const d = new Date(isoString);
46
+ if (isNaN(d.getTime())) return isoString;
47
+ return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
48
+ } catch {
49
+ return isoString;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Total Recall Knowledge Acquisition Engine
55
+ *
56
+ * A proactive, multi-source intelligence builder that:
57
+ * 1. Infers research topics from conversation sessions
58
+ * 2. Maintains a persistent, prioritized Research Agenda
59
+ * 3. Fetches real data from web search, arXiv, npm, GitHub, Wikipedia
60
+ * 4. Cites every source with URL, date, and relevance score
61
+ * 5. Cross-verifies facts across multiple sources
62
+ * 6. Diagnoses its own knowledge coverage and limitations
63
+ * 7. Accepts direct research instructions from users
64
+ */
65
+
66
+ import { agentDir, brainDir } from './config.mjs';
67
+
68
+ const getAgentDir = () => process.env.AGENT_DIR || process.env._TR_TEST_AGENT_DIR || agentDir;
69
+ const getBrainDir = () => {
70
+ const ad = getAgentDir();
71
+ // If agentDir was overridden (e.g. in tests), derive brainDir from it
72
+ return ad !== agentDir ? path.join(ad, 'skills', 'total-recall') : brainDir;
73
+ };
74
+ const getAgendaFile = () => path.join(getBrainDir(), 'research-agenda.jsonl');
75
+ const getSourcesRegistry = () => path.join(getBrainDir(), 'memory-derived', 'source-registry.jsonl');
76
+
77
+ // ─── Research Agenda ────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Load the persistent research agenda from disk.
81
+ * @returns {ResearchTopic[]}
82
+ */
83
+ export function loadAgenda() {
84
+ const agendaFile = getAgendaFile();
85
+ if (!fs.existsSync(agendaFile)) return [];
86
+ return fs.readFileSync(agendaFile, 'utf8')
87
+ .split('\n').filter(Boolean)
88
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
89
+ .filter(Boolean);
90
+ }
91
+
92
+ /**
93
+ * Save the full agenda back to disk.
94
+ */
95
+ function saveAgenda(topics) {
96
+ const agendaFile = getAgendaFile();
97
+ const dir = path.dirname(agendaFile);
98
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
99
+ atomicWrite(agendaFile, topics.map(t => JSON.stringify(t)).join('\n') + '\n');
100
+ }
101
+
102
+ /**
103
+ * Add or update a topic on the research agenda.
104
+ * If the topic already exists, bumps its priority.
105
+ */
106
+ export function addToAgenda({ topic, priority = 50, source, rationale = '', tags = [], directInstruction = false }) {
107
+ const agenda = loadAgenda();
108
+ const existing = agenda.find(t => t.topic.toLowerCase() === topic.toLowerCase());
109
+
110
+ if (existing) {
111
+ existing.priority = Math.min(100, existing.priority + 10);
112
+ existing.mention_count = (existing.mention_count || 1) + 1;
113
+ existing.last_mentioned = new Date().toISOString();
114
+ if (directInstruction) existing.direct_instruction = true;
115
+ } else {
116
+ agenda.push({
117
+ id: crypto.randomBytes(4).toString('hex'),
118
+ topic,
119
+ priority,
120
+ source,
121
+ rationale,
122
+ tags,
123
+ status: 'pending',
124
+ coverage_score: 0,
125
+ mention_count: 1,
126
+ direct_instruction: directInstruction,
127
+ created: new Date().toISOString(),
128
+ last_mentioned: new Date().toISOString(),
129
+ last_researched: null,
130
+ sources_consulted: [],
131
+ });
132
+ }
133
+
134
+ agenda.sort((a, b) => b.priority - a.priority);
135
+ saveAgenda(agenda);
136
+ return existing || agenda.find(t => t.topic.toLowerCase() === topic.toLowerCase());
137
+ }
138
+
139
+ /**
140
+ * Mark a topic as researched and update its coverage score.
141
+ */
142
+ export function markTopicResearched(topicId, { coverageScore, sourcesConsulted }) {
143
+ const agenda = loadAgenda();
144
+ const topic = agenda.find(t => t.id === topicId);
145
+ if (topic) {
146
+ topic.last_researched = new Date().toISOString();
147
+ topic.coverage_score = coverageScore;
148
+ topic.sources_consulted = [...new Set([...(topic.sources_consulted || []), ...sourcesConsulted])];
149
+ topic.status = coverageScore >= 0.7 ? 'well-covered' : 'partially-covered';
150
+ saveAgenda(agenda);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Get the next highest-priority pending topic from the agenda.
156
+ */
157
+ export function getNextAgendaTopic() {
158
+ const agenda = loadAgenda();
159
+ // Prioritize: direct instructions first, then by priority score, then by staleness
160
+ const pending = agenda
161
+ .filter(t => t.status === 'pending' || t.status === 'partially-covered')
162
+ .sort((a, b) => {
163
+ if (a.direct_instruction && !b.direct_instruction) return -1;
164
+ if (!a.direct_instruction && b.direct_instruction) return 1;
165
+ return b.priority - a.priority;
166
+ });
167
+ return pending[0] || null;
168
+ }
169
+
170
+ // ─── Source Registry ─────────────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * Register a source that was consulted and store citation metadata.
174
+ */
175
+ function registerSource(sourceResult, factSlug) {
176
+ const sourcesRegistry = getSourcesRegistry();
177
+ const dir = path.dirname(sourcesRegistry);
178
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
179
+
180
+ const record = JSON.stringify({
181
+ fact_slug: factSlug,
182
+ source: sourceResult.source,
183
+ type: sourceResult.type,
184
+ title: sourceResult.title,
185
+ url: sourceResult.url,
186
+ snippet: (sourceResult.snippet || '').slice(0, 300),
187
+ published: sourceResult.published,
188
+ relevance: sourceResult.relevance,
189
+ accessed_at: new Date().toISOString(),
190
+ }) + '\n';
191
+
192
+ fs.appendFileSync(sourcesRegistry, record);
193
+ }
194
+
195
+ // ─── Topic Inference ─────────────────────────────────────────────────────────────
196
+
197
+ function TOPIC_INFERENCE_SYSTEM(runtimeConfig) {
198
+ const today = getLocalizedDateTime();
199
+ const cutoff = runtimeConfig?.training_cutoff || 'January 2025';
200
+ return `You are a Research Agenda Analyst. Today's date and time is ${today}. The model's training data cutoff is ${cutoff} — anything after that date may be outdated or unknown. Given a conversation transcript, identify topics that would benefit from real-world verification, especially anything that may have changed since the training cutoff.
201
+
202
+ Output valid JSON:
203
+ {
204
+ "topics": [
205
+ {
206
+ "topic": "string (specific, searchable — e.g. 'Ollama REST API endpoints 2025' not just 'AI')",
207
+ "priority": 1-100,
208
+ "rationale": "why this needs research",
209
+ "tags": ["tag1", "tag2"],
210
+ "suggested_sources": ["brave-search", "arxiv", "npm", "github", "wikipedia"]
211
+ }
212
+ ]
213
+ }
214
+
215
+ Rules:
216
+ - Maximum 5 topics per session.
217
+ - Topics must be SPECIFIC and SEARCHABLE — not vague categories.
218
+ - Include version numbers, tool names, library names when relevant.
219
+ - Prioritize topics post-${cutoff} or where the agent showed uncertainty.
220
+ - Output ONLY valid JSON.`;
221
+ }
222
+
223
+ /**
224
+ * Infer research topics from a session transcript via the local LLM.
225
+ */
226
+ export async function inferTopicsFromSession(transcript, runtimeConfig) {
227
+ if (!transcript || transcript.length < 100) return [];
228
+
229
+ const prompt = `Analyze this conversation and identify the most important research topics:\n\n${transcript.slice(0, 8000)}`;
230
+
231
+ try {
232
+ const raw = await callLocalRuntime(prompt, TOPIC_INFERENCE_SYSTEM(runtimeConfig), runtimeConfig);
233
+ const match = raw.match(/\{[\s\S]*\}/);
234
+ if (!match) return [];
235
+ const result = cleanAndParseJSON(match[0]);
236
+ return Array.isArray(result.topics) ? result.topics : [];
237
+ } catch (err) {
238
+ logger.info({ subsystem: 'fact-seeker', message: `Topic inference failed: ${err.message}` });
239
+ return [];
240
+ }
241
+ }
242
+
243
+ // ─── Multi-Source Research Execution ────────────────────────────────────────────
244
+
245
+ function SYNTHESIS_SYSTEM(runtimeConfig) {
246
+ const today = getLocalizedDateTime();
247
+ const cutoff = runtimeConfig?.training_cutoff || 'January 2025';
248
+ return `You are a Knowledge Synthesis Analyst. Today's date and time is ${today}. The model's training data cutoff is ${cutoff}. Given raw search results, extract verified facts and synthesize them into a coherent knowledge node. Prioritize sources published after ${cutoff} — they contain information the model cannot know from training alone.
249
+
250
+ Output valid JSON:
251
+ {
252
+ "title": "string (precise, descriptive)",
253
+ "summary": "string (3-5 paragraphs synthesizing all sources)",
254
+ "key_facts": ["string (specific, verifiable fact with inline citation [Source: URL])"],
255
+ "confidence": 0.0-1.0,
256
+ "temporal_context": "string (when this information is current as of, relative to today ${today})",
257
+ "contradictions": ["string (any sources that disagreed)"],
258
+ "further_research_needed": ["string (gaps that remain)]",
259
+ "recommended_apis": ["string (specific APIs/endpoints worth integrating for this topic)"]
260
+ }
261
+
262
+ Rules:
263
+ - Every key fact MUST have an inline citation [Source: URL]
264
+ - Confidence: single source = max 0.6, 3+ agreeing post-cutoff sources = up to 0.95
265
+ - Flag any fact that relies solely on pre-${cutoff} training data as potentially stale
266
+ - Be temporally specific — note publication dates relative to today and the cutoff
267
+ - Output ONLY valid JSON`;
268
+ }
269
+
270
+ /**
271
+ * Execute research on a single topic across multiple sources.
272
+ * Returns raw results from all sources — synthesis is done separately.
273
+ */
274
+ async function gatherFromSources(topicEntry, researchConfig) {
275
+ const { topic, suggested_sources, tags } = topicEntry;
276
+ const availability = checkSourceAvailability(researchConfig);
277
+ const results = [];
278
+ const errors = [];
279
+
280
+ // Determine which sources to use based on tags and availability
281
+ const useArxiv = (suggested_sources || []).includes('arxiv') ||
282
+ (tags || []).some(t => ['research', 'paper', 'academic', 'ml', 'ai'].includes(t));
283
+ const useNpm = (suggested_sources || []).includes('npm') ||
284
+ (tags || []).some(t => ['javascript', 'node', 'npm', 'typescript', 'package'].includes(t));
285
+ const useGithub = (suggested_sources || []).includes('github') ||
286
+ (tags || []).some(t => ['code', 'library', 'framework', 'open-source'].includes(t));
287
+
288
+ // 1. Web search — Brave → Serper fallback, automatically selected
289
+ if (availability.available.includes('brave-search') || availability.available.includes('serper')) {
290
+ try {
291
+ const webResults = await webSearch(topic, researchConfig, 5);
292
+ results.push(...webResults);
293
+ logger.info({ subsystem: 'fact-seeker', message: `Web search: ${webResults.length} results for "${topic}"` });
294
+ } catch (err) {
295
+ errors.push({ source: 'web-search', error: err.message });
296
+ logger.info({ subsystem: 'fact-seeker', message: `Web search failed: ${err.message}` });
297
+ }
298
+ }
299
+
300
+ // 2. DuckDuckGo Instant Answers (always available, good for definitions/facts)
301
+ try {
302
+ const ddgResult = await duckduckgoInstant(topic, researchConfig);
303
+ if (ddgResult) results.push(ddgResult);
304
+ } catch (err) {
305
+ errors.push({ source: 'duckduckgo', error: err.message });
306
+ }
307
+
308
+ // 3. Wikipedia (always available, high-quality structured knowledge)
309
+ try {
310
+ const wikiResult = await wikipediaFetch(topic, researchConfig);
311
+ if (wikiResult) results.push(wikiResult);
312
+ } catch (err) {
313
+ errors.push({ source: 'wikipedia', error: err.message });
314
+ }
315
+
316
+ // 4. arXiv (for academic/research topics)
317
+ if (useArxiv) {
318
+ try {
319
+ const arxivResults = await arxivSearch(topic, researchConfig, 3);
320
+ results.push(...arxivResults);
321
+ logger.info({ subsystem: 'fact-seeker', message: `arXiv: ${arxivResults.length} papers for "${topic}"` });
322
+ } catch (err) {
323
+ errors.push({ source: 'arxiv', error: err.message });
324
+ }
325
+ }
326
+
327
+ // 5. npm (for JS/Node topics)
328
+ if (useNpm) {
329
+ try {
330
+ const npmResults = await npmSearch(topic, researchConfig, 3);
331
+ results.push(...npmResults);
332
+ } catch (err) {
333
+ errors.push({ source: 'npm', error: err.message });
334
+ }
335
+ }
336
+
337
+ // 6. GitHub (for code/library topics)
338
+ if (useGithub) {
339
+ try {
340
+ const ghResults = await githubSearch(topic, researchConfig, 'repositories', 3);
341
+ results.push(...ghResults);
342
+ } catch (err) {
343
+ errors.push({ source: 'github', error: err.message });
344
+ }
345
+ }
346
+
347
+ // 7. Deep crawl top result with Playwright (or plain fetch if not installed)
348
+ if (results.length > 0) {
349
+ const topResult = results.find(r => ['brave-search', 'serper'].includes(r.source) && r.url);
350
+ if (topResult) {
351
+ try {
352
+ const crawled = await smartFetch(topResult.url, researchConfig);
353
+ if (crawled.fullText && crawled.fullText.length > 200) {
354
+ topResult.fullText = crawled.fullText.slice(0, 3000);
355
+ if (crawled.source === 'playwright') {
356
+ topResult.source = 'playwright';
357
+ topResult.type = 'webpage-rendered';
358
+ }
359
+ }
360
+ } catch (err) {
361
+ logger.debug('fact-seeker: smartFetch failed during topResult crawl', { err: err.message });
362
+ }
363
+ }
364
+ }
365
+
366
+ return { results, errors, sourcesUsed: [...new Set(results.map(r => r.source))] };
367
+ }
368
+
369
+ // ─── Synthesis & Fact Node Writing ──────────────────────────────────────────────
370
+
371
+ /**
372
+ * Synthesize raw source results into a verified fact node via local LLM.
373
+ */
374
+ async function synthesizeFacts(topic, results, runtimeConfig) {
375
+ if (results.length === 0) return null;
376
+
377
+ const sourceText = results.map(r => [
378
+ `### [${r.source.toUpperCase()}] ${r.title}`,
379
+ `URL: ${r.url}`,
380
+ r.published ? `Published: ${r.published}` : '',
381
+ r.snippet || '',
382
+ r.fullText ? `\nFull text excerpt:\n${r.fullText.slice(0, 1500)}` : '',
383
+ ].filter(Boolean).join('\n')).join('\n\n---\n\n');
384
+
385
+ const prompt = `Research Topic: "${topic}"\n\nSources Gathered:\n${sourceText.slice(0, 10000)}\n\nSynthesize these into a verified knowledge node.`;
386
+
387
+ try {
388
+ const raw = await callLocalRuntime(prompt, SYNTHESIS_SYSTEM(runtimeConfig), runtimeConfig);
389
+ const match = raw.match(/\{[\s\S]*\}/);
390
+ if (!match) throw new Error('No JSON in synthesis response');
391
+ return cleanAndParseJSON(match[0]);
392
+ } catch (err) {
393
+ logger.info({ subsystem: 'fact-seeker', message: `Synthesis failed for "${topic}": ${err.message}` });
394
+ return null;
395
+ }
396
+ }
397
+
398
+ // Confidence threshold above which research bypasses the inbox and writes
399
+ // directly to the vault as an active node, triggering an immediate surface recompile.
400
+ const FAST_PATH_CONFIDENCE = 0.7;
401
+
402
+ /**
403
+ * Summarize a single research source using the local LLM.
404
+ * Fallback to snippet if local LLM fails or times out.
405
+ */
406
+ async function synthesizeSupportingNote(topic, result, runtimeConfig) {
407
+ const today = getLocalizedDateTime();
408
+ const sourceContent = [
409
+ `Source: ${result.source}`,
410
+ `Title: ${result.title}`,
411
+ `URL: ${result.url}`,
412
+ result.published ? `Published: ${result.published}` : '',
413
+ `Snippet: ${result.snippet || ''}`,
414
+ result.fullText ? `Excerpt: ${result.fullText.slice(0, 1500)}` : '',
415
+ ].filter(Boolean).join('\n');
416
+
417
+ const prompt = `Research Topic: "${topic}"\n\nSpecific Source details:\n${sourceContent}\n\nSummarize the key facts, findings, and context from this specific source into a concise, detailed supporting research note (1-2 paragraphs).`;
418
+
419
+ const systemPrompt = `You are a Research Assistant. Today's date and time is ${today}.
420
+ Given a specific search or source result, summarize its key findings, data points, or context in 1-2 detailed, high-quality paragraphs. Do not add general summary of other sources, focus ONLY on the provided source content. Keep it premium, objective, and dense with facts.
421
+ Do not wrap in Markdown code blocks, just output the raw summary text.`;
422
+
423
+ try {
424
+ const raw = await callLocalRuntime(prompt, systemPrompt, runtimeConfig);
425
+ const cleaned = raw.trim();
426
+ if (cleaned.length > 20) return cleaned;
427
+ return result.snippet || 'No summary available.';
428
+ } catch (err) {
429
+ logger.info({ subsystem: 'fact-seeker', message: `Supporting note summary failed: ${err.message}` });
430
+ return result.snippet || 'No summary available.';
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Build the bidirectional wiki-graph representing the Main Master Artifact and Supporting Notes.
436
+ */
437
+ export function buildMultiNoteGraph(topic, synthesis, parsedSupportingNotes, status = 'draft') {
438
+ const now = new Date().toISOString();
439
+
440
+ const supportingNodes = parsedSupportingNotes.map(({ result, summary, slug }) => {
441
+ const citations = [{
442
+ source: result.source,
443
+ type: result.type,
444
+ title: result.title,
445
+ url: result.url,
446
+ published: result.published,
447
+ relevance: result.relevance,
448
+ accessed: now,
449
+ }];
450
+
451
+ const supportingTemporal = result.published || now;
452
+ const supportingTemporalFriendly = result.published ? formatBeautifulDate(result.published) : formatBeautifulDate(now);
453
+ const supportingTemporalCallout = `> [!NOTE]\n> **Temporal Context**: Information published/current as of ${supportingTemporalFriendly}.`;
454
+
455
+ const publishedStr = result.published ? ` (Published: ${result.published})` : '';
456
+ const bodyLines = [
457
+ supportingTemporalCallout,
458
+ '',
459
+ summary,
460
+ '',
461
+ '## Citations',
462
+ `- [${result.source}] **${result.title}** — ${result.url}${publishedStr}`
463
+ ];
464
+
465
+ const frontmatter = {
466
+ type: 'memory',
467
+ slug,
468
+ category: 'facts',
469
+ title: `Source Note: ${result.title}`,
470
+ status,
471
+ confidence: synthesis.confidence || 0.6,
472
+ importance: synthesis.confidence >= FAST_PATH_CONFIDENCE ? 4 : 3,
473
+ created: now,
474
+ updated: now,
475
+ last_accessed: now,
476
+ source: {
477
+ type: 'knowledge-acquisition',
478
+ session_id: `fact-seeker-${Date.now().toString(36)}`,
479
+ agent: 'fact-seeker',
480
+ evidence_count: 1,
481
+ },
482
+ supersedes: [],
483
+ superseded_by: null,
484
+ contradicts: [],
485
+ tags: ['fact-seeker', 'auto-researched', 'supporting-note', status === 'active' ? 'fast-path' : 'pending-validation'],
486
+ related: [],
487
+ routes_to_skills: [],
488
+ sentiment_polarity: 'descriptive',
489
+ sentiment_target: 'external-knowledge',
490
+ modality: 'should',
491
+ subject: 'agent',
492
+ predicate: 'know',
493
+ object: topic,
494
+ decay: { half_life_days: 60, access_count: 1 },
495
+ schema_version: 2,
496
+ x_memory_layer: 'research',
497
+ x_topic: topic,
498
+ x_temporal_context: supportingTemporal,
499
+ x_sources_count: 1,
500
+ x_citations: citations,
501
+ };
502
+
503
+ return { slug, body: bodyLines.join('\n'), frontmatter };
504
+ });
505
+
506
+ const masterSlug = `fact-master-${crypto.randomBytes(5).toString('hex')}`;
507
+
508
+ // Bidirectionally link the master node and supporting notes
509
+ const masterRelated = supportingNodes.map(n => n.slug);
510
+ supportingNodes.forEach(node => {
511
+ node.frontmatter.related = [masterSlug];
512
+ });
513
+
514
+ let masterTemporal = now;
515
+ if (synthesis.temporal_context && !isNaN(Date.parse(synthesis.temporal_context))) {
516
+ masterTemporal = new Date(synthesis.temporal_context).toISOString();
517
+ } else {
518
+ let latestPub = null;
519
+ for (const { result } of parsedSupportingNotes) {
520
+ if (result?.published) {
521
+ const parsed = Date.parse(result.published);
522
+ if (!isNaN(parsed)) {
523
+ if (!latestPub || parsed > latestPub) {
524
+ latestPub = parsed;
525
+ }
526
+ }
527
+ }
528
+ }
529
+ if (latestPub) {
530
+ masterTemporal = new Date(latestPub).toISOString();
531
+ } else if (synthesis.temporal_context) {
532
+ masterTemporal = synthesis.temporal_context;
533
+ }
534
+ }
535
+
536
+ const masterTemporalFriendly = formatBeautifulDate(masterTemporal);
537
+ const masterTemporalCallout = `> [!NOTE]\n> **Temporal Context**: Information current as of ${masterTemporalFriendly}.`;
538
+
539
+ const bodyLines = [
540
+ masterTemporalCallout,
541
+ '',
542
+ synthesis.summary || '',
543
+ '',
544
+ '## Supporting Evidence',
545
+ ...supportingNodes.map(n => `- [[${n.slug}]] — ${n.frontmatter.title}`),
546
+ '',
547
+ '## Key Facts',
548
+ ...(synthesis.key_facts || []).map(f => `- ${f}`),
549
+ ];
550
+
551
+ if ((synthesis.further_research_needed || []).length > 0) {
552
+ bodyLines.push('', '## Further Research Needed');
553
+ synthesis.further_research_needed.forEach(g => bodyLines.push(`- ${g}`));
554
+ }
555
+ if ((synthesis.recommended_apis || []).length > 0) {
556
+ bodyLines.push('', '## Recommended APIs / Integrations');
557
+ synthesis.recommended_apis.forEach(a => bodyLines.push(`- ${a}`));
558
+ }
559
+ if ((synthesis.contradictions || []).length > 0) {
560
+ bodyLines.push('', '## Source Contradictions Noted');
561
+ synthesis.contradictions.forEach(c => bodyLines.push(`- ${c}`));
562
+ }
563
+
564
+ const allCitations = parsedSupportingNotes.map(({ result }) => ({
565
+ source: result.source,
566
+ type: result.type,
567
+ title: result.title,
568
+ url: result.url,
569
+ published: result.published,
570
+ relevance: result.relevance,
571
+ accessed: now,
572
+ }));
573
+
574
+ // Append Citations section to the master node body as well
575
+ bodyLines.push('', '## Citations');
576
+ allCitations.forEach(c => {
577
+ const publishedStr = c.published ? ` (Published: ${c.published})` : '';
578
+ bodyLines.push(`- [${c.source}] **${c.title}** — ${c.url}${publishedStr}`);
579
+ });
580
+
581
+ const masterFrontmatter = {
582
+ type: 'memory',
583
+ slug: masterSlug,
584
+ category: 'facts',
585
+ title: synthesis.title || `Research: ${topic}`,
586
+ status,
587
+ confidence: synthesis.confidence || 0.6,
588
+ importance: synthesis.confidence >= FAST_PATH_CONFIDENCE ? 5 : 4,
589
+ created: now,
590
+ updated: now,
591
+ last_accessed: now,
592
+ source: {
593
+ type: 'knowledge-acquisition',
594
+ session_id: `fact-seeker-${Date.now().toString(36)}`,
595
+ agent: 'fact-seeker',
596
+ evidence_count: allCitations.length,
597
+ },
598
+ supersedes: [],
599
+ superseded_by: null,
600
+ contradicts: [],
601
+ tags: ['fact-seeker', 'auto-researched', 'master-artifact', status === 'active' ? 'fast-path' : 'pending-validation'],
602
+ related: masterRelated,
603
+ routes_to_skills: [],
604
+ sentiment_polarity: 'descriptive',
605
+ sentiment_target: 'external-knowledge',
606
+ modality: 'should',
607
+ subject: 'agent',
608
+ predicate: 'know',
609
+ object: topic,
610
+ decay: { half_life_days: 60, access_count: 1 },
611
+ schema_version: 2,
612
+ x_memory_layer: 'research',
613
+ x_topic: topic,
614
+ x_temporal_context: masterTemporal,
615
+ x_sources_count: allCitations.length,
616
+ x_citations: allCitations,
617
+ };
618
+
619
+ const masterNode = { slug: masterSlug, body: bodyLines.join('\n'), frontmatter: masterFrontmatter };
620
+
621
+ return { masterNode, supportingNodes };
622
+ }
623
+
624
+ /**
625
+ * Write high-confidence master and supporting nodes DIRECTLY to the vault
626
+ * and immediately trigger a surface recompile.
627
+ */
628
+ async function writeAndSurfaceImmediately(topic, synthesis, sourceResults, {
629
+ vaultDir, skillsDir, derivedDir, instructionsFile,
630
+ }, runtimeConfig) {
631
+ const parsedSupportingNotes = [];
632
+ const topResults = sourceResults.slice(0, 4);
633
+ for (const r of topResults) {
634
+ const summary = await synthesizeSupportingNote(topic, r, runtimeConfig);
635
+ const slug = `fact-note-${crypto.randomBytes(5).toString('hex')}`;
636
+ parsedSupportingNotes.push({ result: r, summary, slug });
637
+ }
638
+
639
+ const { masterNode, supportingNodes } = buildMultiNoteGraph(topic, synthesis, parsedSupportingNotes, 'active');
640
+
641
+ // Write supporting nodes to vault
642
+ for (const node of supportingNodes) {
643
+ writeNode({ ...node.frontmatter, body: node.body }, vaultDir);
644
+ }
645
+ // Write master node to vault
646
+ writeNode({ ...masterNode.frontmatter, body: masterNode.body }, vaultDir);
647
+
648
+ for (const sr of sourceResults) {
649
+ try { registerSource(sr, masterNode.slug); } catch (err) { logger.debug('fact-seeker: registerSource fast-path failed', { err: err.message }); }
650
+ }
651
+
652
+ logger.info({
653
+ subsystem: 'fact-seeker',
654
+ message: `[FAST-PATH] "${topic}" written to vault as active master (${masterNode.slug}) with ${supportingNodes.length} supporting notes. Recompiling surface…`,
655
+ });
656
+
657
+ // Immediately recompile so INSTRUCTIONS.md reflects this new knowledge
658
+ try {
659
+ const { compileSurface } = await import('./surface.mjs');
660
+ await compileSurface({ vaultDir, skillsDir, derivedDir, instructionsFile });
661
+ logger.info({ subsystem: 'fact-seeker', message: `[FAST-PATH] Surface recompiled — "${topic}" now live in INSTRUCTIONS.md` });
662
+ } catch (err) {
663
+ logger.info({ subsystem: 'fact-seeker', message: `[FAST-PATH] Surface recompile failed (non-fatal): ${err.message}` });
664
+ }
665
+
666
+ return masterNode.slug;
667
+ }
668
+
669
+ /**
670
+ * Write low-confidence master and supporting nodes as draft nodes to the inbox.
671
+ */
672
+ async function writeDraftToInbox(topic, synthesis, sourceResults, inboxDir, runtimeConfig) {
673
+ const parsedSupportingNotes = [];
674
+ const topResults = sourceResults.slice(0, 4);
675
+ for (const r of topResults) {
676
+ const summary = await synthesizeSupportingNote(topic, r, runtimeConfig);
677
+ const slug = `fact-note-${crypto.randomBytes(5).toString('hex')}`;
678
+ parsedSupportingNotes.push({ result: r, summary, slug });
679
+ }
680
+
681
+ const { masterNode, supportingNodes } = buildMultiNoteGraph(topic, synthesis, parsedSupportingNotes, 'draft');
682
+
683
+ if (!fs.existsSync(inboxDir)) fs.mkdirSync(inboxDir, { recursive: true });
684
+
685
+ // Write supporting nodes as drafts in the inbox
686
+ for (const node of supportingNodes) {
687
+ atomicWrite(
688
+ path.join(inboxDir, `${node.slug}.md`),
689
+ safeStringify(node.body, node.frontmatter),
690
+ );
691
+ }
692
+
693
+ // Write master node as draft in the inbox
694
+ atomicWrite(
695
+ path.join(inboxDir, `${masterNode.slug}.md`),
696
+ safeStringify(masterNode.body, masterNode.frontmatter),
697
+ );
698
+
699
+ for (const sr of sourceResults) {
700
+ try { registerSource(sr, masterNode.slug); } catch (err) { logger.debug('fact-seeker: registerSource inbox failed', { err: err.message }); }
701
+ }
702
+
703
+ return masterNode.slug;
704
+ }
705
+
706
+ // ─── Self-Diagnosis ──────────────────────────────────────────────────────────────
707
+
708
+ const SELF_DIAGNOSIS_SYSTEM = `You are a Knowledge System Analyst performing a self-audit.
709
+
710
+ Given the current state of a memory vault (topics covered, confidence levels, staleness), produce a diagnosis report.
711
+
712
+ Output valid JSON:
713
+ {
714
+ "overall_coverage_score": 0.0-1.0,
715
+ "strong_areas": ["string"],
716
+ "weak_areas": ["string"],
717
+ "temporal_gaps": ["string (topics where knowledge may be outdated)"],
718
+ "source_diversity_score": 0.0-1.0,
719
+ "recommended_immediate_research": [
720
+ { "topic": "string", "reason": "string", "priority": 1-100 }
721
+ ],
722
+ "limitations": ["string (honest assessment of what the system doesn't know)"],
723
+ "improvement_actions": ["string (concrete steps to buttress intelligence)"]
724
+ }
725
+
726
+ Output ONLY valid JSON.`;
727
+
728
+ /**
729
+ * Run a self-diagnosis of the knowledge base.
730
+ * Evaluates coverage, temporal awareness, source diversity, and limitations.
731
+ */
732
+ export async function runSelfDiagnosis({ vaultDir, runtimeConfig }) {
733
+ const nodes = loadNodes(vaultDir);
734
+ const activeNodes = nodes.filter(n => n.status === 'active');
735
+ const agenda = loadAgenda();
736
+ const availability = checkSourceAvailability(loadResearchConfig());
737
+
738
+ const now = Date.now();
739
+ const factNodes = activeNodes.filter(n => n.category === 'facts');
740
+ const avgAge = factNodes.length > 0
741
+ ? factNodes.reduce((sum, n) => {
742
+ const age = (now - new Date(n.updated || n.created || 0).getTime()) / 86400000;
743
+ return sum + age;
744
+ }, 0) / factNodes.length
745
+ : 0;
746
+
747
+ const vaultSummary = [
748
+ `Total active nodes: ${activeNodes.length}`,
749
+ `Fact nodes: ${factNodes.length}`,
750
+ `Average fact age: ${avgAge.toFixed(0)} days`,
751
+ `Pending research topics: ${agenda.filter(t => t.status === 'pending').length}`,
752
+ `Well-covered topics: ${agenda.filter(t => t.status === 'well-covered').length}`,
753
+ `Available sources: ${availability.available.join(', ')}`,
754
+ `Unavailable sources: ${availability.unavailable.join(', ')}`,
755
+ '',
756
+ '## Current Knowledge Areas (by category/tags)',
757
+ ...activeNodes.slice(0, 30).map(n =>
758
+ `- [${n.category}] ${n.title} (conf: ${n.confidence}, age: ${Math.floor((now - new Date(n.updated || n.created || 0).getTime()) / 86400000)}d)`
759
+ ),
760
+ ].join('\n');
761
+
762
+ const prompt = `Analyze this knowledge base state and produce a self-diagnosis:\n\n${vaultSummary}`;
763
+
764
+ try {
765
+ const raw = await callLocalRuntime(prompt, SELF_DIAGNOSIS_SYSTEM, runtimeConfig);
766
+ const match = raw.match(/\{[\s\S]*\}/);
767
+ if (!match) throw new Error('No JSON in diagnosis response');
768
+ const diagnosis = cleanAndParseJSON(match[0]);
769
+
770
+ // Auto-enqueue high-priority recommended topics
771
+ const recommended = Array.isArray(diagnosis.recommended_immediate_research)
772
+ ? diagnosis.recommended_immediate_research : [];
773
+ for (const rec of recommended.filter(r => r.priority >= 70)) {
774
+ addToAgenda({
775
+ topic: rec.topic,
776
+ priority: rec.priority,
777
+ source: 'self-diagnosis',
778
+ rationale: rec.reason,
779
+ });
780
+ }
781
+
782
+ // Log source warnings
783
+ for (const warning of availability.warnings) {
784
+ logger.info({ subsystem: 'fact-seeker', message: `[source-config] ${warning}` });
785
+ }
786
+
787
+ logger.info({
788
+ subsystem: 'fact-seeker',
789
+ message: `Self-diagnosis: coverage ${diagnosis.overall_coverage_score}, ${diagnosis.weak_areas?.length || 0} weak areas, ${recommended.length} topics recommended`,
790
+ });
791
+
792
+ return { ...diagnosis, source_availability: availability };
793
+ } catch (err) {
794
+ logger.info({ subsystem: 'fact-seeker', message: `Self-diagnosis failed: ${err.message}` });
795
+ return { error: err.message, source_availability: availability };
796
+ }
797
+ }
798
+
799
+ // ─── Main Entry Point ────────────────────────────────────────────────────────────
800
+
801
+ /**
802
+ * Execute one knowledge acquisition cycle:
803
+ * 1. Pull next topic from agenda (or skip if empty)
804
+ * 2. Gather from all available real sources
805
+ * 3. Synthesize with LLM
806
+ * 4. Write cited draft node to inbox
807
+ * 5. Update agenda coverage score
808
+ *
809
+ * @param {object} opts
810
+ * @param {string} opts.vaultDir
811
+ * @param {string} opts.inboxDir
812
+ * @param {string} opts.queueDir
813
+ * @param {object} opts.runtimeConfig Local LLM config
814
+ * @param {string} [opts.forceTopic] Override agenda — research this topic now
815
+ * @returns {{ topic, factSlug?, sources, error? }}
816
+ */
817
+ export async function runKnowledgeAcquisitionCycle({
818
+ vaultDir,
819
+ inboxDir,
820
+ queueDir,
821
+ runtimeConfig,
822
+ forceTopic = null,
823
+ // Surface paths — needed for immediate recompile on fast path
824
+ skillsDir,
825
+ derivedDir,
826
+ instructionsFile,
827
+ }) {
828
+ const researchConfig = loadResearchConfig();
829
+
830
+ // Determine topic
831
+ let topicEntry;
832
+ if (forceTopic) {
833
+ topicEntry = addToAgenda({ topic: forceTopic, priority: 90, source: 'direct-instruction', directInstruction: true });
834
+ } else {
835
+ topicEntry = getNextAgendaTopic();
836
+ }
837
+
838
+ if (!topicEntry) {
839
+ // No agenda items — run self-diagnosis to generate new ones
840
+ logger.info({ subsystem: 'fact-seeker', message: 'Agenda empty — running self-diagnosis to generate topics' });
841
+ await runSelfDiagnosis({ vaultDir, runtimeConfig });
842
+ topicEntry = getNextAgendaTopic();
843
+ if (!topicEntry) {
844
+ return { topic: null, skipped: 'empty-agenda-after-diagnosis' };
845
+ }
846
+ }
847
+
848
+ const { topic, id: topicId } = topicEntry;
849
+ logger.info({ subsystem: 'fact-seeker', message: `Researching: "${topic}" (priority: ${topicEntry.priority})` });
850
+
851
+ // Gather from real sources
852
+ const { results, errors, sourcesUsed } = await gatherFromSources(topicEntry, researchConfig);
853
+
854
+ if (results.length === 0) {
855
+ logger.info({
856
+ subsystem: 'fact-seeker',
857
+ message: `No results for "${topic}". Errors: ${errors.map(e => `${e.source}: ${e.error}`).join('; ')}`,
858
+ });
859
+ return { topic, sources: [], errors, skipped: 'no-results' };
860
+ }
861
+
862
+ // Synthesize via local LLM
863
+ const synthesis = await synthesizeFacts(topic, results, runtimeConfig);
864
+ if (!synthesis) {
865
+ return { topic, sources: sourcesUsed, skipped: 'synthesis-failed' };
866
+ }
867
+
868
+ const confidence = synthesis.confidence || 0;
869
+
870
+ // ─── Fast Path: high-confidence → vault immediately + surface recompile ────────
871
+ // Low-confidence → inbox for human/validation review
872
+ let factSlug;
873
+ let surfaced = false;
874
+ if (confidence >= FAST_PATH_CONFIDENCE && skillsDir && derivedDir && instructionsFile) {
875
+ factSlug = await writeAndSurfaceImmediately(topic, synthesis, results, {
876
+ vaultDir, skillsDir, derivedDir, instructionsFile,
877
+ }, runtimeConfig);
878
+ surfaced = true;
879
+ } else {
880
+ // Low confidence: stage for validation before vault promotion
881
+ factSlug = await writeDraftToInbox(topic, synthesis, results, inboxDir, runtimeConfig);
882
+ logger.info({
883
+ subsystem: 'fact-seeker',
884
+ message: `[INBOX-PATH] "${topic}" staged for validation (conf: ${confidence} < ${FAST_PATH_CONFIDENCE})`,
885
+ });
886
+ }
887
+
888
+ // Calculate coverage score
889
+ const coverageScore = Math.min(1.0, (results.length / 5) * confidence);
890
+ markTopicResearched(topicId, { coverageScore, sourcesConsulted: sourcesUsed });
891
+
892
+ // Enqueue follow-up research for identified gaps (self-multiplication)
893
+ const gaps = synthesis.further_research_needed || [];
894
+ for (const gap of gaps.slice(0, 3)) {
895
+ addToAgenda({
896
+ topic: gap,
897
+ priority: Math.max(20, topicEntry.priority - 15),
898
+ source: `follow-up:${topic}`,
899
+ rationale: `Gap identified while researching "${topic}"`,
900
+ });
901
+ }
902
+
903
+ logger.info({
904
+ subsystem: 'fact-seeker',
905
+ message: `"${topic}" complete: ${results.length} sources, confidence ${confidence}, slug ${factSlug}, surfaced: ${surfaced}`,
906
+ });
907
+
908
+ return { topic, factSlug, sources: sourcesUsed, confidence, surfaced };
909
+ }
910
+
911
+ /**
912
+ * Ingest a session and add inferred topics to the research agenda.
913
+ * Called by the daemon after each session post-mortem.
914
+ */
915
+ export async function ingestSessionTopics(sessionTranscript, runtimeConfig) {
916
+ const topics = await inferTopicsFromSession(sessionTranscript, runtimeConfig);
917
+ const added = [];
918
+
919
+ for (const t of topics) {
920
+ const entry = addToAgenda({
921
+ topic: t.topic,
922
+ priority: t.priority || 50,
923
+ source: 'session-inference',
924
+ rationale: t.rationale || '',
925
+ tags: t.tags || [],
926
+ });
927
+ added.push(entry.topic);
928
+ }
929
+
930
+ if (added.length > 0) {
931
+ logger.info({
932
+ subsystem: 'fact-seeker',
933
+ message: `Added ${added.length} topics from session: ${added.join(', ')}`,
934
+ });
935
+ }
936
+
937
+ return added;
938
+ }
939
+
940
+ // ─── Specialized Cognitive Research Phase Engines ────────────────────────────────
941
+
942
+ async function pushDeliberationConclusion(conclusions = []) {
943
+ if (!conclusions.length) return;
944
+
945
+ // 1. Resolve interrupts file path
946
+ // Use isolated dynamic agent dir in tests, global in production
947
+ const interruptsFile = path.join(getBrainDir(), 'interrupts', 'pending.md');
948
+
949
+ const dir = path.dirname(interruptsFile);
950
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
951
+
952
+ // 2. Read existing content to check for duplication
953
+ const existingContent = fs.existsSync(interruptsFile) ? fs.readFileSync(interruptsFile, 'utf8') : '';
954
+
955
+ // Filter out conclusions that are already present in pending.md to prevent duplicate alerts
956
+ const uniqueConclusions = conclusions.filter(c => !existingContent.includes(c));
957
+ if (uniqueConclusions.length === 0) {
958
+ logger.info({ subsystem: 'fact-seeker', message: 'Deliberation conclusions are already present in pending.md, skipping notifications.' });
959
+ return;
960
+ }
961
+
962
+ // 3. Append unique conclusions to the interrupts file
963
+ const lines = [
964
+ `\n\n---`,
965
+ `<!-- total-recall deliberation-interrupt: ${new Date().toISOString()} -->`,
966
+ `## 🧠 New Deliberation Insight`,
967
+ ...uniqueConclusions.map(c => `- ${c}`),
968
+ ``,
969
+ `*Query \`search_memory\` or check vault for full context.*`,
970
+ ];
971
+ fs.appendFileSync(interruptsFile, lines.join('\n'));
972
+
973
+ // 4. macOS notification (Skip in tests)
974
+ if (process.env.VITEST) {
975
+ logger.debug('fact-seeker: bypassing notification in test environment');
976
+ return;
977
+ }
978
+
979
+ const notificationSummary = uniqueConclusions[0] || 'New deliberation conclusion';
980
+ try {
981
+ const { sendSystemNotification } = await import('./notifications.mjs');
982
+ await sendSystemNotification('Total Recall Deliberation', notificationSummary, {
983
+ open: interruptsFile,
984
+ sound: 'Glass',
985
+ subtitle: 'Deliberation Insight',
986
+ group: 'total-recall-deliberation'
987
+ });
988
+ } catch (err) {
989
+ logger.debug('fact-seeker: deliberation notification failed', { err: err.message });
990
+ }
991
+ }
992
+
993
+ /**
994
+ * Phase 2/5: Deliberation
995
+ */
996
+ export async function runResearchDeliberationCycle({ vaultDir, nodeSlug, topic, runtimeConfig }) {
997
+ const nodes = loadNodes(vaultDir);
998
+ const targetNode = nodes.find(n => n.slug === nodeSlug);
999
+ if (!targetNode) {
1000
+ return { success: false, error: `Target node not found for slug: ${nodeSlug}` };
1001
+ }
1002
+
1003
+ const otherFacts = nodes.filter(n => n.status === 'active' && n.category === 'facts' && n.slug !== nodeSlug);
1004
+ const otherFactsText = otherFacts.slice(0, 15).map(n => `- [${n.slug}] ${n.title}: ${n.body.slice(0, 300)}`).join('\n');
1005
+
1006
+ const today = getLocalizedDateTime();
1007
+ const prompt = `Today's Date/Time: ${today}
1008
+ Target Node Topic: "${topic}"
1009
+ Title: ${targetNode.title}
1010
+ Temporal Context of Target: ${targetNode.x_temporal_context || 'unknown'}
1011
+ Body:
1012
+ ${targetNode.body}
1013
+
1014
+ Other Active Facts in Memory Vault:
1015
+ ${otherFactsText || 'No other active facts.'}
1016
+
1017
+ Analyze the target node in the context of the other facts. Identify:
1018
+ 1. Any connections, overlaps, or implicit relationships between this node and the other facts.
1019
+ 2. Any contradictions or discrepancies between this node and the other facts.
1020
+ 3. Any facts that this target node supersedes or renders obsolete (e.g. because this is a newer update or contains more recent data than an older fact).
1021
+ 4. Any new higher-level cognitive insights or takeaways.
1022
+ 5. Additional tags to add to the target node.`;
1023
+
1024
+ const systemPrompt = `You are a System 2 Cognitive Deliberation Engine. Today's date and time is ${today}. Your job is to deeply analyze the target knowledge node in relation to other facts in the memory vault.
1025
+ Identify connections, contradictions/discrepancies, facts that are superseded or rendered obsolete by the target node, high-level insights, actionable patterns/corrections, and autonomous tasks.
1026
+
1027
+ Output valid JSON:
1028
+ {
1029
+ "insights": ["specific high-level insight"],
1030
+ "connections": [
1031
+ {
1032
+ "slug": "string (the target node slug to link to)",
1033
+ "description": "string (relationship explanation)"
1034
+ }
1035
+ ],
1036
+ "contradictions": [
1037
+ {
1038
+ "slug": "string (the target node slug that contradicts)",
1039
+ "description": "string (contradiction explanation)"
1040
+ }
1041
+ ],
1042
+ "supersedes": [
1043
+ {
1044
+ "slug": "string (the target node slug that this target node supersedes/renders obsolete)",
1045
+ "description": "string (explanation of why the older node is now obsolete/superseded)"
1046
+ }
1047
+ ],
1048
+ "additional_tags": ["tag1", "tag2"],
1049
+ "synthesized_instructions": [
1050
+ {
1051
+ "title": "string (clear guideline title)",
1052
+ "body": "string (the detailed rule/convention/guideline)",
1053
+ "category": "patterns",
1054
+ "modality": "should",
1055
+ "sentiment_polarity": "descriptive",
1056
+ "subject": "agent",
1057
+ "predicate": "use",
1058
+ "object": "string (what the rule applies to)"
1059
+ }
1060
+ ],
1061
+ "autonomous_tasks": [
1062
+ {
1063
+ "priority": 50,
1064
+ "category": "proactive-research",
1065
+ "reason": "string (why this task is needed)",
1066
+ "body": "string (detailed markdown instructions for the task)"
1067
+ }
1068
+ ]
1069
+ }
1070
+
1071
+ Rules for synthesized_instructions:
1072
+ - Use category "patterns" or "corrections".
1073
+ - Modality MUST be one of: "must", "must_not", "should", "should_not".
1074
+ - Sentiment_polarity MUST be one of: "directive_must", "directive_must_not", "descriptive", "preference".
1075
+ - Subject and predicate MUST be alphanumeric (letters, numbers, hyphens, underscores, dots, spaces only). E.g., subject="agent", predicate="use".
1076
+ - Keep rules highly actionable.
1077
+
1078
+ Rules for autonomous_tasks:
1079
+ - Category MUST be one of: "memory-maintenance", "system2-deliberation", "skill-engineering", "proactive-research", "self-evaluation", "exploration".
1080
+ - Priority MUST be an integer between 1 and 100.
1081
+
1082
+ Output ONLY valid JSON.`;
1083
+
1084
+ try {
1085
+ const raw = await callLocalRuntime(prompt, systemPrompt, runtimeConfig);
1086
+ const match = raw.match(/\{[\s\S]*\}/);
1087
+ if (!match) throw new Error('No JSON in deliberation response');
1088
+ const result = cleanAndParseJSON(match[0]);
1089
+
1090
+ // Update target node body and frontmatter
1091
+ let updatedBody = targetNode.body;
1092
+ const linesToAdd = [];
1093
+
1094
+ if (result.insights && result.insights.length > 0) {
1095
+ linesToAdd.push('', '## Cognitive Deliberation', ...result.insights.map(ins => `- **Insight**: ${ins}`));
1096
+ }
1097
+
1098
+ const relatedSlugs = new Set(targetNode.related || []);
1099
+ if (result.connections && result.connections.length > 0) {
1100
+ if (linesToAdd.length === 0) linesToAdd.push('', '## Cognitive Deliberation');
1101
+ for (const conn of result.connections) {
1102
+ if (conn.slug && conn.slug !== nodeSlug) {
1103
+ linesToAdd.push(`- **Connection to [[${conn.slug}]]**: ${conn.description || ''}`);
1104
+ relatedSlugs.add(conn.slug);
1105
+ // Bidirectional graph link
1106
+ const otherNode = nodes.find(n => n.slug === conn.slug);
1107
+ if (otherNode) {
1108
+ const otherRelated = new Set(otherNode.related || []);
1109
+ otherRelated.add(nodeSlug);
1110
+ otherNode.related = Array.from(otherRelated);
1111
+ otherNode.updated = new Date().toISOString();
1112
+ writeNode(otherNode, vaultDir);
1113
+ }
1114
+ }
1115
+ }
1116
+ }
1117
+ if (result.contradictions && result.contradictions.length > 0) {
1118
+ if (linesToAdd.length === 0) linesToAdd.push('', '## Cognitive Deliberation');
1119
+ for (const contra of result.contradictions) {
1120
+ if (contra.slug && contra.slug !== nodeSlug) {
1121
+ linesToAdd.push(`- **Discrepancy with [[${contra.slug}]]**: ${contra.description || ''}`);
1122
+ relatedSlugs.add(contra.slug);
1123
+ // Bidirectional graph link and contradictions annotation
1124
+ const otherNode = nodes.find(n => n.slug === contra.slug);
1125
+ if (otherNode) {
1126
+ const otherRelated = new Set(otherNode.related || []);
1127
+ otherRelated.add(nodeSlug);
1128
+ otherNode.related = Array.from(otherRelated);
1129
+ const otherContradicts = new Set(otherNode.contradicts || []);
1130
+ otherContradicts.add(nodeSlug);
1131
+ otherNode.contradicts = Array.from(otherContradicts);
1132
+ otherNode.updated = new Date().toISOString();
1133
+ writeNode(otherNode, vaultDir);
1134
+ }
1135
+ }
1136
+ }
1137
+ }
1138
+
1139
+ const originalSupersedes = targetNode.supersedes || [];
1140
+ const newSupersedes = [...new Set([...originalSupersedes])];
1141
+ if (result.supersedes && result.supersedes.length > 0) {
1142
+ if (linesToAdd.length === 0) linesToAdd.push('', '## Cognitive Deliberation');
1143
+ for (const sup of result.supersedes) {
1144
+ if (sup.slug && sup.slug !== nodeSlug) {
1145
+ linesToAdd.push(`- **Supersedes [[${sup.slug}]]**: ${sup.description || ''}`);
1146
+ newSupersedes.push(sup.slug);
1147
+ // Update the superseded node in vault
1148
+ const otherNode = nodes.find(n => n.slug === sup.slug);
1149
+ if (otherNode) {
1150
+ otherNode.superseded_by = nodeSlug;
1151
+ otherNode.updated = new Date().toISOString();
1152
+ writeNode(otherNode, vaultDir);
1153
+ }
1154
+ }
1155
+ }
1156
+ }
1157
+
1158
+ if (linesToAdd.length > 0) {
1159
+ updatedBody += '\n' + linesToAdd.join('\n');
1160
+ }
1161
+
1162
+ const originalTags = targetNode.tags || [];
1163
+ const newTags = [...new Set([...originalTags, ...(result.additional_tags || []), 'deliberated'])];
1164
+
1165
+ targetNode.related = Array.from(relatedSlugs);
1166
+ const updatedNode = {
1167
+ ...targetNode,
1168
+ body: updatedBody,
1169
+ tags: newTags,
1170
+ supersedes: newSupersedes,
1171
+ updated: new Date().toISOString()
1172
+ };
1173
+
1174
+ writeNode(updatedNode, vaultDir);
1175
+
1176
+ const conclusions = [];
1177
+ if (result.insights && result.insights.length > 0) {
1178
+ conclusions.push(...result.insights);
1179
+ }
1180
+ if (result.connections && result.connections.length > 0) {
1181
+ conclusions.push(...result.connections.map(c => `Connection to ${c.slug}: ${c.description || ''}`));
1182
+ }
1183
+
1184
+ if (conclusions.length > 0) {
1185
+ await pushDeliberationConclusion(conclusions);
1186
+ }
1187
+
1188
+ // Process synthesized instructions
1189
+ const localBrainDir = getBrainDir();
1190
+ const now = new Date().toISOString();
1191
+ let rulesSynthesized = false;
1192
+
1193
+ if (result.synthesized_instructions && result.synthesized_instructions.length > 0) {
1194
+ for (const inst of result.synthesized_instructions) {
1195
+ const ruleSlug = `rule-synth-${crypto.randomBytes(5).toString('hex')}`;
1196
+ const ruleNode = {
1197
+ type: 'memory',
1198
+ slug: ruleSlug,
1199
+ category: inst.category || 'patterns',
1200
+ title: inst.title || 'Synthesized Rule',
1201
+ status: 'active',
1202
+ priority: 'absolute',
1203
+ confidence: 0.9,
1204
+ importance: 5,
1205
+ created: now,
1206
+ updated: now,
1207
+ last_accessed: now,
1208
+ source: {
1209
+ type: 'deliberation-synthesis',
1210
+ session_id: targetNode.source?.session_id || `delib-${Date.now().toString(36)}`,
1211
+ agent: 'fact-seeker-deliberation',
1212
+ evidence_count: 1,
1213
+ },
1214
+ supersedes: [],
1215
+ superseded_by: null,
1216
+ contradicts: [],
1217
+ tags: ['deliberation', 'synthesized-rule', 'auto-generated'],
1218
+ related: [nodeSlug],
1219
+ routes_to_skills: [],
1220
+ sentiment_polarity: inst.sentiment_polarity || 'descriptive',
1221
+ sentiment_target: inst.category || 'patterns',
1222
+ modality: inst.modality || 'should',
1223
+ subject: inst.subject || 'agent',
1224
+ predicate: inst.predicate || 'use',
1225
+ object: inst.object || topic,
1226
+ decay: { half_life_days: 120, access_count: 1 },
1227
+ schema_version: 2,
1228
+ x_memory_layer: 'conscious',
1229
+ x_temporal_context: targetNode.x_temporal_context || now,
1230
+ x_citations: [{
1231
+ source: 'deliberation-synthesis',
1232
+ title: `Synthesized from target node: ${targetNode.title || nodeSlug}`,
1233
+ url: `file://${path.join(vaultDir, targetNode.category, targetNode.slug + '.md')}`,
1234
+ published: targetNode.updated || now,
1235
+ relevance: 1.0,
1236
+ accessed: now
1237
+ }],
1238
+ body: inst.body || ''
1239
+ };
1240
+
1241
+ writeNode(ruleNode, vaultDir);
1242
+ rulesSynthesized = true;
1243
+ logger.info({
1244
+ subsystem: 'fact-seeker',
1245
+ message: `Synthesized active rule node: ${ruleSlug} (${inst.category})`
1246
+ });
1247
+ }
1248
+
1249
+ // Trigger immediate surface recompilation if active rules were synthesized
1250
+ if (rulesSynthesized) {
1251
+ try {
1252
+ const skillsDir = path.join(getAgentDir(), 'skills');
1253
+ const derivedDir = path.join(localBrainDir, 'memory-derived');
1254
+ const instructionsFile = path.join(getAgentDir(), 'INSTRUCTIONS.md');
1255
+ const { compileSurface } = await import('./surface.mjs');
1256
+ await compileSurface({ vaultDir, skillsDir, derivedDir, instructionsFile });
1257
+ logger.info({
1258
+ subsystem: 'fact-seeker',
1259
+ message: `Surface compiled with newly synthesized developer instructions/patterns.`
1260
+ });
1261
+ } catch (err) {
1262
+ logger.info({
1263
+ subsystem: 'fact-seeker',
1264
+ message: `Surface compilation failed (non-fatal): ${err.message}`
1265
+ });
1266
+ }
1267
+ }
1268
+ }
1269
+
1270
+ // Process autonomous tasks
1271
+ if (result.autonomous_tasks && result.autonomous_tasks.length > 0) {
1272
+ const queueDir = path.join(localBrainDir, 'scheduler', 'queue');
1273
+ const { persistTaskToDisk } = await import('./scheduler.mjs');
1274
+ for (const t of result.autonomous_tasks) {
1275
+ const taskSlug = `delib-task-${crypto.randomBytes(4).toString('hex')}`;
1276
+ const taskNode = {
1277
+ type: 'task',
1278
+ slug: taskSlug,
1279
+ priority: t.priority || 50,
1280
+ category: t.category || 'proactive-research',
1281
+ status: 'pending',
1282
+ created_by: 'fact-seeker-deliberation',
1283
+ reason: t.reason || `Autonomously generated from deliberation on "${topic}"`,
1284
+ body: t.body || `Follow up on research for "${topic}"`,
1285
+ progress: 0,
1286
+ estimated_calls: 5,
1287
+ deadline: new Date(Date.now() + 86400000).toISOString().split('T')[0]
1288
+ };
1289
+
1290
+ persistTaskToDisk(taskNode, queueDir);
1291
+ logger.info({
1292
+ subsystem: 'fact-seeker',
1293
+ message: `Scheduled autonomous deliberation task: ${taskSlug} (${t.category})`
1294
+ });
1295
+ }
1296
+ }
1297
+
1298
+ return {
1299
+ success: true,
1300
+ output: `Deliberated on "${topic}". Generated ${result.insights?.length || 0} insights, ${result.connections?.length || 0} connections, ${result.synthesized_instructions?.length || 0} instructions, ${result.autonomous_tasks?.length || 0} tasks.`,
1301
+ factSlug: nodeSlug
1302
+ };
1303
+ } catch (err) {
1304
+ return { success: false, error: `Deliberation failed: ${err.message}` };
1305
+ }
1306
+ }
1307
+
1308
+ /**
1309
+ * Phase 3/5: Refinement & Clarity Auditing
1310
+ */
1311
+ export async function runResearchImprovementCycle({ vaultDir, nodeSlug, topic, runtimeConfig }) {
1312
+ const nodes = loadNodes(vaultDir);
1313
+ const targetNode = nodes.find(n => n.slug === nodeSlug);
1314
+ if (!targetNode) {
1315
+ return { success: false, error: `Target node not found for slug: ${nodeSlug}` };
1316
+ }
1317
+
1318
+ const today = getLocalizedDateTime();
1319
+ const prompt = `Today's Date/Time: ${today}
1320
+ Target Node Topic: "${topic}"
1321
+ Title: ${targetNode.title}
1322
+ Body:
1323
+ ${targetNode.body}
1324
+
1325
+ Rewrite the markdown body to improve structure, layout, typography, and premium clarity.
1326
+
1327
+ Rules:
1328
+ - Keep the content extremely premium and professional.
1329
+ - Use clean headings, lists, and tables where appropriate.
1330
+ - STRICTLY PRESERVE all original citations, sources, URLs, and key facts. Do NOT hallucinate or delete any source references!
1331
+ - Strictly output the raw markdown of the improved body without any wrapper or conversational text.`;
1332
+
1333
+ const systemPrompt = `You are a Markdown Refinement & Clarity Auditor. Today's date and time is ${today}.
1334
+ Your job is to restructure and polish markdown files for ultimate readability and premium layout.
1335
+ Ensure proper hierarchy and typography while strictly retaining every single citation, key fact, and URL.
1336
+ Output ONLY the raw markdown of the polished body, starting immediately with no introductory text or backtick wrappers.`;
1337
+
1338
+ try {
1339
+ const raw = await callLocalRuntime(prompt, systemPrompt, runtimeConfig);
1340
+
1341
+ let cleanedBody = raw.trim();
1342
+ if (cleanedBody.startsWith('```markdown')) {
1343
+ cleanedBody = cleanedBody.slice(11).trim();
1344
+ }
1345
+ if (cleanedBody.startsWith('```')) {
1346
+ cleanedBody = cleanedBody.slice(3).trim();
1347
+ }
1348
+ if (cleanedBody.endsWith('```')) {
1349
+ cleanedBody = cleanedBody.slice(0, -3).trim();
1350
+ }
1351
+
1352
+ if (cleanedBody.length < 50) {
1353
+ throw new Error('Polished body is suspiciously short');
1354
+ }
1355
+
1356
+ const updatedNode = {
1357
+ ...targetNode,
1358
+ body: cleanedBody,
1359
+ tags: [...new Set([...(targetNode.tags || []), 'improved'])],
1360
+ updated: new Date().toISOString()
1361
+ };
1362
+
1363
+ writeNode(updatedNode, vaultDir);
1364
+
1365
+ return {
1366
+ success: true,
1367
+ output: `Improved and refined document layout for "${topic}".`,
1368
+ factSlug: nodeSlug
1369
+ };
1370
+ } catch (err) {
1371
+ return { success: false, error: `Markdown improvement failed: ${err.message}` };
1372
+ }
1373
+ }
1374
+
1375
+ /**
1376
+ * Phase 4/5: Source Monitoring
1377
+ */
1378
+ export async function runResearchMonitoringCycle({
1379
+ vaultDir,
1380
+ nodeSlug,
1381
+ topic,
1382
+ runtimeConfig,
1383
+ skillsDir,
1384
+ derivedDir,
1385
+ instructionsFile
1386
+ }) {
1387
+ const nodes = loadNodes(vaultDir);
1388
+ const targetNode = nodes.find(n => n.slug === nodeSlug);
1389
+ if (!targetNode) {
1390
+ return { success: false, error: `Target node not found for slug: ${nodeSlug}` };
1391
+ }
1392
+
1393
+ const researchConfig = loadResearchConfig();
1394
+ const searchQuery = `${topic} newsletters blogs release notes feeds updates`;
1395
+
1396
+ let results = [];
1397
+ try {
1398
+ results = await webSearch(searchQuery, researchConfig, 5);
1399
+ } catch (err) {
1400
+ logger.info({ subsystem: 'fact-seeker', message: `Web search for monitoring failed: ${err.message}` });
1401
+ }
1402
+
1403
+ if (results.length === 0) {
1404
+ return { success: true, output: `Monitoring skipped: no search results found for query: "${searchQuery}"`, factSlug: nodeSlug };
1405
+ }
1406
+
1407
+ const sourceText = results.map(r => `Title: ${r.title}\nURL: ${r.url}\nSnippet: ${r.snippet}`).join('\n\n');
1408
+
1409
+ const today = getLocalizedDateTime();
1410
+ const prompt = `Today's Date/Time: ${today}
1411
+ Research Topic: "${topic}"
1412
+ Search Results for ongoing updates/feeds/newsletters/releases:
1413
+ ${sourceText}
1414
+
1415
+ Extract 2-4 authoritative, high-quality ongoing sources (newsletters, RSS feeds, official release notes, blogs, communities) for this topic.`;
1416
+
1417
+ const systemPrompt = `You are a Research Monitoring Specialist. Today's date and time is ${today}.
1418
+ Your job is to identify high-quality ongoing sources of information (e.g. RSS feeds, newsletters, official release notes, specific blogs) from search results.
1419
+
1420
+ Output valid JSON:
1421
+ {
1422
+ "sources": [
1423
+ {
1424
+ "name": "string (name of the newsletter, blog, or release feed)",
1425
+ "url": "string (direct URL)",
1426
+ "type": "string (newsletter / RSS feed / official blog / release notes / etc.)",
1427
+ "description": "string (why this is a reliable ongoing source)"
1428
+ }
1429
+ ]
1430
+ }
1431
+
1432
+ Output ONLY valid JSON.`;
1433
+
1434
+ try {
1435
+ const raw = await callLocalRuntime(prompt, systemPrompt, runtimeConfig);
1436
+ const match = raw.match(/\{[\s\S]*\}/);
1437
+ if (!match) throw new Error('No JSON in monitoring response');
1438
+ const parsed = cleanAndParseJSON(match[0]);
1439
+ const sources = parsed.sources || [];
1440
+
1441
+ if (sources.length === 0) {
1442
+ return { success: true, output: `Monitoring complete: no ongoing sources extracted for "${topic}".`, factSlug: nodeSlug };
1443
+ }
1444
+
1445
+ let updatedBody = targetNode.body;
1446
+ const linesToAdd = ['', '## Ongoing Monitoring & Sources'];
1447
+ sources.forEach(src => {
1448
+ linesToAdd.push(`- **[${src.name}](${src.url})** (${src.type}) — ${src.description}`);
1449
+ });
1450
+
1451
+ updatedBody += '\n' + linesToAdd.join('\n');
1452
+
1453
+ const updatedNode = {
1454
+ ...targetNode,
1455
+ body: updatedBody,
1456
+ tags: [...new Set([...(targetNode.tags || []), 'monitored'])],
1457
+ updated: new Date().toISOString()
1458
+ };
1459
+
1460
+ writeNode(updatedNode, vaultDir);
1461
+
1462
+ // Trigger immediate surface recompilation if active rules/sources were added
1463
+ if (skillsDir && derivedDir && instructionsFile) {
1464
+ try {
1465
+ const { compileSurface } = await import('./surface.mjs');
1466
+ await compileSurface({ vaultDir, skillsDir, derivedDir, instructionsFile });
1467
+ logger.info({
1468
+ subsystem: 'fact-seeker',
1469
+ message: `Surface compiled with newly monitored sources.`
1470
+ });
1471
+ } catch (err) {
1472
+ logger.info({
1473
+ subsystem: 'fact-seeker',
1474
+ message: `Surface compilation failed (non-fatal): ${err.message}`
1475
+ });
1476
+ }
1477
+ }
1478
+
1479
+ return {
1480
+ success: true,
1481
+ output: `Monitoring complete: added ${sources.length} ongoing sources for "${topic}".`,
1482
+ factSlug: nodeSlug
1483
+ };
1484
+ } catch (err) {
1485
+ return { success: false, error: `Monitoring failed: ${err.message}` };
1486
+ }
1487
+ }
1488
+
1489
+ /**
1490
+ * Phase 5/5: Domain Tangents Expansion
1491
+ */
1492
+ export async function runResearchExpansionCycle({ vaultDir, nodeSlug, topic, runtimeConfig }) {
1493
+ const nodes = loadNodes(vaultDir);
1494
+ const targetNode = nodes.find(n => n.slug === nodeSlug);
1495
+ if (!targetNode) {
1496
+ return { success: false, error: `Target node not found for slug: ${nodeSlug}` };
1497
+ }
1498
+
1499
+ const today = getLocalizedDateTime();
1500
+ const prompt = `Today's Date/Time: ${today}
1501
+ Target Node Topic: "${topic}"
1502
+ Title: ${targetNode.title}
1503
+ Body:
1504
+ ${targetNode.body}
1505
+
1506
+ Deliberate on what adjacent domain topics must be researched next to build complete expertise.
1507
+ Brainstorm 2-3 highly specific, searchable research topics/questions.`;
1508
+
1509
+ const systemPrompt = `You are a Research Expansion Specialist. Today's date and time is ${today}.
1510
+ Your job is to brainstorm the next logical adjacent topics that must be researched to build comprehensive domain expertise on the target topic.
1511
+
1512
+ Output valid JSON:
1513
+ {
1514
+ "tangents": [
1515
+ {
1516
+ "topic": "string (specific, searchable topic)",
1517
+ "priority": "low" | "medium" | "high",
1518
+ "rationale": "string (why this tangent is crucial next step)"
1519
+ }
1520
+ ]
1521
+ }
1522
+
1523
+ Output ONLY valid JSON.`;
1524
+
1525
+ try {
1526
+ const raw = await callLocalRuntime(prompt, systemPrompt, runtimeConfig);
1527
+ const match = raw.match(/\{[\s\S]*\}/);
1528
+ if (!match) throw new Error('No JSON in expansion response');
1529
+ const parsed = cleanAndParseJSON(match[0]);
1530
+ const tangents = parsed.tangents || [];
1531
+
1532
+ if (tangents.length === 0) {
1533
+ return { success: true, output: `Expansion complete: no new tangents brainstormed for "${topic}".`, factSlug: nodeSlug };
1534
+ }
1535
+
1536
+ const localBrainDir = getBrainDir();
1537
+ const queueDir = path.join(localBrainDir, 'scheduler', 'queue');
1538
+ const { persistTaskToDisk } = await import('./scheduler.mjs');
1539
+
1540
+ const spawnedList = [];
1541
+ for (const tangent of tangents.slice(0, 3)) {
1542
+ try {
1543
+ const added = addToQueue({
1544
+ topic: tangent.topic,
1545
+ priority: tangent.priority || 'medium',
1546
+ notes: `Autonomously spawned from research on "${topic}". Rationale: ${tangent.rationale}`
1547
+ });
1548
+ spawnedList.push(added);
1549
+
1550
+ // Also schedule a scheduler task to run proactive research for this topic
1551
+ const prio = tangent.priority === 'high' ? 65 : (tangent.priority === 'low' ? 45 : 55);
1552
+ const taskSlug = `proactive-research-${crypto.randomBytes(4).toString('hex')}`;
1553
+ const taskNode = {
1554
+ type: 'task',
1555
+ slug: taskSlug,
1556
+ priority: prio,
1557
+ category: 'proactive-research',
1558
+ status: 'pending',
1559
+ created_by: 'fact-seeker-expansion',
1560
+ reason: tangent.rationale || `Explore research tangent on: ${tangent.topic}`,
1561
+ body: `Autonomously spawned from research on "${topic}".\n\nTopic: ${tangent.topic}\n\nRationale:\n${tangent.rationale}`,
1562
+ progress: 0,
1563
+ estimated_calls: 5,
1564
+ deadline: new Date(Date.now() + 86400000).toISOString().split('T')[0]
1565
+ };
1566
+
1567
+ persistTaskToDisk(taskNode, queueDir);
1568
+ logger.info({
1569
+ subsystem: 'fact-seeker',
1570
+ message: `Scheduled expansion task: ${taskSlug} for topic "${tangent.topic}"`
1571
+ });
1572
+ } catch (err) {
1573
+ logger.error({ subsystem: 'fact-seeker', message: `Failed to auto-spawn tangent "${tangent.topic}": ${err.message}` });
1574
+ }
1575
+ }
1576
+
1577
+ let updatedBody = targetNode.body;
1578
+ const linesToAdd = ['', '## Spawned Research Expansion Avenues'];
1579
+ tangents.forEach(t => {
1580
+ linesToAdd.push(`- **${t.topic}** (${t.priority || 'medium'}) — ${t.rationale}`);
1581
+ });
1582
+
1583
+ updatedBody += '\n' + linesToAdd.join('\n');
1584
+
1585
+ const updatedNode = {
1586
+ ...targetNode,
1587
+ body: updatedBody,
1588
+ tags: [...new Set([...(targetNode.tags || []), 'expanded'])],
1589
+ updated: new Date().toISOString()
1590
+ };
1591
+
1592
+ writeNode(updatedNode, vaultDir);
1593
+
1594
+ return {
1595
+ success: true,
1596
+ output: `Autonomously spawned ${spawnedList.length} new research tangents: ${spawnedList.map(s => `"${s.topic}"`).join(', ')}.`,
1597
+ factSlug: nodeSlug
1598
+ };
1599
+ } catch (err) {
1600
+ return { success: false, error: `Tangent expansion failed: ${err.message}` };
1601
+ }
1602
+ }