total-recall-brain 3.28.2 → 3.30.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 (132) hide show
  1. package/bin/total-recall.mjs +24 -1
  2. package/docs/ARCHITECTURE.md +26 -18
  3. package/extension/background.js +259 -84
  4. package/extension/content-script.css +7 -13
  5. package/extension/content-script.js +252 -477
  6. package/extension/icons/icon-128.png +0 -0
  7. package/extension/icons/icon-16.png +0 -0
  8. package/extension/icons/icon-32.png +0 -0
  9. package/extension/icons/icon-48.png +0 -0
  10. package/extension/icons/mark.svg +6 -0
  11. package/extension/lib/brain-client.js +160 -73
  12. package/extension/lib/tokens.css +187 -0
  13. package/extension/lib/ui-helpers.js +229 -0
  14. package/extension/lib/ui-helpers.spec.mjs +94 -0
  15. package/extension/manifest.json +20 -5
  16. package/extension/options/options.css +64 -218
  17. package/extension/options/options.html +91 -43
  18. package/extension/options/options.js +208 -85
  19. package/extension/sidepanel/sidepanel.css +268 -970
  20. package/extension/sidepanel/sidepanel.html +200 -166
  21. package/extension/sidepanel/sidepanel.js +788 -942
  22. package/frontend/dist/assets/index-DGMNZi_1.js +5742 -0
  23. package/frontend/dist/index.html +1 -1
  24. package/frontend/src/api/plugins.ts +124 -92
  25. package/frontend/src/components/ResearchAgendaTab.spec.tsx +11 -0
  26. package/frontend/src/components/ResearchAgendaTab.tsx +19 -14
  27. package/frontend/src/pages/PluginsPage.spec.tsx +87 -16
  28. package/frontend/src/pages/PluginsPage.tsx +454 -1218
  29. package/package.json +2 -1
  30. package/plugins/code-quality/README.md +25 -0
  31. package/plugins/code-quality/cli.mjs +25 -0
  32. package/plugins/code-quality/plugin.json +17 -0
  33. package/plugins/git-sentinel/README.md +27 -0
  34. package/{scaffold/.agent/plugins → plugins}/git-sentinel/cli.mjs +8 -5
  35. package/plugins/git-sentinel/plugin.json +18 -0
  36. package/plugins/system-monitor/README.md +26 -0
  37. package/{scaffold/.agent/plugins → plugins}/system-monitor/cli.mjs +5 -4
  38. package/{scaffold/.agent/plugins → plugins}/system-monitor/generator.mjs +4 -5
  39. package/plugins/system-monitor/plugin.json +29 -0
  40. package/scaffold/.agent/skills/meta-harness/SKILL.md +4 -4
  41. package/scaffold/.agent/skills/plugins/SKILL.md +53 -0
  42. package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
  43. package/src/cli/agent.mjs +1 -1
  44. package/src/cli/app/index.mjs +493 -0
  45. package/src/cli/brain.mjs +33 -3
  46. package/src/cli/command.mjs +80 -41
  47. package/src/cli/harness.mjs +1 -1
  48. package/src/cli/init.mjs +3 -1
  49. package/src/cli/mesh.mjs +118 -12
  50. package/src/cli/plugin/create.mjs +144 -25
  51. package/src/cli/plugin/index.mjs +83 -53
  52. package/src/cli/plugin/install.mjs +10 -127
  53. package/src/cli/plugin/list.mjs +44 -44
  54. package/src/cli/plugin/remove.mjs +23 -30
  55. package/src/cli/plugin/scaffold-cmd.mjs +30 -0
  56. package/src/cli/plugin/search.mjs +108 -23
  57. package/src/cli/rebuild.mjs +37 -1
  58. package/src/cli/recall.mjs +17 -2
  59. package/src/cli/research.mjs +4 -6
  60. package/src/cli/share.mjs +2 -0
  61. package/src/core/app-deploy/apply.mjs +386 -0
  62. package/src/core/app-deploy/design-tokens.mjs +217 -0
  63. package/src/core/app-deploy/plan.mjs +249 -0
  64. package/src/core/app-deploy/resolve.mjs +295 -0
  65. package/src/core/app-deploy/source.mjs +337 -0
  66. package/src/core/app-deploy/standalone.mjs +248 -0
  67. package/src/core/app-deploy/upgrade.mjs +256 -0
  68. package/src/core/app-deploy/verify.mjs +213 -0
  69. package/src/core/bound-hosts.mjs +1 -1
  70. package/src/core/brain-registry.mjs +146 -0
  71. package/src/core/brain-state.json +1 -0
  72. package/src/core/daemon-loop.mjs +30 -26
  73. package/src/core/dream.mjs +5 -17
  74. package/src/core/fact-seeker.mjs +53 -368
  75. package/src/core/lan-discovery.mjs +3 -3
  76. package/src/core/mesh-access-sync.mjs +134 -0
  77. package/src/core/mesh-access.mjs +18 -3
  78. package/src/core/mesh-discover.mjs +173 -0
  79. package/src/core/mesh.mjs +131 -32
  80. package/src/core/optimizer.mjs +4 -41
  81. package/src/core/plugin-bundle.mjs +167 -0
  82. package/src/core/plugin-context.mjs +8 -13
  83. package/src/core/plugin-loader.mjs +272 -68
  84. package/src/core/plugin-peers.mjs +163 -0
  85. package/src/core/plugin-public.mjs +85 -0
  86. package/src/core/plugin-runner-child.mjs +24 -0
  87. package/src/core/plugin-runner.mjs +102 -0
  88. package/src/core/plugin-store.mjs +384 -0
  89. package/src/core/plugin-tasks.mjs +212 -0
  90. package/src/core/post-mortem.mjs +40 -9
  91. package/src/core/research-gate.mjs +182 -0
  92. package/src/core/research-queue.mjs +47 -7
  93. package/src/core/research-surface.mjs +211 -0
  94. package/src/core/research.mjs +25 -32
  95. package/src/core/scheduler.mjs +67 -153
  96. package/src/core/schema.mjs +129 -0
  97. package/src/core/search.mjs +12 -1
  98. package/src/core/secret-integration-research.mjs +12 -8
  99. package/src/core/secrets-store.mjs +32 -2
  100. package/src/core/session-watcher.mjs +6 -0
  101. package/src/core/surface.mjs +64 -3
  102. package/src/core/task-executors.mjs +8 -45
  103. package/src/core/vault-cache.mjs +70 -4
  104. package/src/server/api.mjs +21 -0
  105. package/src/server/rest.mjs +2 -0
  106. package/src/server/route-manifest.json +21 -5
  107. package/src/server/routes/collab.mjs +38 -11
  108. package/src/server/routes/extension.mjs +3 -2
  109. package/src/server/routes/memory.mjs +24 -2
  110. package/src/server/routes/mesh.mjs +7 -7
  111. package/src/server/routes/plugins-mesh.mjs +49 -0
  112. package/src/server/routes/plugins.mjs +91 -426
  113. package/src/server/routes/research.mjs +5 -2
  114. package/src/server/routes/share.mjs +2 -0
  115. package/src/server/tools.mjs +29 -2
  116. package/extension/icons/README.md +0 -27
  117. package/extension/lib/preconfigured.js +0 -5
  118. package/extension/popup/popup.css +0 -227
  119. package/extension/popup/popup.html +0 -50
  120. package/extension/popup/popup.js +0 -175
  121. package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
  122. package/scaffold/.agent/plugins/.gitkeep +0 -0
  123. package/scaffold/.agent/plugins/code-quality/README.md +0 -16
  124. package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
  125. package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
  126. package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
  127. package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
  128. package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
  129. package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
  130. package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
  131. package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
  132. package/scaffold/.agent/plugins/system-monitor/plugin.json +0 -38
