total-recall-brain 3.18.2 → 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 (83) hide show
  1. package/frontend/dist/assets/{index-D5xRw5uq.js → index-B6AfuKv9.js} +126 -134
  2. package/frontend/dist/index.html +1 -1
  3. package/frontend/src/api/config.ts +16 -2
  4. package/frontend/src/api/keys.ts +111 -0
  5. package/frontend/src/api/notifications.ts +54 -6
  6. package/frontend/src/api/update.ts +41 -5
  7. package/frontend/src/components/BrainSelector.spec.tsx +71 -47
  8. package/frontend/src/components/BrainSelector.tsx +112 -35
  9. package/frontend/src/components/MobilePairing.spec.tsx +44 -14
  10. package/frontend/src/components/MobilePairing.tsx +229 -22
  11. package/frontend/src/pages/HealthPage.tsx +12 -16
  12. package/frontend/src/pages/NotificationsPage.tsx +6 -3
  13. package/frontend/src/pages/RulesPage.spec.tsx +46 -15
  14. package/frontend/src/pages/RulesPage.tsx +41 -9
  15. package/frontend/src/pages/SecretsPage.tsx +316 -3
  16. package/frontend/src/pages/SettingsPage.spec.tsx +30 -6
  17. package/frontend/src/pages/SettingsPage.tsx +228 -86
  18. package/package.json +1 -1
  19. package/src/cli/agent-dir.mjs +17 -2
  20. package/src/cli/chat.mjs +1 -1
  21. package/src/cli/daemon.mjs +2 -1
  22. package/src/cli/dream.mjs +14 -10
  23. package/src/cli/forget.mjs +14 -2
  24. package/src/cli/friction.mjs +1 -1
  25. package/src/cli/import-rules.mjs +8 -3
  26. package/src/cli/ingest.mjs +3 -2
  27. package/src/cli/lint.mjs +9 -6
  28. package/src/cli/rebuild.mjs +9 -4
  29. package/src/cli/remember.mjs +21 -15
  30. package/src/cli/secret.mjs +184 -4
  31. package/src/cli/share.mjs +5 -3
  32. package/src/cli/skill-projection.mjs +21 -2
  33. package/src/cli/snapshot.mjs +10 -8
  34. package/src/cli/update.mjs +2 -1
  35. package/src/core/clarity-rewriter.mjs +31 -12
  36. package/src/core/config.mjs +10 -4
  37. package/src/core/daemon-loop.mjs +33 -6
  38. package/src/core/embeddings.mjs +51 -3
  39. package/src/core/memory-title.mjs +70 -0
  40. package/src/core/notifications.mjs +204 -34
  41. package/src/core/package-auto-update.mjs +99 -3
  42. package/src/core/pairing.mjs +168 -0
  43. package/src/core/provider-account-sync.mjs +1145 -0
  44. package/src/core/provider-catalog.mjs +11 -4
  45. package/src/core/research.mjs +98 -24
  46. package/src/core/runtime.mjs +11 -1
  47. package/src/core/scheduler.mjs +31 -9
  48. package/src/core/schema.mjs +23 -0
  49. package/src/core/secret-integration-research.mjs +325 -0
  50. package/src/core/secrets-store.mjs +284 -14
  51. package/src/core/secrets-sync.mjs +1 -1
  52. package/src/core/skills-registry.mjs +12 -0
  53. package/src/core/source-adapters.mjs +247 -22
  54. package/src/core/surface.mjs +4 -4
  55. package/src/core/task-executors.mjs +161 -14
  56. package/src/core/throttled-fetch.mjs +5 -0
  57. package/src/core/vault.mjs +7 -1
  58. package/src/core/webhook-handlers.mjs +123 -14
  59. package/src/server/auth.mjs +6 -0
  60. package/src/server/index.mjs +56 -0
  61. package/src/server/rest.mjs +2 -0
  62. package/src/server/route-manifest.json +40 -0
  63. package/src/server/routes/_shared.mjs +33 -1
  64. package/src/server/routes/config.mjs +64 -23
  65. package/src/server/routes/context.mjs +14 -9
  66. package/src/server/routes/dashboard.mjs +39 -18
  67. package/src/server/routes/docs.mjs +27 -13
  68. package/src/server/routes/dream.mjs +10 -11
  69. package/src/server/routes/export.mjs +43 -12
  70. package/src/server/routes/field.mjs +11 -9
  71. package/src/server/routes/graph.mjs +31 -13
  72. package/src/server/routes/import.mjs +35 -16
  73. package/src/server/routes/instructions.mjs +41 -5
  74. package/src/server/routes/memory.mjs +61 -21
  75. package/src/server/routes/mesh.mjs +2 -1
  76. package/src/server/routes/notifications.mjs +103 -0
  77. package/src/server/routes/rules.mjs +75 -20
  78. package/src/server/routes/secrets.mjs +110 -4
  79. package/src/server/routes/share.mjs +2 -1
  80. package/src/server/routes/ssss.mjs +11 -3
  81. package/src/server/routes/system.mjs +20 -1
  82. package/src/server/routes/update.mjs +33 -7
  83. package/src/server/routes/vault.mjs +53 -28
