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.
- package/bin/total-recall.mjs +24 -1
- package/docs/ARCHITECTURE.md +26 -18
- package/extension/background.js +259 -84
- package/extension/content-script.css +7 -13
- package/extension/content-script.js +252 -477
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/icons/mark.svg +6 -0
- package/extension/lib/brain-client.js +160 -73
- package/extension/lib/tokens.css +187 -0
- package/extension/lib/ui-helpers.js +229 -0
- package/extension/lib/ui-helpers.spec.mjs +94 -0
- package/extension/manifest.json +20 -5
- package/extension/options/options.css +64 -218
- package/extension/options/options.html +91 -43
- package/extension/options/options.js +208 -85
- package/extension/sidepanel/sidepanel.css +268 -970
- package/extension/sidepanel/sidepanel.html +200 -166
- package/extension/sidepanel/sidepanel.js +788 -942
- package/frontend/dist/assets/index-DGMNZi_1.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/plugins.ts +124 -92
- package/frontend/src/components/ResearchAgendaTab.spec.tsx +11 -0
- package/frontend/src/components/ResearchAgendaTab.tsx +19 -14
- package/frontend/src/pages/PluginsPage.spec.tsx +87 -16
- package/frontend/src/pages/PluginsPage.tsx +454 -1218
- package/package.json +2 -1
- package/plugins/code-quality/README.md +25 -0
- package/plugins/code-quality/cli.mjs +25 -0
- package/plugins/code-quality/plugin.json +17 -0
- package/plugins/git-sentinel/README.md +27 -0
- package/{scaffold/.agent/plugins → plugins}/git-sentinel/cli.mjs +8 -5
- package/plugins/git-sentinel/plugin.json +18 -0
- package/plugins/system-monitor/README.md +26 -0
- package/{scaffold/.agent/plugins → plugins}/system-monitor/cli.mjs +5 -4
- package/{scaffold/.agent/plugins → plugins}/system-monitor/generator.mjs +4 -5
- package/plugins/system-monitor/plugin.json +29 -0
- package/scaffold/.agent/skills/meta-harness/SKILL.md +4 -4
- package/scaffold/.agent/skills/plugins/SKILL.md +53 -0
- package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
- package/src/cli/agent.mjs +1 -1
- package/src/cli/app/index.mjs +493 -0
- package/src/cli/brain.mjs +33 -3
- package/src/cli/command.mjs +80 -41
- package/src/cli/harness.mjs +1 -1
- package/src/cli/init.mjs +3 -1
- package/src/cli/mesh.mjs +118 -12
- package/src/cli/plugin/create.mjs +144 -25
- package/src/cli/plugin/index.mjs +83 -53
- package/src/cli/plugin/install.mjs +10 -127
- package/src/cli/plugin/list.mjs +44 -44
- package/src/cli/plugin/remove.mjs +23 -30
- package/src/cli/plugin/scaffold-cmd.mjs +30 -0
- package/src/cli/plugin/search.mjs +108 -23
- package/src/cli/rebuild.mjs +37 -1
- package/src/cli/recall.mjs +17 -2
- package/src/cli/research.mjs +4 -6
- package/src/cli/share.mjs +2 -0
- package/src/core/app-deploy/apply.mjs +386 -0
- package/src/core/app-deploy/design-tokens.mjs +217 -0
- package/src/core/app-deploy/plan.mjs +249 -0
- package/src/core/app-deploy/resolve.mjs +295 -0
- package/src/core/app-deploy/source.mjs +337 -0
- package/src/core/app-deploy/standalone.mjs +248 -0
- package/src/core/app-deploy/upgrade.mjs +256 -0
- package/src/core/app-deploy/verify.mjs +213 -0
- package/src/core/bound-hosts.mjs +1 -1
- package/src/core/brain-registry.mjs +146 -0
- package/src/core/brain-state.json +1 -0
- package/src/core/daemon-loop.mjs +30 -26
- package/src/core/dream.mjs +5 -17
- package/src/core/fact-seeker.mjs +53 -368
- package/src/core/lan-discovery.mjs +3 -3
- package/src/core/mesh-access-sync.mjs +134 -0
- package/src/core/mesh-access.mjs +18 -3
- package/src/core/mesh-discover.mjs +173 -0
- package/src/core/mesh.mjs +131 -32
- package/src/core/optimizer.mjs +4 -41
- package/src/core/plugin-bundle.mjs +167 -0
- package/src/core/plugin-context.mjs +8 -13
- package/src/core/plugin-loader.mjs +272 -68
- package/src/core/plugin-peers.mjs +163 -0
- package/src/core/plugin-public.mjs +85 -0
- package/src/core/plugin-runner-child.mjs +24 -0
- package/src/core/plugin-runner.mjs +102 -0
- package/src/core/plugin-store.mjs +384 -0
- package/src/core/plugin-tasks.mjs +212 -0
- package/src/core/post-mortem.mjs +40 -9
- package/src/core/research-gate.mjs +182 -0
- package/src/core/research-queue.mjs +47 -7
- package/src/core/research-surface.mjs +211 -0
- package/src/core/research.mjs +25 -32
- package/src/core/scheduler.mjs +67 -153
- package/src/core/schema.mjs +129 -0
- package/src/core/search.mjs +12 -1
- package/src/core/secret-integration-research.mjs +12 -8
- package/src/core/secrets-store.mjs +32 -2
- package/src/core/session-watcher.mjs +6 -0
- package/src/core/surface.mjs +64 -3
- package/src/core/task-executors.mjs +8 -45
- package/src/core/vault-cache.mjs +70 -4
- package/src/server/api.mjs +21 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +21 -5
- package/src/server/routes/collab.mjs +38 -11
- package/src/server/routes/extension.mjs +3 -2
- package/src/server/routes/memory.mjs +24 -2
- package/src/server/routes/mesh.mjs +7 -7
- package/src/server/routes/plugins-mesh.mjs +49 -0
- package/src/server/routes/plugins.mjs +91 -426
- package/src/server/routes/research.mjs +5 -2
- package/src/server/routes/share.mjs +2 -0
- package/src/server/tools.mjs +29 -2
- package/extension/icons/README.md +0 -27
- package/extension/lib/preconfigured.js +0 -5
- package/extension/popup/popup.css +0 -227
- package/extension/popup/popup.html +0 -50
- package/extension/popup/popup.js +0 -175
- package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
- package/scaffold/.agent/plugins/.gitkeep +0 -0
- package/scaffold/.agent/plugins/code-quality/README.md +0 -16
- package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
- package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
- package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
- package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
- package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
- package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
- package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
- package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
- 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
|
-
*
|
|
346
|
-
*
|
|
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({
|
|
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 =
|
|
353
|
-
const
|
|
354
|
-
if (
|
|
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
|
+
}
|
package/src/core/research.mjs
CHANGED
|
@@ -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
|
|
91
|
-
|
|
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:
|
|
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 =>
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
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
|
-
|
|
611
|
+
Search results:
|
|
612
|
+
${sourceSummary.slice(0, 6000)}`;
|
|
620
613
|
|
|
621
614
|
try {
|
|
622
615
|
let report = await callLocalRuntime(prompt, system, runtimeConfig);
|