@@ -0,0 +1,182 @@
1
+ /**
2
+ * src/core/research-gate.mjs
3
+ *
4
+ * The only doors into the research queue.
5
+ *
6
+ * Research has exactly two reasons to run (RESEARCH_SYSTEM2):
7
+ * 1. A human asked — in chat, from the browser extension, the dashboard, the
8
+ * CLI or any API client. `requestResearch` records that and skips budgets.
9
+ * 2. While working on a project, the AI found a knowledge gap that would make
10
+ * it better at that project. `proposeAutonomousResearch` admits such gaps
11
+ * only within budget and only when existing research doesn't cover them.
12
+ *
13
+ * Nothing inside the research pipeline calls either: research never spawns
14
+ * research. That rule is what stops the fan-out that filled the queue with 89
15
+ * self-generated tangents in two days.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import yaml from 'yaml';
21
+ import { addToQueue, loadQueue, normalizeTopic, RESEARCH_VIAS } from './research-queue.mjs';
22
+ import { brainDir as defaultBrainDir } from './config.mjs';
23
+ import { logger } from './logger.mjs';
24
+
25
+ export const AUTONOMOUS_DEFAULTS = Object.freeze({
26
+ enabled: true,
27
+ maxPendingPerProject: 3,
28
+ maxPerDay: 6,
29
+ maxPerSession: 2,
30
+ // Existing research at or above this cosine similarity already answers the gap.
31
+ coverageSimilarity: 0.72,
32
+ });
33
+
34
+ const DAY_MS = 24 * 60 * 60 * 1000;
35
+ const OPEN_STATUSES = new Set(['pending', 'in_progress']);
36
+
37
+ /**
38
+ * Merge `autonomous:` from config/research.yml over the defaults.
39
+ * @param {object} [fileConfig] parsed research.yml
40
+ */
41
+ export function resolveAutonomousConfig(fileConfig = {}) {
42
+ const raw = (fileConfig && fileConfig.autonomous) || {};
43
+ const num = (v, d) => (Number.isFinite(Number(v)) && Number(v) >= 0 ? Number(v) : d);
44
+ return {
45
+ enabled: raw.enabled === undefined ? AUTONOMOUS_DEFAULTS.enabled : raw.enabled !== false,
46
+ maxPendingPerProject: num(raw.max_pending_per_project, AUTONOMOUS_DEFAULTS.maxPendingPerProject),
47
+ maxPerDay: num(raw.max_per_day, AUTONOMOUS_DEFAULTS.maxPerDay),
48
+ maxPerSession: num(raw.max_per_session, AUTONOMOUS_DEFAULTS.maxPerSession),
49
+ coverageSimilarity: num(raw.coverage_similarity, AUTONOMOUS_DEFAULTS.coverageSimilarity),
50
+ };
51
+ }
52
+
53
+ export function loadAutonomousConfig(brainDir = defaultBrainDir) {
54
+ const file = path.join(brainDir, 'config', 'research.yml');
55
+ try {
56
+ return resolveAutonomousConfig(fs.existsSync(file) ? yaml.parse(fs.readFileSync(file, 'utf8')) || {} : {});
57
+ } catch {
58
+ return resolveAutonomousConfig({});
59
+ }
60
+ }
61
+
62
+ function assertVia(via) {
63
+ if (!RESEARCH_VIAS.includes(via)) throw new Error(`invalid research via: ${via}`);
64
+ }
65
+
66
+ /**
67
+ * A human asked for research. No budgets, no coverage check: they asked.
68
+ *
69
+ * @param {{ topic: string, notes?: string, via: string, priority?: string, project?: string|null, brainDir?: string }} req
70
+ */
71
+ export function requestResearch({ topic, notes, via, priority = 'high', project = null, brainDir } = {}) {
72
+ const clean = String(topic || '').trim();
73
+ if (!clean) throw new Error('topic is required');
74
+ assertVia(via);
75
+ return addToQueue({ topic: clean, notes, priority, origin: 'user', requested_via: via, project, brainDir });
76
+ }
77
+
78
+ /**
79
+ * Default coverage check: the best cosine similarity between the gap and any
80
+ * research-layer node in the vault. Returns 0 when search is unavailable, so a
81
+ * broken embedding provider can only make the gate *more* permissive by the
82
+ * budget, never unbounded.
83
+ */
84
+ export async function defaultCoverageCheck(topic, { vaultDir, derivedDir } = {}) {
85
+ try {
86
+ const { semanticSearch } = await import('./search.mjs');
87
+ const { inferMemoryLayer } = await import('./memory-layers.mjs');
88
+ const results = await semanticSearch(topic, { vaultDir, derivedDir, top_k: 5, includeSessions: false });
89
+ let best = 0;
90
+ for (const r of results) {
91
+ const isResearch = inferMemoryLayer(r) === 'research' || (Array.isArray(r.tags) && r.tags.includes('research'));
92
+ if (isResearch && typeof r.similarity === 'number' && r.similarity > best) best = r.similarity;
93
+ }
94
+ return best;
95
+ } catch {
96
+ return 0;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * The AI, while working on `project`, thinks these gaps are worth researching.
102
+ * Admit at most what the budgets allow, skipping anything already covered.
103
+ *
104
+ * @param {Array<{topic: string, rationale?: string, priority?: number|string}>} candidates
105
+ * @param {{ project: string|null, sessionId?: string|null, via?: 'session'|'secret',
106
+ * config?: object, now?: number, brainDir?: string,
107
+ * coverageCheck?: (topic: string) => Promise<number> }} ctx
108
+ * @returns {Promise<{ queued: object[], skipped: Array<{topic: string, reason: string}> }>}
109
+ */
110
+ export async function proposeAutonomousResearch(candidates, {
111
+ project = null,
112
+ sessionId = null,
113
+ via = 'session',
114
+ config,
115
+ now = Date.now(),
116
+ brainDir,
117
+ coverageCheck,
118
+ } = {}) {
119
+ assertVia(via);
120
+ const cfg = config || loadAutonomousConfig(brainDir);
121
+ const queued = [];
122
+ const skipped = [];
123
+ const list = (Array.isArray(candidates) ? candidates : [])
124
+ .map((c) => ({ ...c, topic: String((c && c.topic) || '').trim() }))
125
+ .filter((c) => c.topic);
126
+
127
+ if (!cfg.enabled) {
128
+ return { queued, skipped: list.map((c) => ({ topic: c.topic, reason: 'disabled' })) };
129
+ }
130
+
131
+ const check = coverageCheck || ((topic) => defaultCoverageCheck(topic, {
132
+ vaultDir: path.join(brainDir || defaultBrainDir, 'memory-vault'),
133
+ derivedDir: path.join(brainDir || defaultBrainDir, 'memory-derived'),
134
+ }));
135
+
136
+ const items = loadQueue(brainDir);
137
+ const autonomous = items.filter((i) => i.origin === 'autonomous');
138
+ let pendingForProject = autonomous.filter((i) => OPEN_STATUSES.has(i.status) && (i.project || null) === project).length;
139
+ let today = autonomous.filter((i) => now - Date.parse(i.created_at || 0) < DAY_MS).length;
140
+ let thisSession = sessionId ? autonomous.filter((i) => i.session_id === sessionId).length : 0;
141
+ const known = new Set(items.map((i) => normalizeTopic(i.topic)));
142
+
143
+ for (const c of list) {
144
+ const key = normalizeTopic(c.topic);
145
+ if (known.has(key)) { skipped.push({ topic: c.topic, reason: 'duplicate' }); continue; }
146
+ if (today >= cfg.maxPerDay) { skipped.push({ topic: c.topic, reason: 'budget-day' }); continue; }
147
+ if (pendingForProject >= cfg.maxPendingPerProject) { skipped.push({ topic: c.topic, reason: 'budget-project' }); continue; }
148
+ if (sessionId && thisSession >= cfg.maxPerSession) { skipped.push({ topic: c.topic, reason: 'budget-session' }); continue; }
149
+
150
+ const covered = await check(c.topic);
151
+ if (covered >= cfg.coverageSimilarity) {
152
+ skipped.push({ topic: c.topic, reason: 'covered' });
153
+ continue;
154
+ }
155
+
156
+ const item = addToQueue({
157
+ topic: c.topic,
158
+ priority: typeof c.priority === 'string' ? c.priority : (Number(c.priority) >= 70 ? 'high' : 'medium'),
159
+ notes: c.rationale || null,
160
+ origin: 'autonomous',
161
+ requested_via: via,
162
+ project,
163
+ rationale: c.rationale || null,
164
+ session_id: sessionId,
165
+ brainDir,
166
+ });
167
+ queued.push(item);
168
+ known.add(key);
169
+ today++;
170
+ pendingForProject++;
171
+ thisSession++;
172
+ }
173
+
174
+ if (queued.length || skipped.length) {
175
+ logger.info({
176
+ subsystem: 'research-gate',
177
+ message: `Autonomous research for ${project || 'no project'}: queued ${queued.length}, skipped ${skipped.length}`
178
+ + (skipped.length ? ` (${skipped.map((s) => s.reason).join(', ')})` : ''),
179
+ });
180
+ }
181
+ return { queued, skipped };
182
+ }
@@ -5,8 +5,13 @@
5
5
  * MCP tools and REST endpoints are thin callers.
6
6
  *
7
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 }
8
+ * Each item: { id, topic, status, priority, notes, node_slug, research_phase, created_at, updated_at, completed_at, summary,
9
+ * origin, requested_via, project, rationale, session_id, attempts }
9
10
  * Status values: 'pending' | 'in_progress' | 'done' | 'failed'
11
+ * Phases: 'acquisition' → 'deliberation' → 'improvement' → done (scheduler.mjs RESEARCH_PHASES)
12
+ * Provenance: origin 'user' (a human asked) | 'autonomous' (the project gate approved a
13
+ * knowledge gap; see research-gate.mjs) | 'legacy' (queued before provenance existed;
14
+ * set only by scripts/migrate-research-system2.mjs — never pinned into instructions).
10
15
  */
11
16
 
12
17
  import fs from 'node:fs';
@@ -339,19 +344,46 @@ export function listQueue({ status, query, limit = 100, offset = 0, brainDir: ov
339
344
  return { counts, total: items.length, items: items.slice(off, off + lim) };
340
345
  }
341
346
 
347
+ export const RESEARCH_ORIGINS = Object.freeze(['user', 'autonomous']);
348
+ export const RESEARCH_VIAS = Object.freeze(['chat', 'extension', 'dashboard', 'cli', 'api', 'share', 'session', 'secret']);
349
+
350
+ export function normalizeTopic(topic) {
351
+ return String(topic || '').trim().toLowerCase().replace(/\s+/g, ' ');
352
+ }
353
+
342
354
  /**
343
355
  * Add a new research topic (status: pending).
344
356
  *
345
- * @param {{ topic: string, priority?: string, notes?: string }} opts
346
- * @returns {object} The created item
357
+ * Prefer research-gate.mjs (`requestResearch` / `proposeAutonomousResearch`),
358
+ * which applies provenance and the autonomous budgets; this is the raw store.
359
+ *
360
+ * @param {{ topic: string, priority?: string, notes?: string, origin?: 'user'|'autonomous',
361
+ * requested_via?: string, project?: string|null, rationale?: string|null,
362
+ * session_id?: string|null, brainDir?: string }} opts
363
+ * @returns {object} The created item (or the existing one for a duplicate topic)
347
364
  */
348
- export function addToQueue({ topic, priority = 'medium', notes, brainDir: overrideBrainDir } = {}) {
365
+ export function addToQueue({
366
+ topic, priority = 'medium', notes, brainDir: overrideBrainDir,
367
+ origin = 'user', requested_via = 'api', project = null, rationale = null, session_id = null,
368
+ } = {}) {
349
369
  if (!topic) throw new Error('topic is required');
370
+ if (!RESEARCH_ORIGINS.includes(origin)) throw new Error(`invalid research origin: ${origin}`);
350
371
 
351
372
  const items = loadQueue(overrideBrainDir);
352
- const normalizedTopic = String(topic).trim().toLowerCase();
353
- const existing = items.find(i => i.topic.trim().toLowerCase() === normalizedTopic);
354
- if (existing) {
373
+ const normalizedTopic = normalizeTopic(topic);
374
+ const existingIdx = items.findIndex(i => normalizeTopic(i.topic) === normalizedTopic);
375
+ if (existingIdx !== -1) {
376
+ const existing = items[existingIdx];
377
+ // A human asking for something the AI queued on its own makes it theirs:
378
+ // it then runs ahead of autonomous work.
379
+ if (origin === 'user' && existing.origin !== 'user' && existing.status !== 'done') {
380
+ items[existingIdx] = {
381
+ ...existing, origin: 'user', requested_via, priority: priority || existing.priority,
382
+ updated_at: new Date().toISOString(),
383
+ };
384
+ saveQueue(items, overrideBrainDir);
385
+ return items[existingIdx];
386
+ }
355
387
  return existing;
356
388
  }
357
389
 
@@ -366,6 +398,12 @@ export function addToQueue({ topic, priority = 'medium', notes, brainDir: overri
366
398
  created_at: new Date().toISOString(),
367
399
  updated_at: new Date().toISOString(),
368
400
  completed_at: null,
401
+ origin,
402
+ requested_via: requested_via || null,
403
+ project: project || null,
404
+ rationale: rationale || null,
405
+ session_id: session_id || null,
406
+ attempts: 0,
369
407
  };
370
408
  item.summary = compileResearchProjectSummary(item, null);
371
409
  items.unshift(item);
@@ -400,6 +438,8 @@ export function updateQueueItem(id, patch = {}, overrideBrainDir) {
400
438
  if (patch.node_slug !== undefined) item.node_slug = patch.node_slug;
401
439
  if (patch.priority !== undefined) item.priority = patch.priority;
402
440
  if (patch.research_phase !== undefined) item.research_phase = patch.research_phase;
441
+ if (patch.attempts !== undefined) item.attempts = Number(patch.attempts) || 0;
442
+ if (patch.completed_at !== undefined) item.completed_at = patch.completed_at;
403
443
 
404
444
  item.updated_at = new Date().toISOString();
405
445
  if ((item.status === 'done' || item.status === 'failed') && !item.completed_at) {
@@ -0,0 +1,211 @@
1
+ /**
2
+ * src/core/research-surface.mjs
3
+ *
4
+ * System 2 → System 1: bring finished background research into the compiled
5
+ * instructions so the agent can use it mid-conversation instead of only when
6
+ * someone happens to `recall` the right words.
7
+ *
8
+ * Selection is deliberate and small: research finished for the current project,
9
+ * plus research a human asked for recently. A report only qualifies if it holds
10
+ * an actual synthesis — the appendix of raw search snippets, or a synthesizer
11
+ * that answered about itself instead of the topic, is not knowledge.
12
+ */
13
+
14
+ const APPENDIX_RE = /^#{1,3}\s*appendix\b/im;
15
+ const USER_RECENT_DAYS = 30;
16
+
17
+ // A synthesizer that answered about its prompt instead of the topic. All seen in
18
+ // the live vault (2026-09-22): the synthesis agent treated the research prompt as
19
+ // a prompt injection, questioned whether it was test content, or explained why
20
+ // it would not cite the snippets. None of that is knowledge.
21
+ const META_RESPONSE_RE = new RegExp([
22
+ String.raw`my actual (?:cutoff|role)`,
23
+ String.raw`training (?:data )?cutoff`,
24
+ String.raw`\bpersona\b`,
25
+ String.raw`scratchpad`,
26
+ String.raw`prompt[- ]injection`,
27
+ String.raw`system_instructions`,
28
+ String.raw`deep research synthesizer`,
29
+ String.raw`i'?m claude\b|as (?:your )?claude code|working in this repo`,
30
+ String.raw`is this (?:test content|related to work)`,
31
+ String.raw`(?:genuine|real) (?:research summary|citations|info)`,
32
+ String.raw`without fabricating`,
33
+ String.raw`i (?:have no record|don'?t have (?:real-time )?(?:web )?(?:search|access))`,
34
+ String.raw`i can(?:no|')t (?:actually |help|synthesize|verify|produce|do that)`,
35
+ String.raw`(?:retrieved|provided|\[searx\]) (?:docs|results|snippets) (?:are|contain|don'?t|do not)`,
36
+ String.raw`if you want (?:a |me to |real |genuine )`,
37
+ ].join('|'), 'i');
38
+
39
+ /** The report's synthesis: body after the H1, before the raw-sources appendix. */
40
+ export function extractSynthesis(body) {
41
+ const text = String(body || '');
42
+ const cut = text.search(APPENDIX_RE);
43
+ const head = cut === -1 ? text : text.slice(0, cut);
44
+ return head
45
+ .replace(/^#\s+.*$/m, '')
46
+ // Obsidian-style callout banners ("> [!NOTE] Temporal Context: …") are chrome.
47
+ .replace(/^>\s*\[![A-Z]+\][^\n]*(?:\n>[^\n]*)*/gm, '')
48
+ .trim();
49
+ }
50
+
51
+ /** True when the synthesis is real research rather than empty or meta-commentary. */
52
+ export function isUsableSynthesis(synthesis) {
53
+ const s = String(synthesis || '').trim();
54
+ if (s.length < 160) return false;
55
+ return !META_RESPONSE_RE.test(s.slice(0, 2000));
56
+ }
57
+
58
+ function cleanLine(line) {
59
+ return line
60
+ .replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '')
61
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
62
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
63
+ .replace(/`([^`]+)`/g, '$1')
64
+ .replace(/\s+/g, ' ')
65
+ .trim();
66
+ }
67
+
68
+ function clip(text, max) {
69
+ if (text.length <= max) return text;
70
+ const cut = text.slice(0, max);
71
+ const stop = cut.lastIndexOf('. ');
72
+ return stop > max * 0.5 ? cut.slice(0, stop + 1) : `${cut.slice(0, cut.lastIndexOf(' '))}…`;
73
+ }
74
+
75
+ /**
76
+ * 2–4 findings: prefer bullets under a findings/summary/bottom-line heading, then
77
+ * any bullets, then the opening sentences.
78
+ */
79
+ export function extractFindings(synthesis, max = 4) {
80
+ const lines = String(synthesis || '').split('\n');
81
+ const bullets = [];
82
+ let inPreferred = false;
83
+ const preferred = [];
84
+ for (const line of lines) {
85
+ const heading = line.match(/^#{2,4}\s+(.*)$/);
86
+ if (heading) {
87
+ inPreferred = /finding|summary|bottom line|takeaway|conclusion|tl;?dr|key/i.test(heading[1]);
88
+ continue;
89
+ }
90
+ if (/^\s*(?:[-*+]|\d+[.)])\s+\S/.test(line)) {
91
+ const clean = cleanLine(line);
92
+ if (clean.length < 12) continue;
93
+ (inPreferred ? preferred : bullets).push(clean);
94
+ }
95
+ }
96
+ let picked = preferred.length ? preferred : bullets;
97
+ if (!picked.length) {
98
+ const prose = lines.filter((l) => l.trim() && !/^#/.test(l)).map(cleanLine).join(' ');
99
+ picked = prose.split(/(?<=[.!?])\s+(?=[A-Z])/).filter((s) => s.length > 20);
100
+ }
101
+ return picked.slice(0, max).map((f) => clip(f, 220));
102
+ }
103
+
104
+ /**
105
+ * @param {{ queueItems: object[], nodes: object[], project?: string|null, now?: number, limit?: number }} opts
106
+ * @returns {Array<{ slug, title, project, origin, completedAt, findings: string[] }>}
107
+ */
108
+ export function selectResearchBriefs({ queueItems = [], nodes = [], project = null, now = Date.now(), limit = 6 } = {}) {
109
+ const bySlug = new Map(nodes.map((n) => [n.slug, n]));
110
+ const recentCutoff = now - USER_RECENT_DAYS * 24 * 60 * 60 * 1000;
111
+ const projectKey = project ? String(project).toLowerCase() : null;
112
+ const seen = new Set();
113
+ const briefs = [];
114
+
115
+ for (const item of queueItems) {
116
+ if (item.status !== 'done' || !item.node_slug || seen.has(item.node_slug)) continue;
117
+ const forProject = projectKey && String(item.project || '').toLowerCase() === projectKey;
118
+ const completed = Date.parse(item.completed_at || item.updated_at || 0) || 0;
119
+ // Only research a human explicitly asked for follows the user across projects.
120
+ // 'legacy' (pre-provenance) items can't prove that, so they reach chat by
121
+ // relevance but are never pinned into instructions.
122
+ const userRecent = item.origin === 'user' && completed >= recentCutoff;
123
+ if (!forProject && !userRecent) continue;
124
+
125
+ const node = bySlug.get(item.node_slug);
126
+ if (!node || node.status === 'archived' || node.status === 'deprecated') continue;
127
+ const synthesis = extractSynthesis(node.body || node.content);
128
+ if (!isUsableSynthesis(synthesis)) continue;
129
+ const findings = extractFindings(synthesis);
130
+ if (!findings.length) continue;
131
+
132
+ seen.add(item.node_slug);
133
+ briefs.push({
134
+ slug: node.slug,
135
+ title: String(node.title || item.topic).replace(/^Consolidated Research Report:\s*/i, ''),
136
+ project: item.project || null,
137
+ origin: item.origin,
138
+ completedAt: completed,
139
+ forProject: Boolean(forProject),
140
+ findings,
141
+ });
142
+ }
143
+
144
+ briefs.sort((a, b) => (b.forProject - a.forProject) || (b.completedAt - a.completedAt));
145
+ return briefs.slice(0, limit);
146
+ }
147
+
148
+ /** Markdown section for the instruction surface; '' when there is nothing to show. */
149
+ export function formatResearchBriefs(briefs, { maxChars = 2500 } = {}) {
150
+ if (!briefs || !briefs.length) return '';
151
+ const header = `## Background Research (System 2)
152
+
153
+ Research your brain completed in the background. Use these findings instead of guessing about the tools involved; run \`npx total-recall recall "<slug>"\` for the full cited report.
154
+ `;
155
+ let out = header;
156
+ for (const b of briefs) {
157
+ const date = b.completedAt ? new Date(b.completedAt).toISOString().slice(0, 10) : 'undated';
158
+ const why = b.origin === 'autonomous' ? `researched for ${b.project || 'a project'}` : 'you asked';
159
+ const block = `\n**${b.title}** (${date}, ${why}; \`${b.slug}\`)\n${b.findings.map((f) => `- ${f}`).join('\n')}\n`;
160
+ if (out.length + block.length > maxChars) break;
161
+ out += block;
162
+ }
163
+ return out === header ? '' : out.trimEnd();
164
+ }
165
+
166
+ // Question-to-report cosine runs lower than title-to-report. Calibrated on the
167
+ // live vault 2026-09-22 (usable reports only): related questions 0.47–0.70;
168
+ // unrelated ones (React tests, Python refactor, SQL, dinner) matched no usable
169
+ // report at all.
170
+ export const CHAT_RESEARCH_MIN_SIMILARITY = 0.45;
171
+
172
+ /**
173
+ * Research relevant to a chat message: one semantic search (no LLM call), keeping
174
+ * research-layer reports that clear the similarity bar and hold a real synthesis.
175
+ *
176
+ * @param {string} message latest user message
177
+ * @param {{ vaultDir: string, derivedDir: string, limit?: number, search?: Function }} opts
178
+ * @returns {Promise<Array<{ slug, title, similarity, excerpt }>>}
179
+ */
180
+ export async function findRelevantResearch(message, { vaultDir, derivedDir, limit = 3, search } = {}) {
181
+ const query = String(message || '').trim().slice(0, 1000);
182
+ if (query.length < 12) return [];
183
+ const run = search || (async (q, o) => (await import('./search.mjs')).semanticSearch(q, o));
184
+ const { inferMemoryLayer } = await import('./memory-layers.mjs');
185
+ // Wide candidate set: unusable reports and rule nodes must not crowd out the
186
+ // one real report that answers the question.
187
+ const results = await run(query, { vaultDir, derivedDir, top_k: 20, includeSessions: false });
188
+ const out = [];
189
+ for (const r of results || []) {
190
+ if (r.type === 'session' || typeof r.similarity !== 'number' || r.similarity < CHAT_RESEARCH_MIN_SIMILARITY) continue;
191
+ const isResearch = inferMemoryLayer(r) === 'research' || (Array.isArray(r.tags) && r.tags.includes('research'));
192
+ if (!isResearch) continue;
193
+ const synthesis = extractSynthesis(r.body || r.content);
194
+ if (!isUsableSynthesis(synthesis)) continue;
195
+ out.push({
196
+ slug: r.slug,
197
+ title: String(r.title || r.slug).replace(/^Consolidated Research Report:\s*/i, ''),
198
+ similarity: r.similarity,
199
+ excerpt: clip(synthesis.replace(/\n{3,}/g, '\n\n'), 1500),
200
+ });
201
+ if (out.length >= limit) break;
202
+ }
203
+ return out;
204
+ }
205
+
206
+ /** System-prompt block for chat; '' when nothing relevant was found. */
207
+ export function formatChatResearch(found) {
208
+ if (!found || !found.length) return '';
209
+ return `\n\n=== BACKGROUND RESEARCH (System 2) ===\nYour background research already produced findings that bear on this message. Prefer them over guessing, say when you rely on them, and cite the slug.\n`
210
+ + found.map((f) => `\n--- ${f.title} (slug: ${f.slug}, ${Math.round(f.similarity * 100)}% match) ---\n${f.excerpt}`).join('\n');
211
+ }
@@ -87,8 +87,14 @@ export async function handleProactiveResearch(task, context = {}) {
87
87
  logger.info({ subsystem: 'deep-research', message: 'Phase 4: Synthesizing research results...' });
88
88
  const finalReport = await synthesizeLocally(task, flatResults, context.runtimeConfig);
89
89
 
90
- // Save the beautiful synthesized executive summary at the top of our main consolidated document
91
- if (finalReport) {
90
+ // Save the synthesis at the top of the consolidated document — but only if it is
91
+ // research. A synthesizer that answered about its prompt would otherwise become
92
+ // the report's "findings" and later be surfaced into the agent's instructions.
93
+ const { isUsableSynthesis } = await import('./research-surface.mjs');
94
+ const usableReport = finalReport && isUsableSynthesis(finalReport) ? finalReport : null;
95
+ if (finalReport && !usableReport) {
96
+ logger.info({ subsystem: 'deep-research', message: `Discarded unusable synthesis for "${task.target}" (meta-response or too thin); keeping sources only.` });
97
+ } else if (finalReport) {
92
98
  draftSlug = saveSynthesizedReportToDraft(task.target, finalReport, inboxDir) || draftSlug;
93
99
  }
94
100
 
@@ -103,21 +109,12 @@ export async function handleProactiveResearch(task, context = {}) {
103
109
  );
104
110
  }
105
111
 
106
- // Phase 5: Also add topic to the Research Agenda for ongoing tracking
107
- addToAgenda({
108
- topic: task.target,
109
- priority: 75,
110
- source: 'deep-research-task',
111
- rationale: task.body || '',
112
- tags: ['deep-research'],
113
- });
114
-
115
112
  // Always return structured result so executors can advance the research queue with a real node slug
116
113
  if (!draftSlug) {
117
114
  draftSlug = `research-report-${slugify(task.target)}`;
118
115
  }
119
116
  return {
120
- report: finalReport || null,
117
+ report: usableReport,
121
118
  factSlug: draftSlug,
122
119
  sources: flatResults.length,
123
120
  queries,
@@ -589,34 +586,30 @@ export async function promoteDraftToVault(parentTopic, inboxDir, vaultDir) {
589
586
 
590
587
  async function synthesizeLocally(task, results, runtimeConfig) {
591
588
  const today = getLocalizedDateTime();
592
- const cutoff = runtimeConfig?.training_cutoff || 'January 2025';
593
589
 
590
+ // Plain, honest task text. callLocalRuntime hands `system` to CLI agents as part
591
+ // of the *user* turn, so fake <system_instructions> tags, a persona and a
592
+ // mandatory scratchpad read as a prompt injection — and in the live vault 88 of
593
+ // 93 reports were the agent refusing or questioning the prompt instead of
594
+ // researching. Sources carry their URLs so citations are possible.
594
595
  const sourceSummary = results
595
- .map(r => `[${r.source}] ${r.title}: ${r.snippet?.slice(0, 300)}`)
596
+ .map((r, i) => `${i + 1}. ${r.title || 'Untitled'}${r.url ? ` <${r.url}>` : ''} (${r.source})\n ${String(r.snippet || '').slice(0, 300).replace(/\s+/g, ' ')}`)
596
597
  .join('\n');
597
598
 
598
- const system = `<system_instructions>
599
- You are a Deep Research Synthesizer.
600
- - Synthesize research results into a concise report.
601
- - Cite sources inline [Source: URL].
602
- - Prioritize fresh, timely information published after the cutoff.
603
- - ALWAYS use a <scratchpad> block first to outline your synthesis and evaluate source reliability before writing the final report.
604
- </system_instructions>
599
+ const system = `This request comes from Total Recall, the user's own background research daemon, and the search results below were fetched by it for the topic. Today is ${today}.`;
605
600
 
606
- <context>
607
- Today's date and time is ${today}.
608
- The model's training data cutoff is ${cutoff}.
609
- </context>`;
601
+ const prompt = `Summarize what these search results establish about: "${task.target}"
610
602
 
611
- const prompt = `<user_goal>
612
- Topic: "${task.target}"
613
- </user_goal>
603
+ Write Markdown with exactly these sections:
604
+ ## Key findings
605
+ 3–7 bullets. Each is a concrete, specific fact (versions, limits, dates, API names, required steps) supported by the results, ending with its source URL in parentheses.
606
+ ## Caveats
607
+ Bullets on what the results leave uncertain or contradict. Omit the section if there is nothing to say.
614
608
 
615
- <retrieved_docs>
616
- ${sourceSummary.slice(0, 6000)}
617
- </retrieved_docs>
609
+ Use only what the results support; if they are too thin to support a finding, say so in one line under Key findings instead of guessing.
618
610
 
619
- Please synthesize the research results.`;
611
+ Search results:
612
+ ${sourceSummary.slice(0, 6000)}`;
620
613
 
621
614
  try {
622
615
  let report = await callLocalRuntime(prompt, system, runtimeConfig);