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
@@ -190,12 +190,15 @@ import { releaseLease } from './leader-election.mjs';
190
190
  * daemon.log across three separate SIGTERMs. The fallback timer bounds that, so
191
191
  * even a wedged iteration cannot keep the daemon alive indefinitely.
192
192
  */
193
+ let stopPluginTasks = null;
194
+
193
195
  function shutdown(signal) {
194
196
  if (shuttingDown) return;
195
197
  shuttingDown = true;
196
198
  logger.info({ subsystem: 'daemon-loop', message: `${signal} received — shutting down gracefully` });
197
199
  running = false;
198
200
  if (vaultWatcher) vaultWatcher.stop();
201
+ if (stopPluginTasks) stopPluginTasks();
199
202
 
200
203
  const fallback = setTimeout(() => {
201
204
  logger.info({
@@ -365,6 +368,15 @@ async function main() {
365
368
 
366
369
  let emptyTicks = 0;
367
370
 
371
+ // Plugin tasks are per node (a plugin installed here runs here), so they are
372
+ // scheduled on their own timer rather than inside the leader-only task loop.
373
+ try {
374
+ const { startPluginTaskScheduler } = await import('./plugin-tasks.mjs');
375
+ stopPluginTasks = startPluginTaskScheduler();
376
+ } catch (err) {
377
+ logger.warn('daemon-loop', `Plugin task scheduler failed to start: ${err.message}`);
378
+ }
379
+
368
380
  while (running) {
369
381
  try {
370
382
  const leader = await isLeader();
@@ -649,33 +661,25 @@ async function main() {
649
661
  } else if (task._research_phase === 'deliberation') {
650
662
  patch.status = 'pending';
651
663
  patch.research_phase = 'improvement';
652
- } else if (task._research_phase === 'improvement') {
653
- patch.status = 'pending';
654
- patch.research_phase = 'monitoring';
655
- } else if (task._research_phase === 'monitoring') {
656
- patch.status = 'pending';
657
- patch.research_phase = 'expansion';
658
- } else if (task._research_phase === 'expansion') {
659
- // Only mark done when we still have a real node
660
- if (nodeSlug && nodeSlug !== 'pending') {
661
- patch.status = 'done';
662
- patch.node_slug = nodeSlug;
663
- patch.completed_at = new Date().toISOString();
664
- } else {
665
- patch.status = 'failed';
666
- patch.notes = 'Expansion finished without a research memory node.';
667
- }
668
- }
669
- } else {
670
- // Failure on later phase: if no node, reset to acquisition for retry
671
- if (
672
- (!task._node_slug || task._node_slug === 'pending') &&
673
- task._research_phase !== 'acquisition'
674
- ) {
675
- patch.status = 'pending';
676
- patch.research_phase = 'acquisition';
677
- patch.notes = `Reset to acquisition after ${task._research_phase} failure: ${result.error || 'unknown'}`;
664
+ } else {
665
+ // Improvement is the last phase: research ends here and never
666
+ // re-opens or spawns follow-ups (RESEARCH_SYSTEM2).
667
+ patch.status = 'done';
668
+ patch.node_slug = nodeSlug;
669
+ patch.completed_at = new Date().toISOString();
678
670
  }
671
+ } else if (task._research_phase !== 'acquisition' && task._node_slug && task._node_slug !== 'pending') {
672
+ // A refinement phase failed but the report already exists: it is
673
+ // usable as-is, so finish rather than parking it in a retry loop.
674
+ patch.status = 'done';
675
+ patch.node_slug = task._node_slug;
676
+ patch.completed_at = new Date().toISOString();
677
+ patch.notes = `Report kept; ${task._research_phase} skipped after error: ${result.error || 'unknown'}`;
678
+ } else if (task._research_phase !== 'acquisition') {
679
+ // Later phase with no node to refine: start over from acquisition.
680
+ patch.status = 'pending';
681
+ patch.research_phase = 'acquisition';
682
+ patch.notes = `Reset to acquisition after ${task._research_phase} failure: ${result.error || 'unknown'}`;
679
683
  }
680
684
  updateQueueItem(task._research_id, patch);
681
685
  } catch (err) {
@@ -247,17 +247,17 @@ import {
247
247
  generateStaleKnowledgeRefreshProposals,
248
248
  evaluateProposalGate,
249
249
  dedupeProposals,
250
- refreshStaleKnowledge
251
250
  } from './optimizer.mjs';
252
251
  import { applyAcceptedProposals } from './proposal-applier.mjs';
253
252
 
254
253
  const DREAM_PROMOTION_THRESHOLD = 0.7;
255
254
 
256
255
  // Master switch for the stale-knowledge-refresh *ticket generator* (see PHASE 4).
257
- // Disabled 2026-08-01 and superseded: staleness is now handled by
258
- // refreshStaleKnowledge(), which enqueues research the daemon can actually
259
- // perform instead of filing one .md per stale node every cycle (16,401 unread
260
- // tickets at its peak). There is no reason to turn this back on.
256
+ // Disabled 2026-08-01: it filed one .md per stale node every cycle (16,401
257
+ // unread tickets at its peak). Its successor, which queued "Verify
258
+ // still-current" research every cycle, was removed too (RESEARCH_SYSTEM2):
259
+ // research runs only when a human asks or a project needs it. Stale nodes are
260
+ // listed for a human by `total-recall proposals stale` / GET /api/proposals/stale.
261
261
  const ENABLE_STALE_KNOWLEDGE_REFRESH = false;
262
262
 
263
263
  /**
@@ -563,18 +563,6 @@ export async function runDreamCycle({
563
563
  logger.error('dream', `Optimizer failed: ${err.message}`);
564
564
  }
565
565
 
566
- // Staleness is handled here rather than as proposals: the research daemon can
567
- // actually re-verify a stale node and commit the result, whereas a proposal
568
- // could only ask someone to. Rate-limited so the backlog drains over cycles.
569
- try {
570
- const { enqueued, stale } = await refreshStaleKnowledge(vaultDir);
571
- if (stale > 0) {
572
- logger.info('dream', `Staleness sweep: ${stale} stale nodes, ${enqueued.length} queued for research this cycle.`);
573
- }
574
- } catch (err) {
575
- logger.error('dream', `Staleness sweep failed: ${err.message}`);
576
- }
577
-
578
566
  // Write daily note summary (native SSSS node; Obsidian Daily Notes reads it directly)
579
567
  try {
580
568
  const existingNodes = getNodes(vaultDir);
@@ -7,8 +7,6 @@ import { callLocalRuntime, cleanAndParseJSON } from './runtime.mjs';
7
7
  import { writeNode, atomicWrite, safeStringify } from './vault.mjs';
8
8
  import { getNodes } from './vault-cache.mjs';
9
9
  import { logger } from './logger.mjs';
10
- import { addToQueue } from './research-queue.mjs';
11
- import { persistTaskToDisk } from './scheduler.mjs';
12
10
  import {
13
11
  loadResearchConfig,
14
12
  checkSourceAvailability,
@@ -271,48 +269,51 @@ function registerSource(sourceResult, factSlug) {
271
269
 
272
270
  // ─── Topic Inference ─────────────────────────────────────────────────────────────
273
271
 
274
- function TOPIC_INFERENCE_SYSTEM(runtimeConfig) {
272
+ function TOPIC_INFERENCE_SYSTEM(runtimeConfig, project) {
275
273
  const today = getLocalizedDateTime();
276
274
  const cutoff = runtimeConfig?.training_cutoff || 'January 2025';
277
- 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.
275
+ return `You are the background (System 2) research planner for an AI coding agent. Today's date and time is ${today}. The agent's training data ends around ${cutoff}.
276
+
277
+ You are given a transcript of the agent working on the project "${project}". Identify KNOWLEDGE GAPS: specific facts the agent lacked that would measurably improve its work on THIS project if researched in the background. Good gaps:
278
+ - APIs, SDKs, libraries, services or versions the project actually uses whose behaviour may have changed after ${cutoff}.
279
+ - Errors or behaviours the agent could not explain or resolve.
280
+ - Places where the agent guessed, hedged, or contradicted itself about an external tool.
281
+
282
+ Not gaps: general curiosity, topics merely mentioned, things the transcript already answers, and anything unrelated to "${project}". Returning an empty list is normal and correct when there is no real gap.
278
283
 
279
284
  Output valid JSON:
280
285
  {
281
- "topics": [
286
+ "gaps": [
282
287
  {
283
- "topic": "string (specific, searchable — e.g. 'Ollama REST API endpoints 2025' not just 'AI')",
284
- "priority": 1-100,
285
- "rationale": "why this needs research",
286
- "tags": ["tag1", "tag2"],
287
- "suggested_sources": ["brave-search", "arxiv", "npm", "github", "wikipedia"]
288
+ "topic": "string (specific and searchable, e.g. 'Stripe Node SDK v17 webhook signature verification')",
289
+ "rationale": "string (the moment in the transcript that exposed the gap, and how the answer helps this project)",
290
+ "priority": 1-100
288
291
  }
289
292
  ]
290
293
  }
291
294
 
292
295
  Rules:
293
- - Maximum 5 topics per session.
294
- - Topics must be SPECIFIC and SEARCHABLE — not vague categories.
295
- - Include version numbers, tool names, library names when relevant.
296
- - Prioritize topics post-${cutoff} or where the agent showed uncertainty.
296
+ - At most 3 gaps.
297
297
  - Output ONLY valid JSON.`;
298
298
  }
299
299
 
300
300
  /**
301
- * Infer research topics from a session transcript via the local LLM.
301
+ * Infer project knowledge gaps from a session transcript via the local LLM.
302
302
  */
303
- export async function inferTopicsFromSession(transcript, runtimeConfig) {
303
+ export async function inferTopicsFromSession(transcript, runtimeConfig, { project = 'this project' } = {}) {
304
304
  if (!transcript || transcript.length < 100) return [];
305
305
 
306
- const prompt = `Analyze this conversation and identify the most important research topics:\n\n${transcript.slice(0, 8000)}`;
306
+ const prompt = `Transcript of the agent working on "${project}":\n\n${transcript.slice(0, 8000)}`;
307
307
 
308
308
  try {
309
- const raw = await callLocalRuntime(prompt, TOPIC_INFERENCE_SYSTEM(runtimeConfig), runtimeConfig);
309
+ const raw = await callLocalRuntime(prompt, TOPIC_INFERENCE_SYSTEM(runtimeConfig, project), runtimeConfig);
310
310
  const match = raw.match(/\{[\s\S]*\}/);
311
311
  if (!match) return [];
312
312
  const result = cleanAndParseJSON(match[0]);
313
- return Array.isArray(result.topics) ? result.topics : [];
313
+ const gaps = Array.isArray(result.gaps) ? result.gaps : (Array.isArray(result.topics) ? result.topics : []);
314
+ return gaps.filter((g) => g && typeof g.topic === 'string' && g.topic.trim()).slice(0, 3);
314
315
  } catch (err) {
315
- logger.info({ subsystem: 'fact-seeker', message: `Topic inference failed: ${err.message}` });
316
+ logger.info({ subsystem: 'fact-seeker', message: `Gap inference failed: ${err.message}` });
316
317
  return [];
317
318
  }
318
319
  }
@@ -762,7 +763,10 @@ async function writeAndSurfaceImmediately(topic, synthesis, sourceResults, {
762
763
  try {
763
764
  const { compileSurface } = await import('./surface.mjs');
764
765
  await compileSurface({ vaultDir, skillsDir, derivedDir, instructionsFile });
765
- logger.info({ subsystem: 'fact-seeker', message: `[FAST-PATH] Surface recompiled — "${topic}" now live in INSTRUCTIONS.md` });
766
+ // Research reaches the instructions through the Background Research section
767
+ // once its queue item is done (research-surface.mjs); this recompile only
768
+ // refreshes indexes and any rules the synthesis produced.
769
+ logger.info({ subsystem: 'fact-seeker', message: `[FAST-PATH] Surface recompiled after writing "${topic}"` });
766
770
  } catch (err) {
767
771
  logger.info({ subsystem: 'fact-seeker', message: `[FAST-PATH] Surface recompile failed (non-fatal): ${err.message}` });
768
772
  }
@@ -872,17 +876,10 @@ export async function runSelfDiagnosis({ vaultDir, runtimeConfig }) {
872
876
  if (!match) throw new Error('No JSON in diagnosis response');
873
877
  const diagnosis = cleanAndParseJSON(match[0]);
874
878
 
875
- // Auto-enqueue high-priority recommended topics
879
+ // Report only. Recommendations are not queued: research runs only when a
880
+ // human asks or the project gate approves a gap (RESEARCH_SYSTEM2).
876
881
  const recommended = Array.isArray(diagnosis.recommended_immediate_research)
877
882
  ? diagnosis.recommended_immediate_research : [];
878
- for (const rec of recommended.filter(r => r.priority >= 70)) {
879
- addToAgenda({
880
- topic: rec.topic,
881
- priority: rec.priority,
882
- source: 'self-diagnosis',
883
- rationale: rec.reason,
884
- });
885
- }
886
883
 
887
884
  // Log source warnings
888
885
  for (const warning of availability.warnings) {
@@ -932,23 +929,13 @@ export async function runKnowledgeAcquisitionCycle({
932
929
  }) {
933
930
  const researchConfig = loadResearchConfig();
934
931
 
935
- // Determine topic
936
- let topicEntry;
937
- if (forceTopic) {
938
- topicEntry = addToAgenda({ topic: forceTopic, priority: 90, source: 'direct-instruction', directInstruction: true });
939
- } else {
940
- topicEntry = getNextAgendaTopic();
941
- }
942
-
943
- if (!topicEntry) {
944
- // No agenda items — run self-diagnosis to generate new ones
945
- logger.info({ subsystem: 'fact-seeker', message: 'Agenda empty — running self-diagnosis to generate topics' });
946
- await runSelfDiagnosis({ vaultDir, runtimeConfig });
947
- topicEntry = getNextAgendaTopic();
948
- if (!topicEntry) {
949
- return { topic: null, skipped: 'empty-agenda-after-diagnosis' };
950
- }
932
+ // Research needs a topic somebody chose. There is no "next agenda topic"
933
+ // fallback and no self-diagnosis topic invention: those let research feed on
934
+ // itself (RESEARCH_SYSTEM2). The agenda is only a coverage ledger now.
935
+ if (!forceTopic || !String(forceTopic).trim()) {
936
+ return { topic: null, skipped: 'no-topic' };
951
937
  }
938
+ const topicEntry = addToAgenda({ topic: forceTopic, priority: 90, source: 'direct-instruction', directInstruction: true });
952
939
 
953
940
  const { topic, id: topicId } = topicEntry;
954
941
  logger.info({ subsystem: 'fact-seeker', message: `Researching: "${topic}" (priority: ${topicEntry.priority})` });
@@ -994,32 +981,8 @@ export async function runKnowledgeAcquisitionCycle({
994
981
  const coverageScore = Math.min(1.0, (results.length / 5) * confidence);
995
982
  markTopicResearched(topicId, { coverageScore, sourcesConsulted: sourcesUsed });
996
983
 
997
- // Enqueue follow-up research for identified gaps (self-multiplication).
998
- //
999
- // Bounded per parent topic. Research runs five phases and the monitoring and
1000
- // expansion phases repeat, so an uncapped 3 gaps per cycle compounds without
1001
- // limit — two completed reports generated 116 and 98 follow-ups respectively,
1002
- // none of which ever ran. The agenda is a backlog, not a fan-out tree.
1003
- const FOLLOW_UP_CAP_PER_TOPIC = 5;
1004
- const alreadySpawned = loadAgenda().filter(
1005
- (t) => t.source === `follow-up:${topic}`,
1006
- ).length;
1007
- const remaining = Math.max(0, FOLLOW_UP_CAP_PER_TOPIC - alreadySpawned);
1008
- const gaps = (synthesis.further_research_needed || []).slice(0, remaining);
1009
- if (remaining === 0 && (synthesis.further_research_needed || []).length > 0) {
1010
- logger.info({
1011
- subsystem: 'fact-seeker',
1012
- message: `Follow-up cap reached for "${topic}" (${alreadySpawned} already queued); not spawning more.`,
1013
- });
1014
- }
1015
- for (const gap of gaps) {
1016
- addToAgenda({
1017
- topic: gap,
1018
- priority: Math.max(20, topicEntry.priority - 15),
1019
- source: `follow-up:${topic}`,
1020
- rationale: `Gap identified while researching "${topic}"`,
1021
- });
1022
- }
984
+ // Gaps the synthesis names stay in the report (further_research_needed) for a
985
+ // human to act on; they are never queued automatically.
1023
986
 
1024
987
  logger.info({
1025
988
  subsystem: 'fact-seeker',
@@ -1030,32 +993,20 @@ export async function runKnowledgeAcquisitionCycle({
1030
993
  }
1031
994
 
1032
995
  /**
1033
- * Ingest a session and add inferred topics to the research agenda.
1034
- * Called by the daemon after each session post-mortem.
996
+ * Background research for a project session (System 2): infer knowledge gaps
997
+ * from the transcript and hand them to the research gate, which applies the
998
+ * autonomous budgets and skips anything existing research already covers.
999
+ *
1000
+ * @returns {Promise<string[]>} topics actually queued
1035
1001
  */
1036
- export async function ingestSessionTopics(sessionTranscript, runtimeConfig) {
1037
- const topics = await inferTopicsFromSession(sessionTranscript, runtimeConfig);
1038
- const added = [];
1039
-
1040
- for (const t of topics) {
1041
- const entry = addToAgenda({
1042
- topic: t.topic,
1043
- priority: t.priority || 50,
1044
- source: 'session-inference',
1045
- rationale: t.rationale || '',
1046
- tags: t.tags || [],
1047
- });
1048
- added.push(entry.topic);
1049
- }
1050
-
1051
- if (added.length > 0) {
1052
- logger.info({
1053
- subsystem: 'fact-seeker',
1054
- message: `Added ${added.length} topics from session: ${added.join(', ')}`,
1055
- });
1056
- }
1057
-
1058
- return added;
1002
+ export async function ingestSessionTopics(sessionTranscript, runtimeConfig, { project, sessionId = null, gate } = {}) {
1003
+ if (!project) return [];
1004
+ const gaps = await inferTopicsFromSession(sessionTranscript, runtimeConfig, { project });
1005
+ if (gaps.length === 0) return [];
1006
+
1007
+ const propose = gate || (await import('./research-gate.mjs')).proposeAutonomousResearch;
1008
+ const { queued } = await propose(gaps, { project, sessionId, via: 'session' });
1009
+ return queued.map((item) => item.topic);
1059
1010
  }
1060
1011
 
1061
1012
  // ─── Specialized Cognitive Research Phase Engines ────────────────────────────────
@@ -1178,14 +1129,6 @@ Output valid JSON:
1178
1129
  "predicate": "use",
1179
1130
  "object": "string (what the rule applies to)"
1180
1131
  }
1181
- ],
1182
- "autonomous_tasks": [
1183
- {
1184
- "priority": 50,
1185
- "category": "proactive-research",
1186
- "reason": "string (why this task is needed)",
1187
- "body": "string (detailed markdown instructions for the task)"
1188
- }
1189
1132
  ]
1190
1133
  }
1191
1134
 
@@ -1196,10 +1139,6 @@ Rules for synthesized_instructions:
1196
1139
  - Subject and predicate MUST be alphanumeric (letters, numbers, hyphens, underscores, dots, spaces only). E.g., subject="agent", predicate="use".
1197
1140
  - Keep rules highly actionable.
1198
1141
 
1199
- Rules for autonomous_tasks:
1200
- - Category MUST be one of: "memory-maintenance", "system2-deliberation", "skill-engineering", "proactive-research", "self-evaluation", "exploration".
1201
- - Priority MUST be an integer between 1 and 100.
1202
-
1203
1142
  Output ONLY valid JSON.`;
1204
1143
 
1205
1144
  try {
@@ -1388,37 +1327,12 @@ Output ONLY valid JSON.`;
1388
1327
  }
1389
1328
  }
1390
1329
 
1391
- // Process autonomous tasks
1392
- if (result.autonomous_tasks && result.autonomous_tasks.length > 0) {
1393
- const queueDir = path.join(localBrainDir, 'scheduler', 'queue');
1394
- const { persistTaskToDisk } = await import('./scheduler.mjs');
1395
- for (const t of result.autonomous_tasks) {
1396
- const taskSlug = `delib-task-${crypto.createHash('md5').update(t.task || t.topic || t.description || '').digest('hex').slice(0, 8)}`;
1397
- const taskNode = {
1398
- type: 'task',
1399
- slug: taskSlug,
1400
- priority: t.priority || 50,
1401
- category: t.category || 'proactive-research',
1402
- status: 'pending',
1403
- created_by: 'fact-seeker-deliberation',
1404
- reason: t.reason || `Autonomously generated from deliberation on "${topic}"`,
1405
- body: t.body || `Follow up on research for "${topic}"`,
1406
- progress: 0,
1407
- estimated_calls: 5,
1408
- deadline: new Date(Date.now() + 86400000).toISOString().split('T')[0]
1409
- };
1410
-
1411
- persistTaskToDisk(taskNode, queueDir);
1412
- logger.info({
1413
- subsystem: 'fact-seeker',
1414
- message: `Scheduled autonomous deliberation task: ${taskSlug} (${t.category})`
1415
- });
1416
- }
1417
- }
1418
-
1330
+ // Deliberation refines this report only. It does not queue follow-up work:
1331
+ // research never spawns research (RESEARCH_SYSTEM2).
1332
+
1419
1333
  return {
1420
1334
  success: true,
1421
- 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.`,
1335
+ output: `Deliberated on "${topic}". Generated ${result.insights?.length || 0} insights, ${result.connections?.length || 0} connections, ${result.synthesized_instructions?.length || 0} instructions.`,
1422
1336
  factSlug: nodeSlug
1423
1337
  };
1424
1338
  } catch (err) {
@@ -1494,236 +1408,7 @@ Output ONLY the raw markdown of the polished body, starting immediately with no
1494
1408
  }
1495
1409
 
1496
1410
  /**
1497
- * Phase 4/5: Source Monitoring
1498
- */
1499
- export async function runResearchMonitoringCycle({
1500
- vaultDir,
1501
- nodeSlug,
1502
- topic,
1503
- runtimeConfig,
1504
- skillsDir,
1505
- derivedDir,
1506
- instructionsFile
1507
- }) {
1508
- const nodes = getNodes(vaultDir);
1509
- const targetNode = nodes.find(n => n.slug === nodeSlug);
1510
- if (!targetNode) {
1511
- return { success: false, error: `Target node not found for slug: ${nodeSlug}` };
1512
- }
1513
-
1514
- const researchConfig = loadResearchConfig();
1515
- const searchQuery = `${topic} newsletters blogs release notes feeds updates`;
1516
-
1517
- let results = [];
1518
- try {
1519
- results = await webSearch(searchQuery, researchConfig, 5);
1520
- } catch (err) {
1521
- logger.info({ subsystem: 'fact-seeker', message: `Web search for monitoring failed: ${err.message}` });
1522
- }
1523
-
1524
- if (results.length === 0) {
1525
- return { success: true, output: `Monitoring skipped: no search results found for query: "${searchQuery}"`, factSlug: nodeSlug };
1526
- }
1527
-
1528
- const sourceText = results.map(r => `Title: ${r.title}\nURL: ${r.url}\nSnippet: ${r.snippet}`).join('\n\n');
1529
-
1530
- const today = getLocalizedDateTime();
1531
- const prompt = `Today's Date/Time: ${today}
1532
- Research Topic: "${topic}"
1533
- Search Results for ongoing updates/feeds/newsletters/releases:
1534
- ${sourceText}
1535
-
1536
- Extract 2-4 authoritative, high-quality ongoing sources (newsletters, RSS feeds, official release notes, blogs, communities) for this topic.`;
1537
-
1538
- const systemPrompt = `You are a Research Monitoring Specialist. Today's date and time is ${today}.
1539
- Your job is to identify high-quality ongoing sources of information (e.g. RSS feeds, newsletters, official release notes, specific blogs) from search results.
1540
-
1541
- Output valid JSON:
1542
- {
1543
- "sources": [
1544
- {
1545
- "name": "string (name of the newsletter, blog, or release feed)",
1546
- "url": "string (direct URL)",
1547
- "type": "string (newsletter / RSS feed / official blog / release notes / etc.)",
1548
- "description": "string (why this is a reliable ongoing source)"
1549
- }
1550
- ]
1551
- }
1552
-
1553
- Output ONLY valid JSON.`;
1554
-
1555
- try {
1556
- const raw = await callLocalRuntime(prompt, systemPrompt, runtimeConfig);
1557
- const match = raw.match(/\{[\s\S]*\}/);
1558
- if (!match) throw new Error('No JSON in monitoring response');
1559
- const parsed = cleanAndParseJSON(match[0]);
1560
- const sources = parsed.sources || [];
1561
-
1562
- if (sources.length === 0) {
1563
- return { success: true, output: `Monitoring complete: no ongoing sources extracted for "${topic}".`, factSlug: nodeSlug };
1564
- }
1565
-
1566
- let updatedBody = targetNode.body;
1567
- const linesToAdd = ['', '## Ongoing Monitoring & Sources'];
1568
- sources.forEach(src => {
1569
- linesToAdd.push(`- **[${src.name}](${src.url})** (${src.type}) — ${src.description}`);
1570
- });
1571
-
1572
- updatedBody += '\n' + linesToAdd.join('\n');
1573
-
1574
- const updatedNode = {
1575
- ...targetNode,
1576
- body: updatedBody,
1577
- tags: [...new Set([...(targetNode.tags || []), 'monitored'])],
1578
- updated: new Date().toISOString()
1579
- };
1580
-
1581
- await writeNode(updatedNode, vaultDir);
1582
-
1583
- // Trigger immediate surface recompilation if active rules/sources were added
1584
- if (skillsDir && derivedDir && instructionsFile) {
1585
- try {
1586
- const { compileSurface } = await import('./surface.mjs');
1587
- await compileSurface({ vaultDir, skillsDir, derivedDir, instructionsFile });
1588
- logger.info({
1589
- subsystem: 'fact-seeker',
1590
- message: `Surface compiled with newly monitored sources.`
1591
- });
1592
- } catch (err) {
1593
- logger.info({
1594
- subsystem: 'fact-seeker',
1595
- message: `Surface compilation failed (non-fatal): ${err.message}`
1596
- });
1597
- }
1598
- }
1599
-
1600
- return {
1601
- success: true,
1602
- output: `Monitoring complete: added ${sources.length} ongoing sources for "${topic}".`,
1603
- factSlug: nodeSlug
1604
- };
1605
- } catch (err) {
1606
- return { success: false, error: `Monitoring failed: ${err.message}` };
1607
- }
1608
- }
1609
-
1610
- /**
1611
- * Phase 5/5: Domain Tangents Expansion
1612
- */
1613
- export async function runResearchExpansionCycle({ vaultDir, nodeSlug, topic, runtimeConfig }) {
1614
- const nodes = getNodes(vaultDir);
1615
- const targetNode = nodes.find(n => n.slug === nodeSlug);
1616
- if (!targetNode) {
1617
- return { success: false, error: `Target node not found for slug: ${nodeSlug}` };
1618
- }
1619
-
1620
- const today = getLocalizedDateTime();
1621
- const prompt = `Today's Date/Time: ${today}
1622
- Target Node Topic: "${topic}"
1623
- Title: ${targetNode.title}
1624
- Body:
1625
- ${targetNode.body}
1626
-
1627
- Deliberate on what adjacent domain topics must be researched next to build complete expertise.
1628
- Brainstorm 2-3 highly specific, searchable research topics/questions.`;
1629
-
1630
- const systemPrompt = `You are a Research Expansion Specialist. Today's date and time is ${today}.
1631
- Your job is to brainstorm the next logical adjacent topics that must be researched to build comprehensive domain expertise on the target topic.
1632
-
1633
- Output valid JSON:
1634
- {
1635
- "tangents": [
1636
- {
1637
- "topic": "string (specific, searchable topic)",
1638
- "priority": "low" | "medium" | "high",
1639
- "rationale": "string (why this tangent is crucial next step)"
1640
- }
1641
- ]
1642
- }
1643
-
1644
- Output ONLY valid JSON.`;
1645
-
1646
- try {
1647
- const raw = await callLocalRuntime(prompt, systemPrompt, runtimeConfig);
1648
- const match = raw.match(/\{[\s\S]*\}/);
1649
- if (!match) throw new Error('No JSON in expansion response');
1650
- const parsed = cleanAndParseJSON(match[0]);
1651
- const tangents = parsed.tangents || [];
1652
-
1653
- if (tangents.length === 0) {
1654
- return { success: true, output: `Expansion complete: no new tangents brainstormed for "${topic}".`, factSlug: nodeSlug };
1655
- }
1656
-
1657
- const localBrainDir = getBrainDir();
1658
- const queueDir = path.join(localBrainDir, 'scheduler', 'queue');
1659
- const { persistTaskToDisk } = await import('./scheduler.mjs');
1660
-
1661
- const spawnedList = [];
1662
- for (const tangent of tangents.slice(0, 3)) {
1663
- try {
1664
- const added = addToQueue({
1665
- topic: tangent.topic,
1666
- priority: tangent.priority || 'medium',
1667
- notes: `Autonomously spawned from research on "${topic}". Rationale: ${tangent.rationale}`
1668
- });
1669
- spawnedList.push(added);
1670
-
1671
- // Also schedule a scheduler task to run proactive research for this topic
1672
- const prio = tangent.priority === 'high' ? 65 : (tangent.priority === 'low' ? 45 : 55);
1673
- const taskSlug = `proactive-research-${crypto.createHash('md5').update(tangent.topic || '').digest('hex').slice(0, 8)}`;
1674
- const taskNode = {
1675
- type: 'task',
1676
- slug: taskSlug,
1677
- priority: prio,
1678
- category: 'proactive-research',
1679
- status: 'pending',
1680
- created_by: 'fact-seeker-expansion',
1681
- reason: tangent.rationale || `Explore research tangent on: ${tangent.topic}`,
1682
- body: `Autonomously spawned from research on "${topic}".\n\nTopic: ${tangent.topic}\n\nRationale:\n${tangent.rationale}`,
1683
- progress: 0,
1684
- estimated_calls: 5,
1685
- deadline: new Date(Date.now() + 86400000).toISOString().split('T')[0]
1686
- };
1687
-
1688
- persistTaskToDisk(taskNode, queueDir);
1689
- logger.info({
1690
- subsystem: 'fact-seeker',
1691
- message: `Scheduled expansion task: ${taskSlug} for topic "${tangent.topic}"`
1692
- });
1693
- } catch (err) {
1694
- logger.error({ subsystem: 'fact-seeker', message: `Failed to auto-spawn tangent "${tangent.topic}": ${err.message}` });
1695
- }
1696
- }
1697
-
1698
- let updatedBody = targetNode.body;
1699
- const linesToAdd = ['', '## Spawned Research Expansion Avenues'];
1700
- tangents.forEach(t => {
1701
- linesToAdd.push(`- **${t.topic}** (${t.priority || 'medium'}) — ${t.rationale}`);
1702
- });
1703
-
1704
- updatedBody += '\n' + linesToAdd.join('\n');
1705
-
1706
- const updatedNode = {
1707
- ...targetNode,
1708
- body: updatedBody,
1709
- tags: [...new Set([...(targetNode.tags || []), 'expanded'])],
1710
- updated: new Date().toISOString()
1711
- };
1712
-
1713
- await writeNode(updatedNode, vaultDir);
1714
-
1715
- return {
1716
- success: true,
1717
- output: `Autonomously spawned ${spawnedList.length} new research tangents: ${spawnedList.map(s => `"${s.topic}"`).join(', ')}.`,
1718
- factSlug: nodeSlug
1719
- };
1720
- } catch (err) {
1721
- return { success: false, error: `Tangent expansion failed: ${err.message}` };
1722
- }
1723
- }
1724
-
1725
- /**
1726
- * Phase 6: Memory Compaction (Consolidation)
1411
+ * Memory Compaction (Consolidation)
1727
1412
  * Finds redundant or overlapping fact nodes and merges them into a consolidated node.
1728
1413
  */
1729
1414
  export async function runMemoryCompaction({ vaultDir, inboxDir, runtimeConfig }) {
@@ -210,13 +210,13 @@ export function lanHostnameFromIp(ip) {
210
210
  * @param {{ vaultRoot?: string, workspace_id?: string, onlyReachable?: boolean }} [options]
211
211
  */
212
212
  export async function registerLanMeshNodes(hosts, options = {}) {
213
- const { defaultVaultRoot, findVfsDocumentByPath, listVfsDocumentsUnder } = await import('./vfs-documents.mjs');
214
- const { meshNodeDocSlug } = await import('./mesh.mjs');
213
+ const { findVfsDocumentByPath, listVfsDocumentsUnder } = await import('./vfs-documents.mjs');
214
+ const { meshNodeDocSlug, meshVaultRoot } = await import('./mesh.mjs');
215
215
  const { processViaPackageKernel } = await import('./ssss-kernel-bridge.mjs');
216
216
  const crypto = await import('node:crypto');
217
217
  const path = await import('node:path');
218
218
 
219
- const vaultRoot = options.vaultRoot || defaultVaultRoot();
219
+ const vaultRoot = options.vaultRoot || meshVaultRoot();
220
220
  const onlyReachable = options.onlyReachable !== false;
221
221
  const candidates = (hosts || []).filter((h) => h?.ip && isLanIpv4(h.ip) && (!onlyReachable || h.tr_reachable));
222
222