@@ -1,19 +1,128 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { buildTaskEnvelope } from './task-envelope.mjs';
4
+ import { persistTaskToDisk } from './scheduler.mjs';
5
+ import { brainDir } from './config.mjs';
1
6
  import { logger } from './logger.mjs';
2
7
 
8
+ const QUEUE_DIR = path.join(brainDir, 'scheduler', 'queue');
9
+
10
+ /** Fixed slug so re-pushes overwrite one card instead of flooding the queue. */
11
+ const DEPLOY_SLUG = 'deploy-auto';
12
+ const SKILL_SYNC_SLUG = 'skillsync-auto';
13
+ const NPM_UPDATE_SLUG = 'npm-update-auto';
14
+
15
+ /**
16
+ * True when a queue file already exists for this slug (pending/in-progress).
17
+ * Avoids N× "Auto-deploy" cards for every GitHub push delivery / multi-ref push.
18
+ */
19
+ function hasPendingTask(slug) {
20
+ try {
21
+ return fs.existsSync(path.join(QUEUE_DIR, `${slug}.md`));
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
3
27
  /**
4
- * Verified webhooks are recorded as SSSS events by the route. They do not run
5
- * shell commands or deployments. Product-specific automation must subscribe to
6
- * those events through an explicitly reviewed workflow with its own gates.
28
+ * GitHub may fire multiple push webhooks (multi-repo, multi-ref, redeliveries).
29
+ * Only production/main refs matter for deploy notices; always coalesce to one task.
7
30
  */
8
- export async function handleWebhook(provider, eventType, _payload) {
9
- const known =
10
- (provider === 'github' && ['push', 'release'].includes(eventType)) ||
11
- (provider === 'stripe' && typeof eventType === 'string' && eventType.includes('.'));
12
-
13
- logger.info('webhooks', `Recorded ${provider}.${eventType} webhook`, {
14
- provider,
15
- eventType,
16
- automation: 'disabled',
17
- });
18
- return { handled: known, action: known ? 'recorded' : 'ignored' };
31
+ function pushRefIsDeployable(payload = {}) {
32
+ const ref = String(payload.ref || '');
33
+ if (!ref) return true; // tests / bare payload still queue once
34
+ return (
35
+ ref === 'refs/heads/production' ||
36
+ ref === 'refs/heads/main' ||
37
+ ref.endsWith('/production') ||
38
+ ref.endsWith('/main')
39
+ );
40
+ }
41
+
42
+ export async function handleWebhook(provider, eventType, payload = {}) {
43
+ logger.info({ subsystem: 'webhooks', message: `Handling ${provider}.${eventType} webhook` });
44
+
45
+ if (provider === 'github' && eventType === 'push') {
46
+ if (!pushRefIsDeployable(payload)) {
47
+ logger.info({
48
+ subsystem: 'webhooks',
49
+ message: `Skipping deploy queue for non-deploy ref ${payload.ref || '(none)'}`,
50
+ });
51
+ return { queued: false, reason: 'ref_not_deployable' };
52
+ }
53
+ if (hasPendingTask(DEPLOY_SLUG)) {
54
+ logger.info({
55
+ subsystem: 'webhooks',
56
+ message: 'Deploy task already pending — not queuing another (coalesce)',
57
+ });
58
+ return { queued: false, reason: 'already_pending', slug: DEPLOY_SLUG };
59
+ }
60
+ // Notify-only card: real production deploy is the droplet watcher on push to production.
61
+ // Do NOT run bin/deploy.sh here — that caused multi-deploy spam and wrong deploy path.
62
+ const env = buildTaskEnvelope({
63
+ intent: 'Auto-deploy triggered by push',
64
+ slug: DEPLOY_SLUG,
65
+ kind: 'system',
66
+ executor: 'command',
67
+ category: 'deployment',
68
+ priority: 80,
69
+ system: true,
70
+ origin: { agent: 'daemon', created_by: 'webhook-github' },
71
+ capabilities: ['vault:read'],
72
+ payload: {
73
+ command: 'echo "Deploy notice only — production droplet auto-deploy handles the real ship"',
74
+ ref: payload.ref || null,
75
+ after: payload.after || null,
76
+ repository: payload.repository?.full_name || null,
77
+ },
78
+ });
79
+ persistTaskToDisk(env, QUEUE_DIR);
80
+ logger.info({ subsystem: 'webhooks', message: 'Queued single coalesced deploy notice task' });
81
+ return { queued: true, slug: DEPLOY_SLUG };
82
+ }
83
+
84
+ if (provider === 'github' && eventType === 'release') {
85
+ if (hasPendingTask(SKILL_SYNC_SLUG)) {
86
+ return { queued: false, reason: 'already_pending', slug: SKILL_SYNC_SLUG };
87
+ }
88
+ const env = buildTaskEnvelope({
89
+ intent: 'Skill sync triggered by release',
90
+ slug: SKILL_SYNC_SLUG,
91
+ kind: 'system',
92
+ executor: 'command',
93
+ category: 'sync',
94
+ priority: 70,
95
+ system: true,
96
+ origin: { agent: 'daemon', created_by: 'webhook-github' },
97
+ capabilities: ['vault:read'],
98
+ payload: { command: 'npx total-recall skill sync' },
99
+ });
100
+ persistTaskToDisk(env, QUEUE_DIR);
101
+ logger.info({ subsystem: 'webhooks', message: 'Queued skill-sync task' });
102
+ return { queued: true, slug: SKILL_SYNC_SLUG };
103
+ }
104
+
105
+ if (provider === 'npm' && eventType === 'package-publish') {
106
+ if (hasPendingTask(NPM_UPDATE_SLUG)) {
107
+ return { queued: false, reason: 'already_pending', slug: NPM_UPDATE_SLUG };
108
+ }
109
+ const env = buildTaskEnvelope({
110
+ intent: 'Package update triggered by npm publish',
111
+ slug: NPM_UPDATE_SLUG,
112
+ kind: 'system',
113
+ executor: 'command',
114
+ category: 'package',
115
+ priority: 60,
116
+ system: true,
117
+ origin: { agent: 'daemon', created_by: 'webhook-npm' },
118
+ capabilities: ['vault:read'],
119
+ payload: { command: 'npm update' },
120
+ });
121
+ persistTaskToDisk(env, QUEUE_DIR);
122
+ logger.info({ subsystem: 'webhooks', message: 'Queued npm update task' });
123
+ return { queued: true, slug: NPM_UPDATE_SLUG };
124
+ }
125
+
126
+ logger.info({ subsystem: 'webhooks', message: `No specific handler for ${provider}.${eventType}` });
127
+ return { queued: false, reason: 'no_handler' };
19
128
  }
@@ -246,6 +246,12 @@ export function requireAuthOrLocal(req, res, next) {
246
246
  const config = loadSecurityConfig();
247
247
  if (config.network?.public_health === true) return next();
248
248
  if (isLocalRequest(req)) return next();
249
+ // Mesh peers probe /health for latency + LAN discovery. Traffic is already on
250
+ // WireGuard; treat mesh socket addresses like local for this gate only.
251
+ // Never trust X-Forwarded-For for this exemption (same as requireHttps mesh rule).
252
+ if (isMeshIp(req.socket?.remoteAddress) && forwardedClientIps(req).length === 0) {
253
+ return next();
254
+ }
249
255
  return requireAuth(req, res, next);
250
256
  }
251
257
 
@@ -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
  });
@@ -120,6 +120,7 @@ import scriptsRouter from './routes/scripts.mjs';
120
120
  import ttsRouter from './routes/tts.mjs';
121
121
  import updateRouter from './routes/update.mjs';
122
122
  import { rulesRouter } from './routes/rules.mjs';
123
+ import notificationsRouter from './routes/notifications.mjs';
123
124
  // ollamaUrl removed — CLI agents replace Ollama
124
125
  import {
125
126
  AGENT_DIR,
@@ -233,6 +234,7 @@ router.use(ttsRouter);
233
234
  router.use(updateRouter);
234
235
  router.use(ssssRouter);
235
236
  router.use(rulesRouter);
237
+ router.use(notificationsRouter);
236
238
 
237
239
  export { router as restRouter };
238
240
  export default router;
@@ -327,6 +327,26 @@
327
327
  "method": "GET",
328
328
  "path": "/api/network/stats"
329
329
  },
330
+ {
331
+ "method": "GET",
332
+ "path": "/api/notifications/history"
333
+ },
334
+ {
335
+ "method": "GET",
336
+ "path": "/api/notifications/rules"
337
+ },
338
+ {
339
+ "method": "POST",
340
+ "path": "/api/notifications/rules"
341
+ },
342
+ {
343
+ "method": "DELETE",
344
+ "path": "/api/notifications/rules/:id"
345
+ },
346
+ {
347
+ "method": "POST",
348
+ "path": "/api/notifications/test"
349
+ },
330
350
  {
331
351
  "method": "GET",
332
352
  "path": "/api/openai-models"
@@ -335,6 +355,10 @@
335
355
  "method": "GET",
336
356
  "path": "/api/openrouter-models"
337
357
  },
358
+ {
359
+ "method": "GET",
360
+ "path": "/api/pairing"
361
+ },
338
362
  {
339
363
  "method": "GET",
340
364
  "path": "/api/research"
@@ -391,6 +415,10 @@
391
415
  "method": "PATCH",
392
416
  "path": "/api/secrets/:key"
393
417
  },
418
+ {
419
+ "method": "POST",
420
+ "path": "/api/secrets/:key/account-sync"
421
+ },
394
422
  {
395
423
  "method": "POST",
396
424
  "path": "/api/secrets/:key/reveal"
@@ -399,6 +427,10 @@
399
427
  "method": "POST",
400
428
  "path": "/api/secrets/:key/rotate"
401
429
  },
430
+ {
431
+ "method": "POST",
432
+ "path": "/api/secrets/account-sync"
433
+ },
402
434
  {
403
435
  "method": "GET",
404
436
  "path": "/api/secrets/checksum"
@@ -443,6 +475,10 @@
443
475
  "method": "GET",
444
476
  "path": "/api/secrets/scan-env"
445
477
  },
478
+ {
479
+ "method": "GET",
480
+ "path": "/api/secrets/shared-values"
481
+ },
446
482
  {
447
483
  "method": "GET",
448
484
  "path": "/api/secrets/sync"
@@ -459,6 +495,10 @@
459
495
  "method": "POST",
460
496
  "path": "/api/secrets/sync/trigger-pull"
461
497
  },
498
+ {
499
+ "method": "GET",
500
+ "path": "/api/secrets/tracking-health"
501
+ },
462
502
  {
463
503
  "method": "GET",
464
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
  }
@@ -68,7 +68,7 @@ router.get('/api/config', requireAuth, requireScope('config:read'), async (req,
68
68
  * GET /api/config-json
69
69
  * Returns full config bundle: security, budget, brain, and allowed secrets keys.
70
70
  */
71
- router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, res) => {
71
+ router.get('/api/config-json', requireAuth, requireScope('config:read'), async (req, res) => {
72
72
  try {
73
73
  const securityPath = path.join(CONFIG_DIR, 'security.yml');
74
74
  const budgetPath = path.join(CONFIG_DIR, 'budget.yml');
@@ -89,8 +89,40 @@ router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, r
89
89
  if (fs.existsSync(brainPath)) {
90
90
  try { brain = JSON.parse(fs.readFileSync(brainPath, 'utf8')) || {}; } catch {}
91
91
  }
92
- if (fs.existsSync(secretsPath)) {
93
- try { secrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8')) || {}; } catch {}
92
+ // Prefer brain secrets store (AES). Never return raw secret values to the dashboard.
93
+ const allowedKeys = [
94
+ 'google_api_key',
95
+ 'anthropic_api_key',
96
+ 'openai_api_key',
97
+ 'openrouter_api_key',
98
+ 'tavily_api_key',
99
+ 'brave_api_key',
100
+ 'exa_api_key',
101
+ 'serper_api_key',
102
+ 'github_token',
103
+ ];
104
+ const safeSecrets = {};
105
+ try {
106
+ const { listSecretsMeta } = await import('../../core/secrets-store.mjs');
107
+ const meta = await listSecretsMeta(BRAIN_DIR);
108
+ for (const row of meta) {
109
+ if (!allowedKeys.includes(row.key)) continue;
110
+ // Mask only — Settings/Secrets UIs use presence indicators, not values.
111
+ safeSecrets[row.key] = row.set ? '••••••••' : '';
112
+ }
113
+ } catch {
114
+ if (fs.existsSync(secretsPath)) {
115
+ try {
116
+ secrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8')) || {};
117
+ } catch {
118
+ /* ignore AES/binary */
119
+ }
120
+ for (const key of allowedKeys) {
121
+ if (secrets[key] !== undefined && secrets[key] !== null && secrets[key] !== '') {
122
+ safeSecrets[key] = '••••••••';
123
+ }
124
+ }
125
+ }
94
126
  }
95
127
 
96
128
  const safeBrain = { ...brain };
@@ -99,14 +131,6 @@ router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, r
99
131
  delete safeBrain.token;
100
132
  }
101
133
 
102
- const allowedKeys = ['google_api_key', 'anthropic_api_key', 'openai_api_key', 'openrouter_api_key', 'tavily_api_key', 'brave_api_key', 'exa_api_key', 'serper_api_key', 'github_token'];
103
- const safeSecrets = {};
104
- for (const key of allowedKeys) {
105
- if (secrets[key] !== undefined) {
106
- safeSecrets[key] = secrets[key];
107
- }
108
- }
109
-
110
134
  res.json({ security, budget, brain: safeBrain, secrets: safeSecrets });
111
135
  } catch (err) { serverError(res, err); }
112
136
  });
@@ -146,22 +170,39 @@ router.post('/api/config-json', requireAuth, requireScope('config:write'), async
146
170
  fs.writeFileSync(brainPath, JSON.stringify(nextBrain, null, 2), { encoding: 'utf8', mode: 0o600 });
147
171
  }
148
172
  if (secrets) {
149
- let existingSecrets = {};
150
- if (fs.existsSync(secretsPath)) {
151
- try { existingSecrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8')) || {}; } catch {}
152
- }
153
- const allowedKeys = ['google_api_key', 'anthropic_api_key', 'openai_api_key', 'openrouter_api_key', 'tavily_api_key', 'brave_api_key', 'exa_api_key', 'serper_api_key', 'github_token'];
173
+ // Only accept real secret values — ignore masked placeholders from GET /api/config-json.
174
+ const allowedKeys = [
175
+ 'google_api_key',
176
+ 'anthropic_api_key',
177
+ 'openai_api_key',
178
+ 'openrouter_api_key',
179
+ 'tavily_api_key',
180
+ 'brave_api_key',
181
+ 'exa_api_key',
182
+ 'serper_api_key',
183
+ 'github_token',
184
+ ];
185
+ const { setSecret, deleteSecret } = await import('../../core/secrets-store.mjs');
154
186
  for (const key of allowedKeys) {
155
- if (secrets[key] !== undefined) {
156
- if (secrets[key] === '') {
157
- delete existingSecrets[key];
158
- } else {
159
- existingSecrets[key] = secrets[key];
187
+ if (secrets[key] === undefined) continue;
188
+ const value = secrets[key];
189
+ if (value === '' || value == null) {
190
+ try {
191
+ await deleteSecret(BRAIN_DIR, key, { action: 'delete', actor: 'settings-config' });
192
+ } catch {
193
+ /* key may not exist */
160
194
  }
195
+ continue;
196
+ }
197
+ if (typeof value === 'string' && (value.includes('•') || value === '[redacted]')) {
198
+ // Masked echo from the UI — do not overwrite the store.
199
+ continue;
161
200
  }
201
+ await setSecret(BRAIN_DIR, key, String(value), {
202
+ action: 'set',
203
+ actor: 'settings-config',
204
+ });
162
205
  }
163
- const { saveSecrets } = await import('../../core/secrets-store.mjs');
164
- await saveSecrets(AGENT_DIR, existingSecrets);
165
206
  }
166
207
 
167
208
  res.json({ success: true });
@@ -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,
@@ -1,52 +1,73 @@
1
1
  import { Router } from 'express';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
4
5
  import { requireAuth, requireScope } from '../auth.mjs';
5
6
  import { getNodes } from '../../core/vault-cache.mjs';
6
- import { VAULT_DIR, INSTRUCTIONS } from './_shared.mjs';
7
+ import {
8
+ VAULT_DIR,
9
+ INSTRUCTIONS,
10
+ ROOT as SHARED_ROOT,
11
+ resolveVaultFromQuery,
12
+ pathsForVault,
13
+ } from './_shared.mjs';
7
14
 
8
15
  const router = Router();
9
- const ROOT = process.env.TR_ROOT || path.join(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..');
16
+ // Prefer shared ROOT (fileURLToPath-safe). Fall back with fileURLToPath — never URL.pathname
17
+ // (breaks on Windows and on paths with encoded characters).
18
+ const ROOT = process.env.TR_ROOT || SHARED_ROOT || path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
10
19
 
11
20
  router.get('/api/dashboard/instructions', requireAuth, requireScope('instructions:read'), (req, res) => {
21
+ const vaultDir = resolveVaultFromQuery(req);
22
+ const paths = pathsForVault(vaultDir);
23
+ // skillsDir = …/.agent/skills → agentDir = …/.agent; surfaces often at repo root (parent of .agent)
24
+ const agentDir = path.dirname(paths.skillsDir);
25
+ const surfaceBase =
26
+ path.basename(agentDir) === '.agent' ? path.dirname(agentDir) : agentDir;
27
+
12
28
  const surfaces = [];
13
29
  const surfaceFiles = ['AGENTS.md', 'GEMINI.md', 'CLAUDE.md', 'INSTRUCTIONS.md'];
14
-
30
+
15
31
  for (const name of surfaceFiles) {
16
- let filePath;
17
- if (name === 'INSTRUCTIONS.md') {
18
- filePath = path.join(ROOT, name);
19
- } else {
20
- filePath = path.join(ROOT, name);
21
- }
22
-
32
+ // Prefer agent-adjacent surfaces; fall back to package ROOT for monorepo dashboard
33
+ const candidates = [
34
+ path.join(surfaceBase, name),
35
+ path.join(agentDir, name),
36
+ path.join(ROOT, name),
37
+ ];
38
+ let filePath = candidates.find((p) => fs.existsSync(p)) || candidates[0];
39
+
23
40
  let size = 0;
24
41
  let lastCompiled = '';
25
42
  let active = false;
26
-
43
+
27
44
  if (fs.existsSync(filePath)) {
28
45
  const stat = fs.statSync(filePath);
29
46
  size = stat.size;
30
47
  lastCompiled = stat.mtime.toISOString();
31
48
  active = true;
32
49
  }
33
-
50
+
34
51
  surfaces.push({
35
52
  name,
36
53
  filename: name,
37
54
  size,
38
55
  lastCompiled,
39
- active
56
+ active,
40
57
  });
41
58
  }
42
-
43
- const lastCompileTimestamp = fs.existsSync(INSTRUCTIONS) ? fs.statSync(INSTRUCTIONS).mtime.toISOString() : '';
44
- const totalNodes = fs.existsSync(VAULT_DIR) ? getNodes(VAULT_DIR).length : 0;
45
-
59
+
60
+ const instructionsFile = paths.instructionsFile || INSTRUCTIONS;
61
+ const lastCompileTimestamp = fs.existsSync(instructionsFile)
62
+ ? fs.statSync(instructionsFile).mtime.toISOString()
63
+ : '';
64
+ const totalNodes = fs.existsSync(vaultDir) ? getNodes(vaultDir).length : 0;
65
+
46
66
  res.json({
47
67
  surfaces,
48
68
  lastCompileTimestamp,
49
- totalNodes
69
+ totalNodes,
70
+ vault_dir: vaultDir,
50
71
  });
51
72
  });
52
73