total-recall-brain 3.28.0 → 3.28.2
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/frontend/src/App.spec.tsx +4 -0
- package/frontend/src/pages/PluginsPage.spec.tsx +10 -2
- package/package.json +1 -1
- package/scaffold/openwiki/README.md +10 -0
- package/scaffold/openwiki/architecture.md +5 -0
- package/scaffold/openwiki/ide.md +10 -0
- package/scaffold/openwiki/memory.md +11 -0
- package/scaffold/openwiki/secrets.md +7 -0
- package/scaffold/openwiki/skills.md +7 -0
- package/src/cli/connect.mjs +13 -8
- package/src/cli/init.mjs +51 -27
- package/src/cli/skill-projection.mjs +67 -16
- package/src/cli/status.mjs +42 -31
- package/src/core/daemon-loop.mjs +68 -15
- package/src/core/embeddings.mjs +14 -4
- package/src/core/fact-seeker.mjs +95 -3
- package/src/core/headscale-client.mjs +30 -6
- package/src/core/leader-election.mjs +49 -0
- package/src/core/research-queue.mjs +22 -0
- package/src/core/research.mjs +71 -0
- package/src/core/runtime.mjs +65 -25
- package/src/core/sandbox.mjs +27 -3
- package/src/core/secret-integration-research.mjs +31 -20
- package/src/core/skills-registry.mjs +2 -0
- package/src/server/route-manifest.json +48 -0
package/src/core/daemon-loop.mjs
CHANGED
|
@@ -172,24 +172,59 @@ export function releasePidLock() {
|
|
|
172
172
|
let taskCount = 0;
|
|
173
173
|
let running = true;
|
|
174
174
|
let vaultWatcher = null;
|
|
175
|
+
let shuttingDown = false;
|
|
176
|
+
|
|
177
|
+
// Upper bound on a graceful stop. A loop iteration can be mid-task, so the
|
|
178
|
+
// signal cannot exit instantly — but it must exit *eventually*.
|
|
179
|
+
const SHUTDOWN_GRACE_MS = 30000;
|
|
175
180
|
|
|
176
181
|
import { releaseLease } from './leader-election.mjs';
|
|
177
182
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Graceful stop, with a guaranteed exit.
|
|
185
|
+
*
|
|
186
|
+
* Clearing `running` alone never stopped this process. The vault and source
|
|
187
|
+
* watchers hold the event loop open, so `daemon stop` printed "✅ Daemon
|
|
188
|
+
* stopped" while the daemon kept running — still executing the OLD module code
|
|
189
|
+
* and still ingesting sessions. No "Daemon stopped." line ever reached
|
|
190
|
+
* daemon.log across three separate SIGTERMs. The fallback timer bounds that, so
|
|
191
|
+
* even a wedged iteration cannot keep the daemon alive indefinitely.
|
|
192
|
+
*/
|
|
193
|
+
function shutdown(signal) {
|
|
194
|
+
if (shuttingDown) return;
|
|
195
|
+
shuttingDown = true;
|
|
196
|
+
logger.info({ subsystem: 'daemon-loop', message: `${signal} received — shutting down gracefully` });
|
|
183
197
|
running = false;
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
process.on('SIGINT', async () => {
|
|
187
|
-
logger.info({ subsystem: 'daemon-loop', message: 'SIGINT received — shutting down gracefully' });
|
|
188
198
|
if (vaultWatcher) vaultWatcher.stop();
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
199
|
+
|
|
200
|
+
const fallback = setTimeout(() => {
|
|
201
|
+
logger.info({
|
|
202
|
+
subsystem: 'daemon-loop',
|
|
203
|
+
message: `Graceful shutdown exceeded ${SHUTDOWN_GRACE_MS}ms — forcing exit`,
|
|
204
|
+
});
|
|
205
|
+
process.exit(0);
|
|
206
|
+
}, SHUTDOWN_GRACE_MS);
|
|
207
|
+
fallback.unref();
|
|
208
|
+
|
|
209
|
+
Promise.resolve()
|
|
210
|
+
.then(() => releaseLease())
|
|
211
|
+
.catch((err) => {
|
|
212
|
+
logger.info({
|
|
213
|
+
subsystem: 'daemon-loop',
|
|
214
|
+
message: `Lease release failed during shutdown: ${err.message}`,
|
|
215
|
+
});
|
|
216
|
+
})
|
|
217
|
+
.finally(() => {
|
|
218
|
+
try {
|
|
219
|
+
releasePidLock();
|
|
220
|
+
} catch {
|
|
221
|
+
// Lock already gone — nothing to release.
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
227
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
193
228
|
|
|
194
229
|
process.on('uncaughtException', (err) => {
|
|
195
230
|
logger.error({ subsystem: 'daemon-loop', message: `Uncaught Exception (suppressed to keep daemon alive): ${err.stack || err.message}` });
|
|
@@ -291,7 +326,7 @@ async function main() {
|
|
|
291
326
|
|
|
292
327
|
// Deterministic election (lowest mesh IP) — tryAcquire/renew are no-op shims
|
|
293
328
|
// that re-evaluate isLeader(); no node-local lease documents are written.
|
|
294
|
-
const { tryAcquireLease, isLeader, getLeaderInfo, renewLease } = await import('./leader-election.mjs');
|
|
329
|
+
const { tryAcquireLease, isLeader, getLeaderInfo, renewLease, leaderPin } = await import('./leader-election.mjs');
|
|
295
330
|
|
|
296
331
|
const acquired = await tryAcquireLease();
|
|
297
332
|
if (acquired) {
|
|
@@ -300,7 +335,22 @@ async function main() {
|
|
|
300
335
|
const info = await getLeaderInfo();
|
|
301
336
|
const leaderStr = info ? info.hostname : 'unknown';
|
|
302
337
|
logger.info({ subsystem: 'daemon-loop', message: `Starting as FOLLOWER (leader: ${leaderStr})` });
|
|
303
|
-
|
|
338
|
+
|
|
339
|
+
// Lowest-online-IP says nothing about whether the elected node can run Total
|
|
340
|
+
// Recall. On this mesh the shared droplet holds 100.64.0.1 and runs no TR at
|
|
341
|
+
// all, so every real machine idled as a follower for months: the daemon
|
|
342
|
+
// reported itself healthy while the pipeline stayed silent. A follower with
|
|
343
|
+
// no pin configured anywhere is almost certainly that mistake, and it is
|
|
344
|
+
// invisible without this warning — followers skip the task loop entirely.
|
|
345
|
+
if (!leaderPin()) {
|
|
346
|
+
logger.warn(
|
|
347
|
+
'daemon-loop',
|
|
348
|
+
`No leader pin found — following "${leaderStr}" by lowest-IP order. Followers skip the task loop, ` +
|
|
349
|
+
`so this node will run no research, dream cycle, or surface recompile. ` +
|
|
350
|
+
`Pin the brain host with TR_LEADER_IP=<mesh-ip> or {"leaderIp":"<mesh-ip>"} in config/leader.json.`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
304
354
|
// Phase 4C: Startup Sync (follower only)
|
|
305
355
|
try {
|
|
306
356
|
const { syncLoop } = await import('./secrets-sync.mjs');
|
|
@@ -653,6 +703,9 @@ async function main() {
|
|
|
653
703
|
}
|
|
654
704
|
|
|
655
705
|
logger.info({ subsystem: 'daemon-loop', message: 'Daemon stopped.' });
|
|
706
|
+
// The watchers keep the event loop alive, so returning from main() is not
|
|
707
|
+
// enough — without this the process lingers and `daemon stop` lies.
|
|
708
|
+
process.exit(0);
|
|
656
709
|
}
|
|
657
710
|
|
|
658
711
|
main().catch(async (err) => {
|
package/src/core/embeddings.mjs
CHANGED
|
@@ -61,15 +61,25 @@ let currentDbPath = null;
|
|
|
61
61
|
const dbVssEnabled = new WeakMap();
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
* better-sqlite3
|
|
65
|
-
*
|
|
64
|
+
* better-sqlite3 appends the platform suffix, so a path that already ends in
|
|
65
|
+
* ".so" reports "vss0.so.so: cannot open shared object file". Strip known
|
|
66
|
+
* extensions before loadExtension.
|
|
67
|
+
*
|
|
68
|
+
* When BOTH attempts fail, rethrow the FIRST error: the second is the artifact
|
|
69
|
+
* of the appended suffix, and logging it hid the real cause on a Linux host —
|
|
70
|
+
* "vss0.so.so: cannot open shared object file" while the actual reason was a
|
|
71
|
+
* missing libblas.so.3 that `ldd vss0.so` names immediately.
|
|
66
72
|
*/
|
|
67
73
|
function loadExtensionBare(db, absolutePath) {
|
|
68
74
|
const bare = String(absolutePath).replace(/\.(so|dylib|dll)$/i, '');
|
|
69
75
|
try {
|
|
70
76
|
db.loadExtension(bare);
|
|
71
|
-
} catch {
|
|
72
|
-
|
|
77
|
+
} catch (bareErr) {
|
|
78
|
+
try {
|
|
79
|
+
db.loadExtension(absolutePath);
|
|
80
|
+
} catch {
|
|
81
|
+
throw bareErr;
|
|
82
|
+
}
|
|
73
83
|
}
|
|
74
84
|
}
|
|
75
85
|
|
package/src/core/fact-seeker.mjs
CHANGED
|
@@ -96,6 +96,20 @@ export function loadAgenda() {
|
|
|
96
96
|
function saveAgenda(topics) {
|
|
97
97
|
const agendaFile = getAgendaFile();
|
|
98
98
|
const dir = path.dirname(agendaFile);
|
|
99
|
+
// Same backstop as the research queue (see research-queue.mjs): a test run must
|
|
100
|
+
// never rewrite the operator's real agenda. The 29 leaked "Automated API
|
|
101
|
+
// Integration Build:" fixture topics came from exactly this hole.
|
|
102
|
+
const isTest = Boolean(process.env.VITEST) || process.env.NODE_ENV === 'test';
|
|
103
|
+
if (
|
|
104
|
+
isTest &&
|
|
105
|
+
!process.env.AGENT_DIR &&
|
|
106
|
+
!process.env._TR_TEST_AGENT_DIR &&
|
|
107
|
+
path.resolve(dir) === path.resolve(getBrainDir())
|
|
108
|
+
) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
'Refusing to write the real research agenda from a test run: set _TR_TEST_AGENT_DIR',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
99
113
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
100
114
|
atomicWrite(agendaFile, topics.map(t => JSON.stringify(t)).join('\n') + '\n');
|
|
101
115
|
}
|
|
@@ -137,6 +151,68 @@ export function addToAgenda({ topic, priority = 50, source, rationale = '', tags
|
|
|
137
151
|
return existing || agenda.find(t => t.topic.toLowerCase() === topic.toLowerCase());
|
|
138
152
|
}
|
|
139
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Status for a topic retired on purpose — distinct from `failed`.
|
|
156
|
+
*
|
|
157
|
+
* A cancelled topic was never attempted, so recording it as a failure
|
|
158
|
+
* misreports pipeline health. See `reclassifyCancelledAgendaTopics()`.
|
|
159
|
+
*/
|
|
160
|
+
export const CANCELLED_STATUS = 'cancelled';
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Retire obsolete key-name scrape topics from the AGENDA.
|
|
164
|
+
*
|
|
165
|
+
* Companion to `cancelBogusApiIntegrationQueueItems()` in
|
|
166
|
+
* secret-integration-research.mjs. That one walks the QUEUE, but the June/July
|
|
167
|
+
* 2026 pollution actually landed in the agenda (29 "Automated API Integration
|
|
168
|
+
* Build: <KEY>" fixture topics), so the designated remediation never touched it.
|
|
169
|
+
* The agenda is fact-seeker's file, so the agenda half lives here.
|
|
170
|
+
*
|
|
171
|
+
* Also sweeps legacy rows still recorded as `failed` with a `cancelled_reason`
|
|
172
|
+
* onto CANCELLED_STATUS.
|
|
173
|
+
*/
|
|
174
|
+
export function cancelBogusAgendaTopics() {
|
|
175
|
+
const agenda = loadAgenda();
|
|
176
|
+
let cancelled = 0;
|
|
177
|
+
for (const t of agenda) {
|
|
178
|
+
if (t.status !== 'pending' && t.status !== 'in_progress') continue;
|
|
179
|
+
if (!/^Automated API Integration Build:/i.test(String(t.topic || ''))) continue;
|
|
180
|
+
t.status = CANCELLED_STATUS;
|
|
181
|
+
t.coverage_score = 0;
|
|
182
|
+
t.cancelled_reason =
|
|
183
|
+
'obsolete key-name scrape job; re-add the secret to trigger AI provider inference';
|
|
184
|
+
cancelled += 1;
|
|
185
|
+
}
|
|
186
|
+
if (cancelled > 0) saveAgenda(agenda);
|
|
187
|
+
return { cancelled, reclassified: reclassifyCancelledAgendaTopics() };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Repair topics that were retired before CANCELLED_STATUS existed.
|
|
192
|
+
*
|
|
193
|
+
* A row carrying `status: 'failed'` *together with* a `cancelled_reason` was
|
|
194
|
+
* cancelled, never attempted — `cancelled_reason` is written only by the
|
|
195
|
+
* cancellation paths. The 2026-09 agenda cleanup retired 231 such topics
|
|
196
|
+
* (204 unbounded follow-ups + 27 obsolete fixtures) as `failed`, so anything
|
|
197
|
+
* tallying `status === 'failed'` reported 231 phantom failures beside 43
|
|
198
|
+
* genuinely pending topics. Move them onto the dedicated status so failure
|
|
199
|
+
* counts mean real failures. Idempotent.
|
|
200
|
+
*
|
|
201
|
+
* @returns {number} rows reclassified
|
|
202
|
+
*/
|
|
203
|
+
export function reclassifyCancelledAgendaTopics() {
|
|
204
|
+
const agenda = loadAgenda();
|
|
205
|
+
let reclassified = 0;
|
|
206
|
+
for (const t of agenda) {
|
|
207
|
+
if (t.status === 'failed' && t.cancelled_reason) {
|
|
208
|
+
t.status = CANCELLED_STATUS;
|
|
209
|
+
reclassified += 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (reclassified > 0) saveAgenda(agenda);
|
|
213
|
+
return reclassified;
|
|
214
|
+
}
|
|
215
|
+
|
|
140
216
|
/**
|
|
141
217
|
* Mark a topic as researched and update its coverage score.
|
|
142
218
|
*/
|
|
@@ -918,9 +994,25 @@ export async function runKnowledgeAcquisitionCycle({
|
|
|
918
994
|
const coverageScore = Math.min(1.0, (results.length / 5) * confidence);
|
|
919
995
|
markTopicResearched(topicId, { coverageScore, sourcesConsulted: sourcesUsed });
|
|
920
996
|
|
|
921
|
-
// Enqueue follow-up research for identified gaps (self-multiplication)
|
|
922
|
-
|
|
923
|
-
|
|
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) {
|
|
924
1016
|
addToAgenda({
|
|
925
1017
|
topic: gap,
|
|
926
1018
|
priority: Math.max(20, topicEntry.priority - 15),
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* hardcoded here — it is resolved from the operator's own secret entry or
|
|
7
7
|
* environment, so this works against any Headscale (or Tailscale SaaS) install.
|
|
8
8
|
*/
|
|
9
|
-
import
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { getSecretsCatalog, getSecret, defaultBrainDir } from './secrets-store.mjs';
|
|
10
11
|
import { throttledFetch } from './throttled-fetch.mjs';
|
|
11
12
|
|
|
12
13
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
@@ -39,10 +40,32 @@ export function normalizeControlUrl(url) {
|
|
|
39
40
|
/**
|
|
40
41
|
* Locate the operator's headscale credential without knowing its key name.
|
|
41
42
|
* Any secret whose provider is `headscale` is treated as the control-server token.
|
|
43
|
+
*
|
|
44
|
+
* The control server is a machine-level resource, not a per-repo one, so the
|
|
45
|
+
* credential normally lives in the GLOBAL brain while callers pass whichever
|
|
46
|
+
* project brain they happen to be running in. Falling back to the global store
|
|
47
|
+
* is what lets `mesh status`, `mesh nodes` and node enrollment work from inside
|
|
48
|
+
* any project checkout — without it every project reported "not configured"
|
|
49
|
+
* while the key sat in ~/.agent the whole time.
|
|
42
50
|
*/
|
|
51
|
+
async function locateHeadscaleKey(brainDir) {
|
|
52
|
+
const target = brainDir || defaultBrainDir();
|
|
53
|
+
|
|
54
|
+
const local = await getSecretsCatalog(target);
|
|
55
|
+
const hit = local.keys.find((k) => k.provider === 'headscale');
|
|
56
|
+
if (hit) return { meta: hit, storeDir: target };
|
|
57
|
+
|
|
58
|
+
const globalDir = defaultBrainDir();
|
|
59
|
+
if (path.resolve(globalDir) === path.resolve(target)) return null;
|
|
60
|
+
|
|
61
|
+
const globalCatalog = await getSecretsCatalog(globalDir);
|
|
62
|
+
const globalHit = globalCatalog.keys.find((k) => k.provider === 'headscale');
|
|
63
|
+
return globalHit ? { meta: globalHit, storeDir: globalDir } : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
43
66
|
export async function findHeadscaleKeyMeta(brainDir) {
|
|
44
|
-
const
|
|
45
|
-
return
|
|
67
|
+
const found = await locateHeadscaleKey(brainDir);
|
|
68
|
+
return found ? found.meta : null;
|
|
46
69
|
}
|
|
47
70
|
|
|
48
71
|
/**
|
|
@@ -51,12 +74,13 @@ export async function findHeadscaleKeyMeta(brainDir) {
|
|
|
51
74
|
* gracefully should use `describeHeadscaleAvailability()` instead.
|
|
52
75
|
*/
|
|
53
76
|
export async function resolveHeadscaleConfig(brainDir) {
|
|
54
|
-
const
|
|
55
|
-
if (!
|
|
77
|
+
const found = await locateHeadscaleKey(brainDir);
|
|
78
|
+
if (!found) {
|
|
56
79
|
throw new Error('Headscale API Key not configured in ApiKeysPage');
|
|
57
80
|
}
|
|
81
|
+
const { meta, storeDir } = found;
|
|
58
82
|
|
|
59
|
-
const got = await getSecret(
|
|
83
|
+
const got = await getSecret(storeDir, meta.key, {
|
|
60
84
|
action: 'use',
|
|
61
85
|
actor: 'headscale-client',
|
|
62
86
|
});
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { getMeshPeers, getMeshSelf } from './mesh.mjs';
|
|
4
|
+
import { brainDir } from './config.mjs';
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Deterministic lowest-mesh-IP leader election.
|
|
@@ -19,6 +22,44 @@ import { getMeshPeers, getMeshSelf } from './mesh.mjs';
|
|
|
19
22
|
/** Documented upper bound for isLeader() flip after the prior leader goes offline. */
|
|
20
23
|
export const FAILOVER_BOUND_MS = 12_000;
|
|
21
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Operator pin for which node leads.
|
|
27
|
+
*
|
|
28
|
+
* Lowest-online-IP is deterministic, but it says nothing about whether that node
|
|
29
|
+
* can actually run the daemon. On this mesh the shared droplet holds the lowest
|
|
30
|
+
* IP (100.64.0.1) and runs no Total Recall at all — it captured leadership
|
|
31
|
+
* permanently, so it could never serve `/api/secrets/sync` and every other node
|
|
32
|
+
* idled as a follower: no research, no dream cycle, no surface recompile,
|
|
33
|
+
* anywhere. Followers skip the task loop entirely, so "lowest IP" quietly became
|
|
34
|
+
* "nobody works".
|
|
35
|
+
*
|
|
36
|
+
* Set `TR_LEADER_IP` to the mesh IP of the machine that owns the canonical brain
|
|
37
|
+
* on EVERY node that runs Total Recall, so they all agree on the answer. A node
|
|
38
|
+
* whose own IP is not the pin never leads. When the pinned node is offline nobody
|
|
39
|
+
* leads — fail-closed, which is the right default for an explicit operator
|
|
40
|
+
* choice, and far better than electing a leader that cannot do the work.
|
|
41
|
+
*/
|
|
42
|
+
export function leaderPin(brain = brainDir) {
|
|
43
|
+
const raw = String(process.env.TR_LEADER_IP || '').trim();
|
|
44
|
+
if (raw) return raw;
|
|
45
|
+
|
|
46
|
+
// File fallback. The env var only exists when the supervisor supplies it:
|
|
47
|
+
// `total-recall daemon start` from a plain shell has no TR_LEADER_IP, so the
|
|
48
|
+
// pin silently disappeared and the node became a follower of the lowest-IP
|
|
49
|
+
// peer — the droplet, which runs no Total Recall. That is the exact
|
|
50
|
+
// "lowest IP quietly became nobody works" outage this pin was added to fix,
|
|
51
|
+
// and it is invisible: the daemon starts, looks healthy, and does nothing.
|
|
52
|
+
try {
|
|
53
|
+
const file = path.join(brain, 'config', 'leader.json');
|
|
54
|
+
if (!fs.existsSync(file)) return null;
|
|
55
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
56
|
+
const ip = String(parsed?.leaderIp || '').trim();
|
|
57
|
+
return ip || null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
22
63
|
function compareNodes(a, b) {
|
|
23
64
|
const ipOrder = String(a.ip || '').localeCompare(String(b.ip || ''), undefined, { numeric: true });
|
|
24
65
|
return ipOrder || String(a.hostname || '').localeCompare(String(b.hostname || ''));
|
|
@@ -31,6 +72,14 @@ function compareNodes(a, b) {
|
|
|
31
72
|
export async function getLeaderInfo() {
|
|
32
73
|
const nodes = getMeshPeers({ includeSelf: true }).filter((node) => node.online);
|
|
33
74
|
if (nodes.length === 0) return null;
|
|
75
|
+
|
|
76
|
+
const pin = leaderPin();
|
|
77
|
+
if (pin) {
|
|
78
|
+
const pinned = nodes.find((node) => node.ip === pin);
|
|
79
|
+
if (!pinned) return null;
|
|
80
|
+
return { hostname: pinned.hostname, ip: pinned.ip, strategy: 'pinned-mesh-ip' };
|
|
81
|
+
}
|
|
82
|
+
|
|
34
83
|
const leader = [...nodes].sort(compareNodes)[0];
|
|
35
84
|
return { hostname: leader.hostname, ip: leader.ip, strategy: 'lowest-mesh-ip' };
|
|
36
85
|
}
|
|
@@ -28,6 +28,27 @@ function getQueueFile(overrideBrainDir) {
|
|
|
28
28
|
return path.join(dir, 'research-queue.jsonl');
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Refuse to write the operator's REAL research state from inside a test run.
|
|
33
|
+
*
|
|
34
|
+
* The June/July 2026 agenda accumulated 29 "Automated API Integration Build:"
|
|
35
|
+
* fixture entries plus a queue file the daemon never drained, because a code
|
|
36
|
+
* path reached the default brain while tests were running. Threading `brainDir`
|
|
37
|
+
* through every caller is the real fix; this is the backstop, so a future caller
|
|
38
|
+
* that forgets it fails loudly instead of quietly polluting real data. Tests
|
|
39
|
+
* point at a temp brain with _TR_TEST_AGENT_DIR (or AGENT_DIR).
|
|
40
|
+
*/
|
|
41
|
+
function assertNotRealBrainInTest(dir, what) {
|
|
42
|
+
const isTest = Boolean(process.env.VITEST) || process.env.NODE_ENV === 'test';
|
|
43
|
+
if (!isTest) return;
|
|
44
|
+
if (process.env._TR_TEST_AGENT_DIR || process.env.AGENT_DIR) return;
|
|
45
|
+
if (path.resolve(dir) === path.resolve(getBrainDir())) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Refusing to write the real ${what} from a test run: pass an explicit brainDir or set _TR_TEST_AGENT_DIR`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
const STATUS_RANK = { pending: 0, in_progress: 1, done: 2, failed: 3 };
|
|
32
53
|
|
|
33
54
|
// ─── Summary Compiler ─────────────────────────────────────────────────────────
|
|
@@ -278,6 +299,7 @@ export function loadQueue(overrideBrainDir) {
|
|
|
278
299
|
|
|
279
300
|
export function saveQueue(items, overrideBrainDir) {
|
|
280
301
|
const queueFile = getQueueFile(overrideBrainDir);
|
|
302
|
+
assertNotRealBrainInTest(path.dirname(queueFile), 'research queue');
|
|
281
303
|
fs.mkdirSync(path.dirname(queueFile), { recursive: true });
|
|
282
304
|
fs.writeFileSync(queueFile, items.map(i => JSON.stringify(i)).join('\n') + '\n', 'utf8');
|
|
283
305
|
}
|
package/src/core/research.mjs
CHANGED
|
@@ -92,6 +92,17 @@ export async function handleProactiveResearch(task, context = {}) {
|
|
|
92
92
|
draftSlug = saveSynthesizedReportToDraft(task.target, finalReport, inboxDir) || draftSlug;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// Phase 4b: Promote the finished report into the vault. The deliberation phase
|
|
96
|
+
// resolves its target from the vault, not the inbox, so without this the
|
|
97
|
+
// five-phase pipeline always stalls at step 2 with "Target node not found".
|
|
98
|
+
if (draftSlug) {
|
|
99
|
+
await promoteDraftToVault(
|
|
100
|
+
task.target,
|
|
101
|
+
inboxDir,
|
|
102
|
+
context.vaultDir || path.join(BRAIN_DIR, 'memory-vault'),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
95
106
|
// Phase 5: Also add topic to the Research Agenda for ongoing tracking
|
|
96
107
|
addToAgenda({
|
|
97
108
|
topic: task.target,
|
|
@@ -514,6 +525,66 @@ export function saveSynthesizedReportToDraft(parentTopic, finalReport, inboxDir)
|
|
|
514
525
|
return slug;
|
|
515
526
|
}
|
|
516
527
|
|
|
528
|
+
/**
|
|
529
|
+
* Promote a synthesized report out of the inbox into the vault.
|
|
530
|
+
*
|
|
531
|
+
* Acquisition stages every report as `status: draft` under
|
|
532
|
+
* `memory-inbox/pending`, but the Phase 2/5 deliberation cycle resolves its
|
|
533
|
+
* target with `getNodes(vaultDir).find(n => n.slug === nodeSlug)`. A draft that
|
|
534
|
+
* never leaves the inbox therefore made every deliberation task fail with
|
|
535
|
+
* "Target node not found for slug: …", so the five-phase pipeline stalled at
|
|
536
|
+
* step 2 on every topic no matter how good the report was.
|
|
537
|
+
*
|
|
538
|
+
* The documented flow gates on confidence: >= 0.7 is a "Direct Vault Write &
|
|
539
|
+
* Immediate Surface Recompile", below that stays staged for review. Synthesized
|
|
540
|
+
* reports carry confidence 0.9, so they take the fast path.
|
|
541
|
+
*/
|
|
542
|
+
export const VAULT_PROMOTION_MIN_CONFIDENCE = 0.7;
|
|
543
|
+
|
|
544
|
+
export async function promoteDraftToVault(parentTopic, inboxDir, vaultDir) {
|
|
545
|
+
const slug = `research-report-${slugify(parentTopic)}`;
|
|
546
|
+
const filePath = path.join(inboxDir, `${slug}.md`);
|
|
547
|
+
if (!fs.existsSync(filePath)) return null;
|
|
548
|
+
|
|
549
|
+
let parsed;
|
|
550
|
+
try {
|
|
551
|
+
parsed = matter(fs.readFileSync(filePath, 'utf8'));
|
|
552
|
+
} catch (err) {
|
|
553
|
+
logger.warn({ subsystem: 'deep-research', message: `Cannot read draft ${slug}: ${err.message}` });
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const confidence = Number(parsed.data?.confidence);
|
|
558
|
+
if (!Number.isFinite(confidence) || confidence < VAULT_PROMOTION_MIN_CONFIDENCE) {
|
|
559
|
+
logger.info({
|
|
560
|
+
subsystem: 'deep-research',
|
|
561
|
+
message: `Report ${slug} stays in the inbox (confidence ${parsed.data?.confidence ?? 'unset'} < ${VAULT_PROMOTION_MIN_CONFIDENCE})`,
|
|
562
|
+
});
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// The draft already carries the full SSSS frontmatter; promotion only flips it
|
|
567
|
+
// from staged-for-review to a live vault node.
|
|
568
|
+
const node = { ...parsed.data, slug, status: 'active', body: parsed.content.trim() };
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
const { writeNode } = await import('./vault.mjs');
|
|
572
|
+
await writeNode(node, vaultDir || path.join(BRAIN_DIR, 'memory-vault'));
|
|
573
|
+
} catch (err) {
|
|
574
|
+
logger.warn({
|
|
575
|
+
subsystem: 'deep-research',
|
|
576
|
+
message: `Vault promotion failed for ${slug}: ${err.message}`,
|
|
577
|
+
});
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
logger.info({
|
|
582
|
+
subsystem: 'deep-research',
|
|
583
|
+
message: `Promoted ${slug} to the vault (confidence ${confidence}) — deliberation can now resolve it`,
|
|
584
|
+
});
|
|
585
|
+
return slug;
|
|
586
|
+
}
|
|
587
|
+
|
|
517
588
|
// ─── Synthesis ───────────────────────────────────────────────────────────────────
|
|
518
589
|
|
|
519
590
|
async function synthesizeLocally(task, results, runtimeConfig) {
|
package/src/core/runtime.mjs
CHANGED
|
@@ -277,6 +277,64 @@ function resolveAgent(config) {
|
|
|
277
277
|
* @param {object} config - Runtime config from loadRuntimeConfig()
|
|
278
278
|
* @returns {string} The agent's text response
|
|
279
279
|
*/
|
|
280
|
+
/**
|
|
281
|
+
* Merge every secrets layer the runtime can see.
|
|
282
|
+
*
|
|
283
|
+
* Precedence, lowest first (each later layer overrides the earlier ones):
|
|
284
|
+
* 1. <agentRoot>/secrets.enc legacy flat store
|
|
285
|
+
* 2. <cwd>/.agent/secrets.enc legacy flat store
|
|
286
|
+
* 3. ~/.agent/skills/total-recall/config/secrets.enc global brain
|
|
287
|
+
* 4. <agentRoot>/skills/total-recall/config/secrets.enc project brain
|
|
288
|
+
*
|
|
289
|
+
* Project-over-global matches `resolveBrainDir('auto')`, which every other TR
|
|
290
|
+
* component already uses: the project store is the more specific answer. The
|
|
291
|
+
* legacy flat stores are leftovers from older installs — they may still hold
|
|
292
|
+
* keys nothing else has, but they must never mask a canonical brain store.
|
|
293
|
+
*
|
|
294
|
+
* The previous implementation took the first path that held ANY keys and
|
|
295
|
+
* stopped, in legacy-first order. A stale 12-key <cwd>/.agent/secrets.enc
|
|
296
|
+
* therefore shadowed the 100-key brain store completely, and its dead
|
|
297
|
+
* `google_api_key` (AIza…, HTTP 400 from generativelanguage) overrode the
|
|
298
|
+
* working one — so every spawned agent received a key that cannot call Gemini
|
|
299
|
+
* while the good key sat unreachable one layer down.
|
|
300
|
+
*
|
|
301
|
+
* Exported for tests; callers use the defaults.
|
|
302
|
+
*
|
|
303
|
+
* @param {{ cwd?: string, agentRoot?: string, home?: string }} [opts]
|
|
304
|
+
* @returns {Promise<Record<string, string>>}
|
|
305
|
+
*/
|
|
306
|
+
export async function loadDynamicSecrets(opts = {}) {
|
|
307
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
308
|
+
const agentRoot = opts.agentRoot ?? agentDir;
|
|
309
|
+
const home = opts.home ?? os.homedir();
|
|
310
|
+
|
|
311
|
+
const { loadSecretsSync } = await import('./secrets-store.mjs');
|
|
312
|
+
const layers = [
|
|
313
|
+
path.join(agentRoot, 'secrets.enc'),
|
|
314
|
+
path.join(cwd, '.agent', 'secrets.enc'),
|
|
315
|
+
path.join(home, '.agent', 'skills', 'total-recall', 'config', 'secrets.enc'),
|
|
316
|
+
path.join(agentRoot, 'skills', 'total-recall', 'config', 'secrets.enc'),
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
const merged = {};
|
|
320
|
+
for (const p of layers) {
|
|
321
|
+
if (!fs.existsSync(p)) continue;
|
|
322
|
+
try {
|
|
323
|
+
const isConfigPath = p.includes(path.join('config', 'secrets.enc'));
|
|
324
|
+
const dir = isConfigPath ? path.dirname(path.dirname(p)) : path.dirname(p);
|
|
325
|
+
const loaded = loadSecretsSync(dir);
|
|
326
|
+
for (const [k, v] of Object.entries(loaded)) {
|
|
327
|
+
if (k === '__tr_secrets_meta') continue;
|
|
328
|
+
if (v) merged[k] = v;
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
// An unreadable or undecryptable layer must not break dispatch; the
|
|
332
|
+
// remaining layers still apply.
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return merged;
|
|
336
|
+
}
|
|
337
|
+
|
|
280
338
|
export async function callLocalRuntime(prompt, system, config) {
|
|
281
339
|
// Pre-flight budget watchdog safety check
|
|
282
340
|
checkBudgetSafety();
|
|
@@ -292,29 +350,8 @@ export async function callLocalRuntime(prompt, system, config) {
|
|
|
292
350
|
|
|
293
351
|
let lastError;
|
|
294
352
|
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
try {
|
|
298
|
-
const pathsToCheck = [
|
|
299
|
-
path.join(process.cwd(), '.agent', 'secrets.enc'),
|
|
300
|
-
path.join(agentDir, 'secrets.enc'),
|
|
301
|
-
path.join(os.homedir(), '.agent', 'skills', 'total-recall', 'config', 'secrets.enc'),
|
|
302
|
-
path.join(agentDir, 'skills', 'total-recall', 'config', 'secrets.enc'),
|
|
303
|
-
];
|
|
304
|
-
for (const p of pathsToCheck) {
|
|
305
|
-
if (fs.existsSync(p)) {
|
|
306
|
-
try {
|
|
307
|
-
const { loadSecretsSync } = await import('./secrets-store.mjs');
|
|
308
|
-
const isConfigPath = p.includes(path.join('config', 'secrets.enc'));
|
|
309
|
-
const brainDir = isConfigPath ? path.dirname(path.dirname(p)) : path.dirname(p);
|
|
310
|
-
dynamicSecrets = loadSecretsSync(brainDir);
|
|
311
|
-
if (Object.keys(dynamicSecrets).length > 0) {
|
|
312
|
-
break;
|
|
313
|
-
}
|
|
314
|
-
} catch {}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
} catch {}
|
|
353
|
+
// Loaded on every dispatch so a store edit takes effect without a restart.
|
|
354
|
+
const dynamicSecrets = await loadDynamicSecrets();
|
|
318
355
|
|
|
319
356
|
// Try agents in priority order, never re-trying one that already failed.
|
|
320
357
|
//
|
|
@@ -397,8 +434,11 @@ export async function callLocalRuntime(prompt, system, config) {
|
|
|
397
434
|
promptLength: fullPrompt.length,
|
|
398
435
|
});
|
|
399
436
|
|
|
400
|
-
// Pre-flight command execution validation
|
|
401
|
-
|
|
437
|
+
// Pre-flight command execution validation. The spawn below passes an argv
|
|
438
|
+
// array (no shell), so a prompt payload is one inert argument — scan only the
|
|
439
|
+
// unquoted part, or any vault fact quoting a `curl … | bash` install line
|
|
440
|
+
// trips a false "piping download tool to shell" exception on every dispatch.
|
|
441
|
+
validateCommand(cmd, { argvSpawn: true });
|
|
402
442
|
|
|
403
443
|
const xaiKey =
|
|
404
444
|
process.env.XAI_API_KEY ||
|