total-recall-brain 3.18.3 → 3.19.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 (50) hide show
  1. package/frontend/dist/assets/{index-CGqvdwHT.js → index-B6AfuKv9.js} +115 -115
  2. package/frontend/dist/index.html +1 -1
  3. package/frontend/src/api/update.ts +29 -4
  4. package/frontend/src/pages/HealthPage.tsx +12 -16
  5. package/frontend/src/pages/SettingsPage.tsx +22 -1
  6. package/package.json +1 -1
  7. package/src/cli/agent-dir.mjs +15 -1
  8. package/src/cli/chat.mjs +1 -1
  9. package/src/cli/daemon.mjs +2 -1
  10. package/src/cli/dream.mjs +14 -10
  11. package/src/cli/forget.mjs +14 -2
  12. package/src/cli/friction.mjs +1 -1
  13. package/src/cli/import-rules.mjs +8 -3
  14. package/src/cli/ingest.mjs +3 -2
  15. package/src/cli/lint.mjs +9 -6
  16. package/src/cli/rebuild.mjs +9 -4
  17. package/src/cli/remember.mjs +21 -15
  18. package/src/cli/secret.mjs +4 -1
  19. package/src/cli/share.mjs +5 -3
  20. package/src/cli/skill-projection.mjs +21 -2
  21. package/src/cli/snapshot.mjs +10 -8
  22. package/src/cli/update.mjs +2 -1
  23. package/src/core/clarity-rewriter.mjs +31 -12
  24. package/src/core/config.mjs +10 -4
  25. package/src/core/daemon-loop.mjs +2 -1
  26. package/src/core/embeddings.mjs +51 -3
  27. package/src/core/memory-title.mjs +70 -0
  28. package/src/core/runtime.mjs +11 -1
  29. package/src/core/secrets-store.mjs +7 -1
  30. package/src/core/skills-registry.mjs +12 -0
  31. package/src/core/surface.mjs +4 -4
  32. package/src/core/throttled-fetch.mjs +5 -0
  33. package/src/core/vault.mjs +7 -1
  34. package/src/server/index.mjs +56 -0
  35. package/src/server/route-manifest.json +16 -0
  36. package/src/server/routes/_shared.mjs +33 -1
  37. package/src/server/routes/context.mjs +14 -9
  38. package/src/server/routes/dashboard.mjs +39 -18
  39. package/src/server/routes/docs.mjs +27 -13
  40. package/src/server/routes/dream.mjs +10 -11
  41. package/src/server/routes/export.mjs +43 -12
  42. package/src/server/routes/field.mjs +11 -9
  43. package/src/server/routes/graph.mjs +31 -13
  44. package/src/server/routes/import.mjs +35 -16
  45. package/src/server/routes/instructions.mjs +41 -5
  46. package/src/server/routes/memory.mjs +61 -21
  47. package/src/server/routes/share.mjs +2 -1
  48. package/src/server/routes/ssss.mjs +11 -3
  49. package/src/server/routes/update.mjs +22 -3
  50. package/src/server/routes/vault.mjs +53 -28
@@ -567,24 +567,42 @@ export async function runCutoffAudit({ vaultDir, queueDir, runtimeConfig }) {
567
567
  return { audited: batch.length, flagged, critical };
568
568
  }
569
569
 
570
+ /** Resolve vault dir from node file path (…/memory-vault/category/slug.md). */
571
+ function vaultDirFromNodeFile(filePath) {
572
+ if (!filePath) return null;
573
+ let cur = path.dirname(filePath);
574
+ while (cur && cur !== path.dirname(cur)) {
575
+ if (path.basename(cur) === 'memory-vault') return cur;
576
+ cur = path.dirname(cur);
577
+ }
578
+ // Fallback: parent of category dir
579
+ return path.dirname(path.dirname(filePath));
580
+ }
581
+
582
+ function nodeFilePath(node) {
583
+ return node?._filepath || node?._filePath || null;
584
+ }
585
+
570
586
  async function stampVerified(node) {
571
- if (!node._filepath) return;
587
+ const fp = nodeFilePath(node);
588
+ if (!fp) return;
572
589
  try {
573
- const raw = fs.readFileSync(node._filepath, 'utf8');
590
+ const raw = fs.readFileSync(fp, 'utf8');
574
591
  const { data, content } = matter(raw);
575
592
  data.x_cutoff_verified_at = new Date().toISOString();
576
593
  data.x_cutoff_risk = 'none';
577
- atomicWrite(node._filepath, safeStringify(content, data));
578
- invalidate(path.dirname(node._filepath));
594
+ atomicWrite(fp, safeStringify(content, data));
595
+ invalidate(vaultDirFromNodeFile(fp));
579
596
  } catch (err) {
580
597
  logger.debug('clarity-rewriter: stampCutoffRiskNone failed', { err: err.message });
581
598
  }
582
599
  }
