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,145 @@
1
+ /**
2
+ * Quick Capture β€” ingest messages from external sources as SSSS inbox nodes
3
+ *
4
+ * Classifies incoming message text and writes a draft SSSS memory node
5
+ * to ~/.agent/memory-inbox/ for Dream Cycle synthesis on the next pass.
6
+ * File-native; no external database.
7
+ */
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import crypto from 'node:crypto';
12
+ import { brainDir, agentDir } from './config.mjs';
13
+
14
+ function getBrainDir() {
15
+ // Support test overrides: if _TR_TEST_AGENT_DIR is set, derive brainDir from it
16
+ const testDir = process.env._TR_TEST_AGENT_DIR;
17
+ if (testDir && testDir !== agentDir) {
18
+ return path.join(testDir, 'skills', 'total-recall');
19
+ }
20
+ return brainDir;
21
+ }
22
+
23
+ function inboxDir() {
24
+ return path.join(getBrainDir(), 'memory-inbox', 'capture');
25
+ }
26
+
27
+ // ─── Heuristic classifier ────────────────────────────────────────────────────────
28
+
29
+ const TASK_PATTERNS = [/\bTODO\b/i, /\bremind me\b/i, /\btask:/i, /^(do|fix|add|remove|update|create)\b/i];
30
+ const QUESTION_PATTERNS = [/\?$/, /\bhow\b/i, /\bwhat\b/i, /\bwhy\b/i, /\bwhen\b/i];
31
+
32
+ function classifyMessage(text) {
33
+ if (TASK_PATTERNS.some(p => p.test(text))) return 'task';
34
+ if (QUESTION_PATTERNS.some(p => p.test(text))) return 'observation';
35
+ return 'observation';
36
+ }
37
+
38
+ // ─── Node draft builder ──────────────────────────────────────────────────────────
39
+
40
+ function buildInboxNode({ text, author, channel, source, ts }) {
41
+ const now = ts || new Date().toISOString();
42
+ const slug = `capture-${source}-${crypto.randomBytes(4).toString('hex')}`;
43
+ const nodeType = classifyMessage(text);
44
+
45
+ return {
46
+ slug,
47
+ type: 'memory',
48
+ category: 'capture',
49
+ title: text.slice(0, 80).replace(/\n/g, ' '),
50
+ status: 'draft',
51
+ confidence: 0.5,
52
+ importance: 2,
53
+ created: now,
54
+ updated: now,
55
+ last_accessed: now,
56
+ source: {
57
+ type: source,
58
+ session_id: slug,
59
+ agent: author || 'unknown',
60
+ evidence_count: 1
61
+ },
62
+ supersedes: [],
63
+ superseded_by: null,
64
+ contradicts: [],
65
+ tags: ['capture', source, channel || 'direct'].filter(Boolean),
66
+ related: [],
67
+ routes_to_skills: [],
68
+ sentiment_polarity: 'descriptive',
69
+ sentiment_target: text.slice(0, 40),
70
+ modality: 'should',
71
+ subject: author || source,
72
+ predicate: nodeType === 'task' ? 'needs_to' : 'observed',
73
+ object: text.slice(0, 120),
74
+ decay: { half_life_days: 30, access_count: 0 },
75
+ schema_version: 2,
76
+ x_memory_layer: 'system1',
77
+ raw_content: text,
78
+ x_temporal_context: now,
79
+ x_citations: [{
80
+ source: source || 'chat',
81
+ title: `Captured message from channel ${channel || 'direct'}`,
82
+ url: `chat://${source}/${channel || 'direct'}`,
83
+ published: now,
84
+ relevance: 1.0,
85
+ accessed: now
86
+ }]
87
+ };
88
+ }
89
+
90
+ // ─── Source validation ───────────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Validate a capture source identifier.
94
+ * Accepts any lowercase alphanumeric kebab-case string up to 50 characters.
95
+ * @param {string} source
96
+ * @returns {boolean}
97
+ */
98
+ export function isValidSource(source) {
99
+ return typeof source === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(source) && source.length <= 50;
100
+ }
101
+
102
+ // ─── Capture entry point ─────────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Write an inbox node from an inbound message.
106
+ *
107
+ * @param {object} opts
108
+ * @param {string} opts.text Message text content
109
+ * @param {string} [opts.author] Author username / user id
110
+ * @param {string} [opts.channel] Channel name or id
111
+ * @param {string} opts.source Kebab-case source identifier (e.g. 'slack', 'chrome-extension', 'google-takeout')
112
+ * @param {string} [opts.ts] ISO timestamp (defaults to now)
113
+ * @returns {{ slug: string, file: string }}
114
+ */
115
+ export function captureMessage({ text, author, channel, source, ts }) {
116
+ if (!text || !text.trim()) throw new Error('text is required and must not be empty');
117
+ if (!isValidSource(source)) throw new Error('source must be a lowercase alphanumeric kebab-case string (max 50 chars)');
118
+
119
+ const node = buildInboxNode({ text: text.trim(), author, channel, source, ts });
120
+
121
+ const dir = inboxDir();
122
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
123
+
124
+ const file = path.join(dir, `${node.slug}.json`);
125
+ fs.writeFileSync(file, JSON.stringify(node, null, 2), 'utf8');
126
+
127
+ return { slug: node.slug, file };
128
+ }
129
+
130
+ /**
131
+ * List inbox capture nodes, most recent first.
132
+ * @returns {object[]}
133
+ */
134
+ export function listCaptureInbox() {
135
+ const dir = inboxDir();
136
+ if (!fs.existsSync(dir)) return [];
137
+ return fs.readdirSync(dir)
138
+ .filter(f => f.endsWith('.json'))
139
+ .map(f => {
140
+ try { return JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); }
141
+ catch { return null; }
142
+ })
143
+ .filter(Boolean)
144
+ .sort((a, b) => b.created.localeCompare(a.created));
145
+ }
@@ -0,0 +1,431 @@
1
+ /**
2
+ * src/core/research-queue.mjs
3
+ *
4
+ * Canonical research queue CRUD. All queue logic lives here.
5
+ * MCP tools and REST endpoints are thin callers.
6
+ *
7
+ * Storage: .agent/research-queue.jsonl (one JSON object per line)
8
+ * Each item: { id, topic, status, priority, notes, node_slug, research_phase, created_at, updated_at, completed_at, summary }
9
+ * Status values: 'pending' | 'in_progress' | 'done' | 'failed'
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import crypto from 'node:crypto';
15
+ import matter from 'gray-matter';
16
+ import { loadNodes, writeNode, deleteNode } from './vault.mjs';
17
+ import { brainDir } from './config.mjs';
18
+
19
+ const getBrainDir = () => process.env._TR_TEST_AGENT_DIR || brainDir;
20
+
21
+ function getQueueFile(overrideBrainDir) {
22
+ const dir = overrideBrainDir || getBrainDir();
23
+ return path.join(dir, 'research-queue.jsonl');
24
+ }
25
+
26
+ const STATUS_RANK = { pending: 0, in_progress: 1, done: 2, failed: 3 };
27
+
28
+ // ─── Summary Compiler ─────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Compile a gorgeous, detailed research project summary from the topic state and vault node.
32
+ */
33
+ export function compileResearchProjectSummary(item, node) {
34
+ if (!node) {
35
+ return {
36
+ id: item.id,
37
+ topic: item.topic,
38
+ status: item.status,
39
+ phase: item.research_phase || 'acquisition',
40
+ last_updated: item.updated_at,
41
+ conclusions: [],
42
+ ongoing_directions: ['Research project is enqueued / in progress in phase: ' + (item.research_phase || 'acquisition')],
43
+ sources_count: 0
44
+ };
45
+ }
46
+
47
+ const body = node.body || '';
48
+ const sections = {};
49
+ let currentHeader = null;
50
+
51
+ // Split body by lines to group into sections
52
+ const lines = body.split('\n');
53
+ for (const line of lines) {
54
+ const headerMatch = line.match(/^##\s+(.+)$/);
55
+ if (headerMatch) {
56
+ currentHeader = headerMatch[1].trim().toLowerCase();
57
+ sections[currentHeader] = [];
58
+ } else if (currentHeader) {
59
+ sections[currentHeader].push(line);
60
+ }
61
+ }
62
+
63
+ // Helper to extract bullet points from a section
64
+ const extractBullets = (sectionNames) => {
65
+ const bullets = [];
66
+ for (const name of sectionNames) {
67
+ const secLines = sections[name];
68
+ if (secLines) {
69
+ for (const line of secLines) {
70
+ const trimmed = line.trim();
71
+ if (trimmed.startsWith('-') || trimmed.startsWith('*')) {
72
+ const cleaned = trimmed.replace(/^[-*]\s+/, '');
73
+ bullets.push(cleaned);
74
+ }
75
+ }
76
+ }
77
+ }
78
+ return bullets;
79
+ };
80
+
81
+ // Find conclusions & key facts
82
+ const keyFacts = extractBullets(['key facts', 'verified facts', 'facts']);
83
+ const cognitiveDelib = extractBullets(['cognitive deliberation', 'insights', 'deliberation']);
84
+ const conclusions = [...keyFacts, ...cognitiveDelib];
85
+
86
+ // Find ongoing directions, gaps, and monitoring
87
+ const furtherResearch = extractBullets(['further research needed', 'gaps', 'tangents']);
88
+ const monitoring = extractBullets(['ongoing monitoring & sources', 'monitored sources', 'monitoring']);
89
+ const expansion = extractBullets(['spawned research expansion avenues', 'expansion avenues', 'expansion']);
90
+ const ongoingDirections = [...furtherResearch, ...monitoring, ...expansion];
91
+
92
+ return {
93
+ id: item.id,
94
+ topic: item.topic,
95
+ status: item.status,
96
+ phase: item.research_phase || 'acquisition',
97
+ node_slug: item.node_slug,
98
+ node_status: node.status,
99
+ last_updated: item.updated_at,
100
+ conclusions: conclusions.length > 0 ? conclusions : ['Initial facts gathered and indexed in the knowledge base.'],
101
+ ongoing_directions: ongoingDirections.length > 0 ? ongoingDirections : ['Next phases: deliberation, layout refinement, ongoing monitoring, and tangential expansion.'],
102
+ sources_count: node.source?.evidence_count || keyFacts.length || 0
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Sync a research project summary node to the SSSS memory vault.
108
+ * Creates/updates a .md node in memory-vault/facts/ named research-project-<id>.md.
109
+ */
110
+ export function syncResearchProjectNode(item) {
111
+ if (!item) return;
112
+ const vaultDir = path.join(getBrainDir(), 'memory-vault');
113
+
114
+ // ONLY create/sync the vault memory node if the project's status is 'done'!
115
+ // If it is pending, in_progress, or failed, we do NOT want it in our memory-vault/facts directory
116
+ // as it does not contain real completed memory yet.
117
+ if (item.status !== 'done') {
118
+ try {
119
+ deleteNode(`research-project-${item.id}`, vaultDir);
120
+ } catch {}
121
+ return;
122
+ }
123
+
124
+ // Build a beautiful markdown body for the summary node
125
+ const lines = [];
126
+ lines.push(`# Research Project Summary: ${item.topic}`);
127
+ lines.push('');
128
+ lines.push(`> [!NOTE]`);
129
+ lines.push(`> **Project Status**: \`${item.status}\` | **Research Phase**: \`${item.research_phase || 'acquisition'}\``);
130
+ lines.push(`> **Priority**: \`${item.priority || 'medium'}\``);
131
+ lines.push(`> **Created**: \`${item.created_at}\` | **Last Updated**: \`${item.updated_at}\``);
132
+ lines.push('');
133
+
134
+ lines.push('## Conclusions');
135
+ if (item.summary?.conclusions && item.summary.conclusions.length > 0) {
136
+ for (const c of item.summary.conclusions) {
137
+ lines.push(`- ${c}`);
138
+ }
139
+ } else {
140
+ lines.push('- No conclusions recorded yet.');
141
+ }
142
+ lines.push('');
143
+
144
+ lines.push('## Ongoing Directions');
145
+ if (item.summary?.ongoing_directions && item.summary.ongoing_directions.length > 0) {
146
+ for (const d of item.summary.ongoing_directions) {
147
+ lines.push(`- ${d}`);
148
+ }
149
+ } else {
150
+ lines.push('- No ongoing directions recorded yet.');
151
+ }
152
+ lines.push('');
153
+
154
+ if (item.node_slug) {
155
+ lines.push('## Full Report');
156
+ lines.push(`- [[${item.node_slug}]]`);
157
+ lines.push('');
158
+ }
159
+
160
+ const body = lines.join('\n');
161
+
162
+ const node = {
163
+ type: 'memory',
164
+ slug: `research-project-${item.id}`,
165
+ category: 'facts',
166
+ title: `Research Project Summary: ${item.topic}`,
167
+ body,
168
+ status: 'active',
169
+ confidence: 1.0,
170
+ importance: 3,
171
+ created: item.created_at,
172
+ updated: item.updated_at,
173
+ last_accessed: new Date().toISOString(),
174
+ source: {
175
+ type: 'research-engine',
176
+ session_id: item.id,
177
+ evidence_count: item.summary?.sources_count || 0
178
+ },
179
+ supersedes: [],
180
+ superseded_by: null,
181
+ contradicts: [],
182
+ tags: ['research-project', 'summary', 'status'],
183
+ related: item.node_slug ? [item.node_slug] : [],
184
+ routes_to_skills: [],
185
+ sentiment_polarity: 'descriptive',
186
+ sentiment_target: 'research-project',
187
+ modality: 'should',
188
+ subject: 'agent',
189
+ predicate: 'tracked_research_project',
190
+ object: 'knowledge_vault',
191
+ decay: {
192
+ half_life_days: 90,
193
+ access_count: 1
194
+ },
195
+ schema_version: 2,
196
+ x_temporal_context: item.updated_at,
197
+ x_citations: [{
198
+ source: 'research-engine',
199
+ title: item.topic,
200
+ url: `research://${item.id}`,
201
+ published: item.created_at,
202
+ relevance: 1.0,
203
+ accessed: new Date().toISOString()
204
+ }]
205
+ };
206
+
207
+ try {
208
+ writeNode(node, vaultDir);
209
+ } catch (e) {
210
+ // Log or handle error gracefully
211
+ }
212
+ }
213
+
214
+ // ─── I/O ───────────────────────────────────────────────────────────────────────
215
+
216
+ export function loadQueue(overrideBrainDir) {
217
+ const queueFile = getQueueFile(overrideBrainDir);
218
+ const effectiveBrainDir = overrideBrainDir || getBrainDir();
219
+ if (!fs.existsSync(queueFile)) return [];
220
+ const items = fs.readFileSync(queueFile, 'utf8')
221
+ .split('\n').filter(Boolean)
222
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
223
+ .filter(Boolean);
224
+
225
+ // Dynamic self-healing: ensure every research item has a summary enqueued
226
+ let changed = false;
227
+ const vaultDir = path.join(effectiveBrainDir, 'memory-vault');
228
+
229
+ for (const item of items) {
230
+ if (!item.summary) {
231
+ let node = null;
232
+ if (item.node_slug) {
233
+ try {
234
+ const nodes = loadNodes(vaultDir);
235
+ node = nodes.find(n => n.slug === item.node_slug);
236
+ if (!node) {
237
+ const inboxFile = path.join(effectiveBrainDir, 'memory-inbox', 'pending', `${item.node_slug}.md`);
238
+ if (fs.existsSync(inboxFile)) {
239
+ const raw = fs.readFileSync(inboxFile, 'utf8');
240
+ const { data, content } = matter(raw);
241
+ node = { ...data, body: content.trim() };
242
+ }
243
+ }
244
+ } catch {}
245
+ }
246
+ item.summary = compileResearchProjectSummary(item, node);
247
+ changed = true;
248
+ }
249
+
250
+ // Dynamic self-healing: ensure a corresponding markdown summary node is in the vault
251
+ if (item.status === 'done') {
252
+ const summaryPath = path.join(vaultDir, 'facts', `research-project-${item.id}.md`);
253
+ if (!fs.existsSync(summaryPath)) {
254
+ try {
255
+ syncResearchProjectNode(item);
256
+ } catch {}
257
+ }
258
+ } else {
259
+ try {
260
+ deleteNode(`research-project-${item.id}`, vaultDir);
261
+ } catch {}
262
+ }
263
+ }
264
+
265
+ if (changed) {
266
+ try {
267
+ saveQueue(items, overrideBrainDir);
268
+ } catch {}
269
+ }
270
+
271
+ return items;
272
+ }
273
+
274
+ export function saveQueue(items, overrideBrainDir) {
275
+ const queueFile = getQueueFile(overrideBrainDir);
276
+ fs.mkdirSync(path.dirname(queueFile), { recursive: true });
277
+ fs.writeFileSync(queueFile, items.map(i => JSON.stringify(i)).join('\n') + '\n', 'utf8');
278
+ }
279
+
280
+ // ─── CRUD ──────────────────────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * List all research projects, optionally filtered by status or search query.
284
+ * Sorted: pending β†’ in_progress β†’ done β†’ failed, then by updated_at desc.
285
+ *
286
+ * @param {{ status?: string, query?: string, limit?: number, offset?: number }} [opts]
287
+ * @returns {{ counts: object, total: number, items: object[] }}
288
+ */
289
+ export function listQueue({ status, query, limit = 100, offset = 0, brainDir: overrideBrainDir } = {}) {
290
+ const all = loadQueue(overrideBrainDir);
291
+ const counts = { pending: 0, in_progress: 0, done: 0, failed: 0 };
292
+ all.forEach(i => { if (counts[i.status] !== undefined) counts[i.status]++; });
293
+
294
+ let items = status && status !== 'all' ? all.filter(i => i.status === status) : [...all];
295
+
296
+ if (query) {
297
+ const q = String(query).toLowerCase();
298
+ items = items.filter(i =>
299
+ (i.topic && i.topic.toLowerCase().includes(q)) ||
300
+ (i.notes && i.notes.toLowerCase().includes(q))
301
+ );
302
+ }
303
+
304
+ items.sort((a, b) => {
305
+ const ra = STATUS_RANK[a.status] ?? 4, rb = STATUS_RANK[b.status] ?? 4;
306
+ if (ra !== rb) return ra - rb;
307
+ return (b.updated_at || b.created_at).localeCompare(a.updated_at || a.created_at);
308
+ });
309
+
310
+ const lim = Math.min(500, Number(limit) || 100);
311
+ const off = Number(offset) || 0;
312
+ return { counts, total: items.length, items: items.slice(off, off + lim) };
313
+ }
314
+
315
+ /**
316
+ * Add a new research topic (status: pending).
317
+ *
318
+ * @param {{ topic: string, priority?: string, notes?: string }} opts
319
+ * @returns {object} The created item
320
+ */
321
+ export function addToQueue({ topic, priority = 'medium', notes, brainDir: overrideBrainDir } = {}) {
322
+ if (!topic) throw new Error('topic is required');
323
+ const item = {
324
+ id: crypto.randomUUID(),
325
+ topic: String(topic),
326
+ status: 'pending',
327
+ priority: priority || 'medium',
328
+ notes: notes || null,
329
+ node_slug: null,
330
+ research_phase: 'acquisition',
331
+ created_at: new Date().toISOString(),
332
+ updated_at: new Date().toISOString(),
333
+ completed_at: null,
334
+ };
335
+ item.summary = compileResearchProjectSummary(item, null);
336
+ const items = loadQueue(overrideBrainDir);
337
+ items.unshift(item);
338
+ saveQueue(items, overrideBrainDir);
339
+
340
+ // Sync initial pending project summary node to the vault
341
+ try {
342
+ syncResearchProjectNode(item);
343
+ } catch {}
344
+
345
+ return item;
346
+ }
347
+
348
+ /**
349
+ * Update an existing item by id.
350
+ *
351
+ * @param {string} id
352
+ * @param {{ status?: string, notes?: string, node_slug?: string, priority?: string, research_phase?: string }} patch
353
+ * @returns {object} The updated item
354
+ */
355
+ export function updateQueueItem(id, patch = {}, overrideBrainDir) {
356
+ const items = loadQueue(overrideBrainDir);
357
+ const idx = items.findIndex(i => i.id === id);
358
+ if (idx === -1) throw Object.assign(new Error(`Research project not found: ${id}`), { status: 404 });
359
+
360
+ const item = { ...items[idx] };
361
+ if (patch.status !== undefined) {
362
+ item.status = patch.status;
363
+ if (patch.status === 'pending') {
364
+ item.completed_at = null;
365
+ }
366
+ }
367
+ if (patch.notes !== undefined) item.notes = patch.notes;
368
+ if (patch.node_slug !== undefined) item.node_slug = patch.node_slug;
369
+ if (patch.priority !== undefined) item.priority = patch.priority;
370
+ if (patch.research_phase !== undefined) item.research_phase = patch.research_phase;
371
+
372
+ item.updated_at = new Date().toISOString();
373
+ if ((item.status === 'done' || item.status === 'failed') && !item.completed_at) {
374
+ item.completed_at = new Date().toISOString();
375
+ }
376
+
377
+ // Load the latest node (from vault or inbox pending) to compile summary
378
+ const localBrainDir = overrideBrainDir || getBrainDir();
379
+ const vaultDir = path.join(localBrainDir, 'memory-vault');
380
+ let node = null;
381
+ if (item.node_slug) {
382
+ try {
383
+ const nodes = loadNodes(vaultDir);
384
+ node = nodes.find(n => n.slug === item.node_slug);
385
+
386
+ if (!node) {
387
+ const inboxFile = path.join(localBrainDir, 'memory-inbox', 'pending', `${item.node_slug}.md`);
388
+ if (fs.existsSync(inboxFile)) {
389
+ const raw = fs.readFileSync(inboxFile, 'utf8');
390
+ const { data, content } = matter(raw);
391
+ node = { ...data, body: content.trim() };
392
+ }
393
+ }
394
+ } catch (e) {
395
+ // Ignore non-fatal vault lookup error
396
+ }
397
+ }
398
+ item.summary = compileResearchProjectSummary(item, node);
399
+
400
+ items[idx] = item;
401
+ saveQueue(items, overrideBrainDir);
402
+
403
+ // Sync updated research project summary node to the vault
404
+ try {
405
+ syncResearchProjectNode(item);
406
+ } catch {}
407
+
408
+ return item;
409
+ }
410
+
411
+ /**
412
+ * Remove a research project by id.
413
+ *
414
+ * @param {string} id
415
+ * @returns {{ deleted: boolean, id: string, topic: string }}
416
+ */
417
+ export function removeFromQueue(id, overrideBrainDir) {
418
+ const items = loadQueue(overrideBrainDir);
419
+ const idx = items.findIndex(i => i.id === id);
420
+ if (idx === -1) throw Object.assign(new Error(`Research project not found: ${id}`), { status: 404 });
421
+ const [removed] = items.splice(idx, 1);
422
+ saveQueue(items, overrideBrainDir);
423
+
424
+ // Delete the corresponding summary node from the vault
425
+ try {
426
+ const vaultDir = path.join(overrideBrainDir || getBrainDir(), 'memory-vault');
427
+ deleteNode(`research-project-${id}`, vaultDir);
428
+ } catch {}
429
+
430
+ return { deleted: true, id: removed.id, topic: removed.topic };
431
+ }