583
600
 
584
601
  async function stampCutoffRisk(node, riskLevel, claimCount) {
585
- if (!node._filepath) return;
602
+ const fp = nodeFilePath(node);
603
+ if (!fp) return;
586
604
  try {
587
- const raw = fs.readFileSync(node._filepath, 'utf8');
605
+ const raw = fs.readFileSync(fp, 'utf8');
588
606
  const { data, content } = matter(raw);
589
607
  data.x_cutoff_risk = riskLevel;
590
608
  data.x_cutoff_claim_count = claimCount;
@@ -592,8 +610,8 @@ async function stampCutoffRisk(node, riskLevel, claimCount) {
592
610
  // Lower confidence to reflect that claims are unverified
593
611
  const confidencePenalty = riskLevel === 'critical' ? 0.3 : riskLevel === 'high' ? 0.2 : 0.1;
594
612
  data.confidence = Math.max(0.1, (data.confidence || 0.5) - confidencePenalty);
595
- atomicWrite(node._filepath, safeStringify(content, data));
596
- invalidate(path.dirname(node._filepath));
613
+ atomicWrite(fp, safeStringify(content, data));
614
+ invalidate(vaultDirFromNodeFile(fp));
597
615
  } catch (err) {
598
616
  logger.debug('clarity-rewriter: stampCutoffRisk failed', { err: err.message });
599
617
  }
@@ -756,9 +774,10 @@ export async function writeCorrection(originalSlug, researchEvidence, { vaultDir
756
774
  }
757
775
 
758
776
  async function stampNodeForCorrection(node, correctionSlug, disposition) {
759
- if (!node._filepath) return;
777
+ const fp = nodeFilePath(node);
778
+ if (!fp) return;
760
779
  try {
761
- const raw = fs.readFileSync(node._filepath, 'utf8');
780
+ const raw = fs.readFileSync(fp, 'utf8');
762
781
  const { data, content } = matter(raw);
763
782
  data.status = disposition === 'deleted' ? 'archived' : 'superseded';
764
783
  data.superseded_by = correctionSlug;
@@ -766,8 +785,8 @@ async function stampNodeForCorrection(node, correctionSlug, disposition) {
766
785
  // Lower confidence significantly — this fact has been proven wrong
767
786
  data.confidence = Math.max(0.05, (data.confidence || 0.5) * 0.3);
768
787
  data.updated = new Date().toISOString();
769
- atomicWrite(node._filepath, safeStringify(content, data));
770
- invalidate(path.dirname(node._filepath));
788
+ atomicWrite(fp, safeStringify(content, data));
789
+ invalidate(vaultDirFromNodeFile(fp));
771
790
  } catch (err) {
772
791
  logger.debug('clarity-rewriter: stampNodeForCorrection failed', { err: err.message });
773
792
  }
@@ -48,6 +48,7 @@ const configSchema = z.object({
48
48
  cliModel: z.string().optional(),
49
49
  cliTimeout: z.preprocess((val) => val === undefined || val === '' ? 300 : parseInt(String(val), 10), z.number().int().default(300)),
50
50
  googleApiKey: z.string().optional(),
51
+ openRouterApiKey: z.string().optional(),
51
52
  embedModel: z.string().default('gemini-embedding-2'),
52
53
  searxngBaseUrl: z.string().optional(),
53
54
  braveApiKey: z.string().optional(),
@@ -151,10 +152,13 @@ try {
151
152
  const isConfigPath = p.includes(path.join('config', 'secrets.enc'));
152
153
  const brainDir = isConfigPath ? path.dirname(path.dirname(p)) : path.dirname(p);
153
154
  const parsed = loadSecretsSync(brainDir);
154
- if (Object.keys(parsed).length > 0) {
155
- secrets = parsed;
156
- break; // Found valid secrets!
157
- }
155
+ // Merge (don't stop at the first non-empty file): an earlier, sparser
156
+ // store (e.g. the global ~/.agent/secrets.enc root) was previously
157
+ // short-circuiting the search and silently hiding real secrets that
158
+ // only existed in a later, richer store (e.g. the total-recall
159
+ // skill's own config/secrets.enc). Earlier candidates still win on
160
+ // key collisions — this only fills in keys the earlier ones lack.
161
+ secrets = { ...parsed, ...secrets };
158
162
  } catch {}
159
163
  }
160
164
  }
@@ -178,6 +182,7 @@ const rawConfig = {
178
182
  cliModel: process.env.TR_CLI_MODEL,
179
183
  cliTimeout: process.env.TR_CLI_TIMEOUT,
180
184
  googleApiKey: process.env.GOOGLE_API_KEY || secrets.google_api_key,
185
+ openRouterApiKey: process.env.OPENROUTER_API_KEY || secrets.openrouter_api_key || secrets.OPENROUTER_API_KEY,
181
186
  embedModel: process.env.TR_EMBED_MODEL,
182
187
  searxngBaseUrl: process.env.SEARXNG_BASE_URL,
183
188
  braveApiKey: process.env.BRAVE_SEARCH_API_KEY || process.env.BRAVE_API_KEY || secrets.brave_api_key,
@@ -225,6 +230,7 @@ export const {
225
230
  cliModel,
226
231
  cliTimeout,
227
232
  googleApiKey,
233
+ openRouterApiKey,
228
234
  embedModel,
229
235
  searxngBaseUrl,
230
236
  braveApiKey,
@@ -31,7 +31,8 @@ const DERIVED_DIR = path.join(BRAIN_DIR, 'memory-derived');
31
31
  const CONFLICTS_DIR = path.join(BRAIN_DIR, 'memory-inbox', 'conflicts');
32
32
  const SESSIONS_DIR = path.join(BRAIN_DIR, 'sessions');
33
33
  const QUEUE_DIR = path.join(BRAIN_DIR, 'scheduler', 'queue');
34
- const INSTRUCTIONS_FILE = path.join(BRAIN_DIR, 'INSTRUCTIONS.md');
34
+ // Surface shims resolve from the agent root (.agent/), not brainDir (…/skills/total-recall/)
35
+ const INSTRUCTIONS_FILE = path.join(AGENT_DIR, 'INSTRUCTIONS.md');
35
36
 
36
37
  // How often to recompile surfaces (every N task ticks)
37
38
  const RECOMPILE_EVERY_N_TASKS = 20;
@@ -20,7 +20,7 @@ import {
20
20
  getVssLoadablePath,
21
21
  } from 'sqlite-vss';
22
22
 
23
- import { googleApiKey, embedModel, brainDir as defaultBrainDir } from './config.mjs';
23
+ import { googleApiKey, openRouterApiKey, embedModel, brainDir as defaultBrainDir } from './config.mjs';
24
24
  import { logger } from './logger.mjs';
25
25
 
26
26
  const DEFAULT_EMBED_MODEL = embedModel;
@@ -34,6 +34,10 @@ function resolveOpenAiApiKey() {
34
34
  return process.env.OPENAI_API_KEY || null;
35
35
  }
36
36
 
37
+ function resolveOpenRouterApiKey() {
38
+ return openRouterApiKey || process.env.OPENROUTER_API_KEY || null;
39
+ }
40
+
37
41
  let dbInstance = null;
38
42
  let currentDbPath = null;
39
43
  /** @type {WeakMap<object, boolean>} */
@@ -227,6 +231,37 @@ async function getGoogleEmbedding(text, model) {
227
231
  return data.embedding.values;
228
232
  }
229
233
 
234
+ async function getOpenRouterEmbedding(text, model = 'openai/text-embedding-3-small') {
235
+ const openRouterKey = resolveOpenRouterApiKey();
236
+ if (!openRouterKey) {
237
+ throw new Error('OPENROUTER_API_KEY not set. Cannot generate embeddings.');
238
+ }
239
+
240
+ const res = await throttledFetch('https://openrouter.ai/api/v1/embeddings', {
241
+ method: 'POST',
242
+ headers: {
243
+ 'Content-Type': 'application/json',
244
+ 'Authorization': `Bearer ${openRouterKey}`,
245
+ },
246
+ // Request 768 dims to match the existing vault vss0(embedding(768)) schema
247
+ // and stay comparable against previously-stored Google embeddings.
248
+ body: JSON.stringify({ model, input: text, dimensions: 768 }),
249
+ signal: AbortSignal.timeout(30000),
250
+ }, 20000);
251
+
252
+ if (!res.ok) {
253
+ const body = await res.text().catch(() => '');
254
+ throw new Error(`OpenRouter embedding API error ${res.status}: ${body || 'no response body'}`);
255
+ }
256
+
257
+ const data = await res.json();
258
+ if (!data.data?.[0]?.embedding) {
259
+ throw new Error('OpenRouter returned no embedding vector');
260
+ }
261
+
262
+ return data.data[0].embedding;
263
+ }
264
+
230
265
  async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
231
266
  const openaiKey = resolveOpenAiApiKey();
232
267
  if (!openaiKey) {
@@ -258,11 +293,24 @@ async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
258
293
 
259
294
  export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
260
295
  const input = String(text).slice(0, 8000);
296
+ const openRouterKey = resolveOpenRouterApiKey();
261
297
  const googleKey = resolveGoogleApiKey();
262
298
  const openaiKey = resolveOpenAiApiKey();
263
299
 
264
300
  let embedding;
265
- if (googleKey) {
301
+
302
+ // OpenRouter first: standing preference (Gemini's budget is exhausted, so
303
+ // Google is a near-guaranteed slow failure right now — trying it first just
304
+ // taxes every call with a ~15-20s timeout before falling through).
305
+ if (openRouterKey) {
306
+ try {
307
+ embedding = await getOpenRouterEmbedding(input);
308
+ } catch (err) {
309
+ logger.debug('embeddings: OpenRouter failed, trying Google fallback', { err: err.message });
310
+ }
311
+ }
312
+
313
+ if (!embedding && googleKey) {
266
314
  try {
267
315
  embedding = await getGoogleEmbedding(input, model);
268
316
  } catch (err) {
@@ -280,7 +328,7 @@ export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
280
328
  }
281
329
 
282
330
  if (!embedding) {
283
- throw new Error('No embedding provider available. Set GOOGLE_API_KEY or OPENAI_API_KEY.');
331
+ throw new Error('No embedding provider available. Set OPENROUTER_API_KEY, GOOGLE_API_KEY, or OPENAI_API_KEY.');
284
332
  }
285
333
 
286
334
  return embedding;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Memory title helpers — provenance belongs in source/tags, not a title prefix.
3
+ */
4
+
5
+ const SELF_CAPTURED_PREFIX_RE = /^Self-captured memory:\s*/i;
6
+
7
+ /**
8
+ * Strip legacy auto-title prefix if present.
9
+ * @param {string} title
10
+ * @returns {string}
11
+ */
12
+ export function stripSelfCapturedTitlePrefix(title) {
13
+ return String(title || '').replace(SELF_CAPTURED_PREFIX_RE, '').trim();
14
+ }
15
+
16
+ /**
17
+ * True when title is the old auto-generated echo of the body.
18
+ * @param {string} title
19
+ * @param {string} [body]
20
+ */
21
+ export function isSelfCapturedEchoTitle(title, body = '') {
22
+ const raw = String(title || '').trim();
23
+ if (SELF_CAPTURED_PREFIX_RE.test(raw)) return true;
24
+ const stripped = stripSelfCapturedTitlePrefix(raw);
25
+ const text = String(body || '').replace(/\s+/g, ' ').trim();
26
+ if (!stripped || !text) return false;
27
+ const head = stripped.replace(/\.\.\.$/, '').trim().toLowerCase();
28
+ if (head.length < 12) return false;
29
+ return text.toLowerCase().startsWith(head.slice(0, Math.min(40, head.length)));
30
+ }
31
+
32
+ /**
33
+ * Build a short human title from body content (no provenance prefix).
34
+ * Uses first sentence when reasonable, else first ~100 chars.
35
+ * @param {string} body
36
+ * @returns {string}
37
+ */
38
+ export function defaultTitleFromBody(body) {
39
+ const oneLine = String(body || '')
40
+ .replace(/\s+/g, ' ')
41
+ .trim();
42
+ if (!oneLine) return 'Untitled memory';
43
+
44
+ // Prefer first sentence
45
+ const m = oneLine.match(/^(.{12,}?[.!?])(\s|$)/);
46
+ let candidate = m ? m[1].trim() : oneLine;
47
+
48
+ // Avoid absurdly long single-sentence titles
49
+ if (candidate.length > 100) {
50
+ candidate = candidate.slice(0, 97).replace(/\s+\S*$/, '').trim() + '...';
51
+ }
52
+ return candidate || oneLine.slice(0, 100);
53
+ }
54
+
55
+ /**
56
+ * Normalize an existing title that may carry the legacy prefix.
57
+ * If after strip the title is empty or still an echo of body, rebuild from body.
58
+ * @param {string} title
59
+ * @param {string} body
60
+ * @returns {string}
61
+ */
62
+ export function normalizeMemoryTitle(title, body) {
63
+ const stripped = stripSelfCapturedTitlePrefix(title);
64
+ if (!stripped) return defaultTitleFromBody(body);
65
+ if (isSelfCapturedEchoTitle(title, body) || isSelfCapturedEchoTitle(stripped, body)) {
66
+ // Prefer full first sentence from body over truncated echo
67
+ return defaultTitleFromBody(body);
68
+ }
69
+ return stripped;
70
+ }
@@ -139,12 +139,22 @@ export function loadRuntimeConfig(_configPath) {
139
139
  }
140
140
 
141
141
  // Tier D: Check SSSS Compiled Memory Preference Surface
142
+ // Surfaces live at agent root / repo root — not under brainDir (…/skills/total-recall/)
142
143
  if (!preferredAgent) {
143
144
  try {
144
145
  const checkPaths = [
146
+ path.join(agentDir, 'INSTRUCTIONS.md'),
147
+ path.join(agentDir, 'AGENTS.md'),
148
+ path.join(agentDir, 'GEMINI.md'),
149
+ path.join(path.dirname(agentDir), 'INSTRUCTIONS.md'),
150
+ path.join(path.dirname(agentDir), 'AGENTS.md'),
151
+ path.join(process.cwd(), 'INSTRUCTIONS.md'),
152
+ path.join(process.cwd(), 'AGENTS.md'),
153
+ path.join(process.cwd(), 'Claude.md'),
154
+ // legacy mistaken brainDir locations (kept as last resort)
145
155
  path.join(brainDir, 'INSTRUCTIONS.md'),
146
156
  path.join(brainDir, 'GEMINI.md'),
147
- path.join(brainDir, 'AGENTS.md')
157
+ path.join(brainDir, 'AGENTS.md'),
148
158
  ];
149
159
  for (const p of checkPaths) {
150
160
  if (fs.existsSync(p)) {
@@ -760,7 +760,13 @@ export async function setSecret(brainDir, key, value, opts = {}) {
760
760
  });
761
761
 
762
762
  // First-time set only: optionally queue product-level API research (not raw key-name scraping).
763
- if (!prev.created_at && opts.skip_integration_research !== true) {
763
+ // Skip under Vitest / NODE_ENV=test — enqueue can hang on network and blow 5s timeouts.
764
+ // Force research in tests with skip_integration_research: false.
765
+ const inTest = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test';
766
+ const skipResearch =
767
+ opts.skip_integration_research === true ||
768
+ (inTest && opts.skip_integration_research !== false);
769
+ if (!prev.created_at && !skipResearch) {
764
770
  try {
765
771
  const { maybeEnqueueIntegrationResearch } = await import('./secret-integration-research.mjs');
766
772
  const result = await maybeEnqueueIntegrationResearch(brainDir, key, {
@@ -552,6 +552,18 @@ export function isRepoScopedSkill(brainDir, skillId) {
552
552
  entry?.source_path,
553
553
  ...registry.installs.filter((install) => install.skill_id === skillId).map((install) => install.path),
554
554
  ].filter(Boolean);
555
+ // Also examine copies not yet tracked in the registry. collectSkillLocations()
556
+ // (what syncSkillTwoWay actually acts on) additionally scans every known repo
557
+ // for live-but-undiscovered copies of this skill id. Without doing the same
558
+ // here, the very first sync that ever touches a newly-known repo only sees
559
+ // the catalog source as a candidate, never notices that repo's same-named
560
+ // skill diverges, and lets an unrelated same-named skill silently clobber it
561
+ // before either copy is ever registered.
562
+ for (const repo of loadKnownRepoRoots(brainDir)) {
563
+ for (const hit of discoverSkillsInRepo(repo)) {
564
+ if (hit.id === skillId) candidates.push(hit.path);
565
+ }
566
+ }
555
567
  const seen = new Set();
556
568
  const liveRepos = new Set();
557
569
  const liveHashes = new Set();
@@ -186,16 +186,16 @@ function modalityMarker(node) {
186
186
  */
187
187
  export function heuristicCompact(node) {
188
188
  const rawTitle = (node.title || '').trim();
189
+ // Legacy prefix only — provenance is source.type / tags, not title text
189
190
  const title = rawTitle.replace(/^Self-captured memory:\s*/i, '').trim();
190
191
  const text = (node.body || node.content || '').trim();
191
192
  const marker = modalityMarker(node);
192
193
  const prefix = marker ? `${marker} ` : '';
193
194
 
194
195
  // Determine the best display text, avoiding title/body duplication.
195
- // When title is auto-generated ("Self-captured memory: ...") it echoes the body —
196
- // use the body directly to avoid doubling the same content.
197
- const titleIsEcho = rawTitle.startsWith('Self-captured memory:') ||
198
- (text && title.length > 20 && text.toLowerCase().startsWith(title.toLowerCase().slice(0, 20)));
196
+ // Echo titles (legacy "Self-captured…" or title that is just a body prefix) use body only.
197
+ const titleIsEcho = /^Self-captured memory:/i.test(rawTitle) ||
198
+ (text && title.length > 20 && text.toLowerCase().startsWith(title.toLowerCase().replace(/\.\.\.$/, '').slice(0, 20)));
199
199
 
200
200
  if (node.category && ['invariants', 'preferences', 'anti-patterns'].includes(node.category)) {
201
201
  if (text) {
@@ -140,6 +140,9 @@ export async function loadFirewallPolicy(brainDir) {
140
140
  policyWatcher = fs.watch(policyPath, (eventType) => {
141
141
  if (eventType === 'change') applyPolicy();
142
142
  });
143
+ // Unref so this watcher does not prevent one-shot CLI commands (e.g.
144
+ // `recall`) from exiting cleanly once their work is done.
145
+ policyWatcher.unref();
143
146
  }
144
147
 
145
148
  // Also watch the parent directory so we pick up the policy file being
@@ -153,8 +156,10 @@ export async function loadFirewallPolicy(brainDir) {
153
156
  policyWatcher = fs.watch(policyPath, (evt) => {
154
157
  if (evt === 'change') applyPolicy();
155
158
  });
159
+ policyWatcher.unref();
156
160
  }
157
161
  });
162
+ parentWatcher.unref();
158
163
  }
159
164
  } catch (err) {
160
165
  logger.error('throttled-fetch', `Failed to load network policy: ${err.message}`);
@@ -76,7 +76,13 @@ export function loadNodes(vaultDir) {
76
76
  const raw = fs.readFileSync(file, 'utf8');
77
77
  const { data, content } = matter(raw);
78
78
  if (data.type === 'memory') {
79
- nodes.push({ ...data, body: content.trim() });
79
+ // Attach absolute path for stampers/rewriters (both spellings used in codebase)
80
+ nodes.push({
81
+ ...data,
82
+ body: content.trim(),
83
+ _filePath: file,
84
+ _filepath: file,
85
+ });
80
86
  }
81
87
  } catch (err) {
82
88
  logger.warn('vault', `Failed to parse ${file}`, { err: err.message });
@@ -43,6 +43,51 @@ const PACKAGE_VERSION = (() => {
43
43
  }
44
44
  })();
45
45
 
46
+ // ─── PID Lock ───────────────────────────────────────────────────────────────────
47
+ // A second instance that loses the listen() race for PORT has historically kept
48
+ // running indefinitely (no bound port, no work, never exiting) because nothing
49
+ // handled the EADDRINUSE 'error' event. Fail fast instead, mirroring the daemon
50
+ // loop's acquirePidLock/releasePidLock pattern.
51
+ const SERVER_PID_FILE = path.join(configBrainDir, 'server.pid');
52
+
53
+ function acquireServerPidLock() {
54
+ try {
55
+ if (fs.existsSync(SERVER_PID_FILE)) {
56
+ const existingPid = parseInt(fs.readFileSync(SERVER_PID_FILE, 'utf8').trim(), 10);
57
+ if (existingPid && !isNaN(existingPid)) {
58
+ try {
59
+ process.kill(existingPid, 0); // signal 0 = liveness check only
60
+ logger.error('server', `Another server instance is already running (PID: ${existingPid}). Exiting.`);
61
+ process.exit(1);
62
+ } catch {
63
+ logger.info('server', `Stale server PID file found (PID: ${existingPid} is dead). Overwriting.`);
64
+ }
65
+ }
66
+ }
67
+ } catch {
68
+ // PID file unreadable — proceed
69
+ }
70
+ try {
71
+ fs.mkdirSync(path.dirname(SERVER_PID_FILE), { recursive: true });
72
+ fs.writeFileSync(SERVER_PID_FILE, String(process.pid), { mode: 0o644 });
73
+ } catch (err) {
74
+ logger.warn('server', `Could not write server PID lockfile: ${err.message}`);
75
+ }
76
+ }
77
+
78
+ function releaseServerPidLock() {
79
+ try {
80
+ if (fs.existsSync(SERVER_PID_FILE)) {
81
+ const storedPid = parseInt(fs.readFileSync(SERVER_PID_FILE, 'utf8').trim(), 10);
82
+ if (storedPid === process.pid) fs.unlinkSync(SERVER_PID_FILE);
83
+ }
84
+ } catch {
85
+ // best-effort cleanup
86
+ }
87
+ }
88
+
89
+ acquireServerPidLock();
90
+
46
91
  // ─── Watchdog ───────────────────────────────────────────────────────────────────
47
92
  // Attach circuit-breaker log monitor before any subsystem can emit events.
48
93
  import { attachLogMonitor } from '../core/watchdog.mjs';
@@ -699,6 +744,11 @@ const server = app.listen(PORT, HOST, () => {
699
744
  }
700
745
  logger.info("server", `Total Recall Brain v3.0.0 is listening on http://${HOST}:${PORT}`);
701
746
  });
747
+ server.on('error', (err) => {
748
+ logger.error('server', `Failed to bind ${HOST}:${PORT}: ${err.message}. Exiting.`);
749
+ releaseServerPidLock();
750
+ process.exit(1);
751
+ });
702
752
 
703
753
  // If bound to a mesh IP, also bind to 127.0.0.1 so local CLI/Dashboard works
704
754
  if (HOST !== '127.0.0.1' && HOST !== '0.0.0.0') {
@@ -706,6 +756,11 @@ if (HOST !== '127.0.0.1' && HOST !== '0.0.0.0') {
706
756
  setupUpgradeHandler(localServer);
707
757
  logger.info("server", `Total Recall Brain v3.0.0 is ALSO listening on http://127.0.0.1:${PORT}`);
708
758
  });
759
+ localServer.on('error', (err) => {
760
+ logger.error('server', `Failed to bind 127.0.0.1:${PORT}: ${err.message}. Exiting.`);
761
+ releaseServerPidLock();
762
+ process.exit(1);
763
+ });
709
764
  }
710
765
 
711
766
  logger.info("server", "┌─────────────────────────────────────────────┐");
@@ -940,6 +995,7 @@ async function handleShutdown(signal) {
940
995
  }
941
996
 
942
997
  clearTimeout(forceExitTimeout);
998
+ releaseServerPidLock();
943
999
  logger.info('server', 'Graceful shutdown complete. Exiting.');
944
1000
  process.exit(0);
945
1001
  });
@@ -415,6 +415,10 @@
415
415
  "method": "PATCH",
416
416
  "path": "/api/secrets/:key"
417
417
  },
418
+ {
419
+ "method": "POST",
420
+ "path": "/api/secrets/:key/account-sync"
421
+ },
418
422
  {
419
423
  "method": "POST",
420
424
  "path": "/api/secrets/:key/reveal"
@@ -423,6 +427,10 @@
423
427
  "method": "POST",
424
428
  "path": "/api/secrets/:key/rotate"
425
429
  },
430
+ {
431
+ "method": "POST",
432
+ "path": "/api/secrets/account-sync"
433
+ },
426
434
  {
427
435
  "method": "GET",
428
436
  "path": "/api/secrets/checksum"
@@ -467,6 +475,10 @@
467
475
  "method": "GET",
468
476
  "path": "/api/secrets/scan-env"
469
477
  },
478
+ {
479
+ "method": "GET",
480
+ "path": "/api/secrets/shared-values"
481
+ },
470
482
  {
471
483
  "method": "GET",
472
484
  "path": "/api/secrets/sync"
@@ -483,6 +495,10 @@
483
495
  "method": "POST",
484
496
  "path": "/api/secrets/sync/trigger-pull"
485
497
  },
498
+ {
499
+ "method": "GET",
500
+ "path": "/api/secrets/tracking-health"
501
+ },
486
502
  {
487
503
  "method": "GET",
488
504
  "path": "/api/secrets/usage"
@@ -113,6 +113,29 @@ export function resolveAllVaultsFromQuery(req) {
113
113
  return uniqueVaults.length > 0 ? uniqueVaults : [VAULT_DIR];
114
114
  }
115
115
 
116
+ /**
117
+ * Derive brain-local paths from a vault directory.
118
+ * vaultDir is always `…/<brain>/memory-vault`.
119
+ * Ensures project brains use their own derived indexes / surfaces, not global.
120
+ *
121
+ * @param {string} vaultDir
122
+ * @returns {{ brainDir: string, derivedDir: string, skillsDir: string, instructionsFile: string, sessionsDir: string, inboxDir: string }}
123
+ */
124
+ export function pathsForVault(vaultDir) {
125
+ const brainDir = path.dirname(vaultDir);
126
+ // brainDir = …/.agent/skills/total-recall → agentDir = …/.agent
127
+ const skillsParent = path.dirname(brainDir); // …/skills
128
+ const agentDir = path.dirname(skillsParent); // …/.agent
129
+ return {
130
+ brainDir,
131
+ derivedDir: path.join(brainDir, 'memory-derived'),
132
+ skillsDir: path.join(agentDir, 'skills'),
133
+ instructionsFile: path.join(agentDir, 'INSTRUCTIONS.md'),
134
+ sessionsDir: path.join(brainDir, 'sessions'),
135
+ inboxDir: path.join(brainDir, 'memory-inbox'),
136
+ };
137
+ }
138
+
116
139
  export function notFound(res, msg) {
117
140
  return res.status(404).json({ error: msg || 'Not found' });
118
141
  }
@@ -127,6 +150,15 @@ export function serverError(res, err) {
127
150
  }
128
151
 
129
152
  /** Strip the internal `body` field from a vault node into `content`. */
130
- export function sanitizeNode({ body, ...rest }) {
153
+ export function sanitizeNode(node) {
154
+ if (!node || typeof node !== 'object') return node;
155
+ // Strip internal fields — never expose absolute paths or vault internals over the API
156
+ const {
157
+ body,
158
+ _filePath,
159
+ _filepath,
160
+ _layer,
161
+ ...rest
162
+ } = node;
131
163
  return { ...rest, content: body };
132
164
  }
@@ -1,10 +1,11 @@
1
1
  import { Router } from 'express';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import crypto from 'node:crypto';
5
2
  import { requireAuth, requireScope } from '../auth.mjs';
6
- import { serverError, ROOT, BRAIN_DIR, VAULT_DIR, SKILLS_DIR, INSTRUCTIONS, DERIVED_DIR, badRequest } from './_shared.mjs';
7
- import { logger } from '../../core/logger.mjs';
3
+ import {
4
+ serverError,
5
+ badRequest,
6
+ resolveVaultFromQuery,
7
+ pathsForVault,
8
+ } from './_shared.mjs';
8
9
 
9
10
  const router = Router();
10
11
 
@@ -12,10 +13,12 @@ router.post('/api/context', requireAuth, requireScope('memory:read'), async (req
12
13
  try {
13
14
  const { compileContext } = await import('../../core/context-compiler.mjs');
14
15
  const { query, budget, momentum_slugs } = req.body || {};
16
+ const vaultDir = resolveVaultFromQuery(req);
17
+ const { derivedDir } = pathsForVault(vaultDir);
15
18
  const result = await compileContext({
16
19
  query: query || '',
17
- vaultDir: VAULT_DIR,
18
- derivedDir: DERIVED_DIR,
20
+ vaultDir,
21
+ derivedDir,
19
22
  budget: budget || {},
20
23
  consumer: 'api',
21
24
  momentumSlugs: momentum_slugs || [],
@@ -29,7 +32,8 @@ router.post('/api/context', requireAuth, requireScope('memory:read'), async (req
29
32
  router.get('/api/context/preview', requireAuth, requireScope('memory:read'), async (req, res) => {
30
33
  try {
31
34
  const { previewContext } = await import('../../core/context-compiler.mjs');
32
- const result = previewContext({ vaultDir: VAULT_DIR });
35
+ const vaultDir = resolveVaultFromQuery(req);
36
+ const result = previewContext({ vaultDir });
33
37
  res.json(result);
34
38
  } catch (err) {
35
39
  serverError(res, err);
@@ -41,9 +45,10 @@ router.post('/api/context/stream', requireAuth, requireScope('memory:read'), asy
41
45
  const { streamParallelContext } = await import('../../core/parallel-context.mjs');
42
46
  const { query, budget_tokens, batch_size, concurrency, min_score } = req.body || {};
43
47
  if (!query) return badRequest(res, 'query is required');
48
+ const vaultDir = resolveVaultFromQuery(req);
44
49
  const result = await streamParallelContext({
45
50
  query,
46
- vaultDir: VAULT_DIR,
51
+ vaultDir,
47
52
  budgetTokens: budget_tokens,
48
53
  batchSize: batch_size,
49
54
  concurrency,