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
@@ -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
 
@@ -27,10 +27,26 @@ function getRegistryTypes(brainDir) {
27
27
  return {};
28
28
  }
29
29
 
30
- // Ensure the path is safe
30
+ // Ensure the path is safe (relative, no traversal)
31
31
  function isSafePath(p) {
32
- const normalized = path.normalize(p).replace(/^(\.\.(\/|\\|$))+/, '');
33
- return normalized === p && !p.startsWith('/') && !p.includes('..');
32
+ if (!p || typeof p !== 'string' || p.includes('\0')) return false;
33
+ if (path.isAbsolute(p)) return false;
34
+ const normalized = path.normalize(p);
35
+ // Reject any path that still escapes after normalize
36
+ if (normalized.startsWith('..') || normalized.includes(`${path.sep}..${path.sep}`) || normalized.endsWith(`${path.sep}..`)) {
37
+ return false;
38
+ }
39
+ if (normalized.includes('..')) return false;
40
+ return true;
41
+ }
42
+
43
+ /** Resolve relPath under vaultDir; returns null if it escapes the vault. */
44
+ function resolveSafeVaultPath(vaultDir, relPath) {
45
+ if (!isSafePath(relPath)) return null;
46
+ const root = path.resolve(vaultDir);
47
+ const abs = path.resolve(vaultDir, relPath);
48
+ if (abs !== root && !abs.startsWith(root + path.sep)) return null;
49
+ return abs;
34
50
  }
35
51
 
36
52
  /**
@@ -108,9 +124,8 @@ router.get('/api/docs/read', requireAuth, requireScope('ssss:read'), (req, res)
108
124
  try {
109
125
  const vaultDir = resolveVaultFromQuery(req);
110
126
  const relPath = req.query.path;
111
- if (!relPath || !isSafePath(relPath)) return badRequest(res, 'Invalid path');
112
-
113
- const absPath = path.join(vaultDir, relPath);
127
+ const absPath = resolveSafeVaultPath(vaultDir, relPath);
128
+ if (!absPath) return badRequest(res, 'Invalid path');
114
129
  if (!fs.existsSync(absPath)) return notFound(res, 'Document not found');
115
130
 
116
131
  const raw = fs.readFileSync(absPath, 'utf8');
@@ -134,8 +149,8 @@ router.post('/api/docs', requireAuth, requireScope('ssss:write'), async (req, re
134
149
  const vaultDir = resolveVaultFromQuery(req);
135
150
  const { path: relPath, content } = req.body;
136
151
 
137
- if (!relPath || !isSafePath(relPath)) return badRequest(res, 'Invalid path');
138
- const absPath = path.join(vaultDir, relPath);
152
+ const absPath = resolveSafeVaultPath(vaultDir, relPath);
153
+ if (!absPath) return badRequest(res, 'Invalid path');
139
154
  if (fs.existsSync(absPath)) return res.status(409).json({ error: 'Document already exists' });
140
155
 
141
156
  const envelope = {
@@ -164,8 +179,8 @@ router.put('/api/docs', requireAuth, requireScope('ssss:write'), async (req, res
164
179
  const vaultDir = resolveVaultFromQuery(req);
165
180
  const { path: relPath, content } = req.body;
166
181
 
167
- if (!relPath || !isSafePath(relPath)) return badRequest(res, 'Invalid path');
168
- const absPath = path.join(vaultDir, relPath);
182
+ const absPath = resolveSafeVaultPath(vaultDir, relPath);
183
+ if (!absPath) return badRequest(res, 'Invalid path');
169
184
  if (!fs.existsSync(absPath)) return notFound(res, 'Document not found');
170
185
 
171
186
  const envelope = {
@@ -193,9 +208,8 @@ router.delete('/api/docs', requireAuth, requireScope('ssss:write'), async (req,
193
208
  try {
194
209
  const vaultDir = resolveVaultFromQuery(req);
195
210
  const relPath = req.query.path;
196
-
197
- if (!relPath || !isSafePath(relPath)) return badRequest(res, 'Invalid path');
198
- const absPath = path.join(vaultDir, relPath);
211
+ const absPath = resolveSafeVaultPath(vaultDir, relPath);
212
+ if (!absPath) return badRequest(res, 'Invalid path');
199
213
  if (!fs.existsSync(absPath)) return notFound(res, 'Document not found');
200
214
 
201
215
  const envelope = {
@@ -1,27 +1,26 @@
1
1
  import { Router } from 'express';
2
- import fs from 'node:fs';
3
2
  import path from 'node:path';
4
- import crypto from 'node:crypto';
5
3
  import { requireAuth, requireScope } from '../auth.mjs';
6
- import { serverError, ROOT, BRAIN_DIR, VAULT_DIR, SKILLS_DIR, INSTRUCTIONS, DERIVED_DIR } from './_shared.mjs';
7
- import { logger } from '../../core/logger.mjs';
4
+ import { serverError, resolveVaultFromQuery, pathsForVault } from './_shared.mjs';
8
5
 
9
6
  const router = Router();
10
7
 
11
8
  router.post('/api/dream', requireAuth, requireScope('memory:recompile'), async (req, res) => {
12
9
  try {
13
10
  const { runDreamCycle } = await import('../../core/dream.mjs');
14
- const conflictsDir = path.join(BRAIN_DIR, 'memory-inbox', 'conflicts');
15
-
11
+ const vaultDir = resolveVaultFromQuery(req);
12
+ const paths = pathsForVault(vaultDir);
13
+ const conflictsDir = path.join(paths.brainDir, 'memory-inbox', 'conflicts');
14
+
16
15
  const result = await runDreamCycle({
17
- vaultDir: VAULT_DIR,
18
- skillsDir: SKILLS_DIR,
19
- derivedDir: DERIVED_DIR,
16
+ vaultDir,
17
+ skillsDir: paths.skillsDir,
18
+ derivedDir: paths.derivedDir,
20
19
  conflictsDir,
21
- instructionsFile: INSTRUCTIONS,
20
+ instructionsFile: paths.instructionsFile,
22
21
  });
23
22
 
24
- res.json({ success: true, status: result.status });
23
+ res.json({ success: true, status: result.status, vault_dir: vaultDir });
25
24
  } catch (err) {
26
25
  serverError(res, err);
27
26
  }
@@ -1,22 +1,28 @@
1
1
  import { Router } from 'express';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import crypto from 'node:crypto';
5
4
  import { spawn } from 'node:child_process';
6
5
  import { requireAuth, requireScope } from '../auth.mjs';
7
- import { serverError, ROOT, BRAIN_DIR, VAULT_DIR, SKILLS_DIR, INSTRUCTIONS, DERIVED_DIR, SESSIONS_DIR } from './_shared.mjs';
8
- import { logger } from '../../core/logger.mjs';
6
+ import {
7
+ serverError,
8
+ resolveVaultFromQuery,
9
+ pathsForVault,
10
+ } from './_shared.mjs';
9
11
 
10
12
  const router = Router();
11
13
 
12
14
  router.get('/api/brain/export', requireAuth, requireScope('brain:export'), async (req, res) => {
13
15
  try {
16
+ const vaultDir = resolveVaultFromQuery(req);
17
+ const paths = pathsForVault(vaultDir);
18
+ const brainDir = paths.brainDir;
19
+
14
20
  const ALL_PARTS = {
15
- vault: VAULT_DIR,
16
- derived: DERIVED_DIR,
17
- sessions: SESSIONS_DIR,
18
- config: path.join(BRAIN_DIR, 'config'),
19
- skills: SKILLS_DIR,
21
+ vault: vaultDir,
22
+ derived: paths.derivedDir,
23
+ sessions: paths.sessionsDir,
24
+ config: path.join(brainDir, 'config'),
25
+ skills: paths.skillsDir,
20
26
  };
21
27
  const requested = req.query.include
22
28
  ? String(req.query.include).split(',').map(s => s.trim())
@@ -27,14 +33,39 @@ router.get('/api/brain/export', requireAuth, requireScope('brain:export'), async
27
33
  const date = new Date().toISOString().slice(0, 10);
28
34
  res.setHeader('Content-Type', 'application/gzip');
29
35
  res.setHeader('Content-Disposition', `attachment; filename="total-recall-brain-${date}.tar.gz"`);
36
+ res.setHeader('X-Total-Recall-Vault', vaultDir);
37
+
38
+ // Only pack paths under brainDir (avoid `..` parent escapes for skills)
39
+ const relativeDirs = [];
40
+ for (const k of dirs) {
41
+ const abs = ALL_PARTS[k];
42
+ const rel = path.relative(brainDir, abs);
43
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
44
+ // skills lives as sibling of total-recall under .agent/skills — skip unsafe parent
45
+ if (k === 'skills') continue;
46
+ continue;
47
+ }
48
+ relativeDirs.push(rel || '.');
49
+ }
50
+ if (relativeDirs.length === 0) {
51
+ // Always can pack vault at least
52
+ relativeDirs.push(path.relative(brainDir, vaultDir) || 'memory-vault');
53
+ }
30
54
 
31
- const relativeDirs = dirs.map(k => path.relative(BRAIN_DIR, ALL_PARTS[k]));
32
- const tar = spawn('tar', ['czf', '-', '-C', BRAIN_DIR, '--exclude=security.yml', '--exclude=keys.jsonl', '--exclude=session-secret', ...relativeDirs], { stdio: ['ignore', 'pipe', 'ignore'] });
55
+ const tar = spawn('tar', [
56
+ 'czf', '-',
57
+ '-C', brainDir,
58
+ '--exclude=security.yml',
59
+ '--exclude=keys.jsonl',
60
+ '--exclude=session-secret',
61
+ ...relativeDirs,
62
+ ], { stdio: ['ignore', 'pipe', 'ignore'] });
33
63
  tar.stdout.pipe(res);
34
64
  tar.on('error', err => { if (!res.headersSent) serverError(res, err); });
35
65
  tar.on('close', code => { if (code !== 0 && !res.writableEnded) res.end(); });
36
-
37
- } catch (err) { serverError(res, err); }
66
+ } catch (err) {
67
+ serverError(res, err);
68
+ }
38
69
  });
39
70
 
40
71
  export default router;
@@ -1,18 +1,16 @@
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 { serverError, badRequest, resolveVaultFromQuery, pathsForVault } from './_shared.mjs';
8
4
 
9
5
  const router = Router();
10
6
 
11
7
  router.post('/api/field/compile', requireAuth, requireScope('memory:recompile'), async (req, res) => {
12
8
  try {
13
9
  const { compileField } = await import('../../core/vector-field.mjs');
14
- const result = await compileField({ vaultDir: VAULT_DIR, derivedDir: DERIVED_DIR });
15
- res.json({ compiled: true, ...result.meta });
10
+ const vaultDir = resolveVaultFromQuery(req);
11
+ const { derivedDir } = pathsForVault(vaultDir);
12
+ const result = await compileField({ vaultDir, derivedDir });
13
+ res.json({ compiled: true, vault_dir: vaultDir, ...result.meta });
16
14
  } catch (err) {
17
15
  serverError(res, err);
18
16
  }
@@ -23,12 +21,14 @@ router.post('/api/field/sample', requireAuth, requireScope('memory:read'), async
23
21
  const { sampleField } = await import('../../core/vector-field.mjs');
24
22
  const { query, top_k, entanglement_boost, velocity_weight } = req.body || {};
25
23
  if (!query) return badRequest(res, 'query is required');
24
+ const vaultDir = resolveVaultFromQuery(req);
25
+ const { derivedDir } = pathsForVault(vaultDir);
26
26
  const result = await sampleField({
27
27
  query,
28
28
  topK: top_k,
29
29
  entanglementBoost: entanglement_boost,
30
30
  velocityWeight: velocity_weight,
31
- derivedDir: DERIVED_DIR,
31
+ derivedDir,
32
32
  });
33
33
  res.json(result);
34
34
  } catch (err) {
@@ -39,7 +39,9 @@ router.post('/api/field/sample', requireAuth, requireScope('memory:read'), async
39
39
  router.get('/api/field/stats', requireAuth, requireScope('memory:read'), async (req, res) => {
40
40
  try {
41
41
  const { fieldStats } = await import('../../core/vector-field.mjs');
42
- res.json(fieldStats(DERIVED_DIR));
42
+ const vaultDir = resolveVaultFromQuery(req);
43
+ const { derivedDir } = pathsForVault(vaultDir);
44
+ res.json({ vault_dir: vaultDir, ...fieldStats(derivedDir) });
43
45
  } catch (err) {
44
46
  serverError(res, err);
45
47
  }
@@ -30,14 +30,24 @@ import {
30
30
  ROOT,
31
31
  badRequest,
32
32
  serverError,
33
+ resolveVaultFromQuery,
34
+ pathsForVault,
33
35
  } from './_shared.mjs';
34
36
 
35
37
  const router = Router();
36
38
 
37
39
  // ─── Helpers ───────────────────────────────────────────────────────────────────
38
40
 
39
- function isDashboardEnhanced() {
40
- return fs.existsSync(path.join(VAULT_DIR, '..', 'preferences', 'dashboard-enhanced.md'));
41
+ /**
42
+ * Feature flag file locations:
43
+ * - Production SSSS layout: <vault>/preferences/dashboard-enhanced.md
44
+ * - Legacy/test layout: <vault>/../preferences/dashboard-enhanced.md
45
+ */
46
+ function isDashboardEnhanced(vaultDir = VAULT_DIR) {
47
+ return (
48
+ fs.existsSync(path.join(vaultDir, 'preferences', 'dashboard-enhanced.md')) ||
49
+ fs.existsSync(path.join(vaultDir, '..', 'preferences', 'dashboard-enhanced.md'))
50
+ );
41
51
  }
42
52
 
43
53
  function sha256(content) {
@@ -124,19 +134,23 @@ function safeReferencePath(name) {
124
134
  * Requires dashboard-enhanced feature flag.
125
135
  */
126
136
  router.get('/api/graph', requireAuth, requireScope('ssss:read'), (req, res) => {
127
- if (!isDashboardEnhanced()) {
137
+ const vaultDir = resolveVaultFromQuery(req);
138
+ if (!isDashboardEnhanced(vaultDir)) {
128
139
  return res.status(404).json({ error: 'dashboard-enhanced feature flag not enabled' });
129
140
  }
130
141
  try {
131
- const graphFile = path.join(DERIVED_DIR, 'graph-index.jsonl');
132
- const routesFile = path.join(DERIVED_DIR, 'skill-routes.jsonl');
142
+ const { derivedDir } = pathsForVault(vaultDir);
143
+ // Prefer brain-local derived; fall back to shared DERIVED_DIR (tests/mocks)
144
+ const derived = fs.existsSync(derivedDir) ? derivedDir : DERIVED_DIR;
145
+ const graphFile = path.join(derived, 'graph-index.jsonl');
146
+ const routesFile = path.join(derived, 'skill-routes.jsonl');
133
147
  const nodes = fs.existsSync(graphFile)
134
148
  ? fs.readFileSync(graphFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l))
135
149
  : [];
136
150
  const routes = fs.existsSync(routesFile)
137
151
  ? fs.readFileSync(routesFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l))
138
152
  : [];
139
- res.json({ nodes, routes });
153
+ res.json({ nodes, routes, vault_dir: vaultDir });
140
154
  } catch (err) { serverError(res, err); }
141
155
  });
142
156
 
@@ -146,11 +160,13 @@ router.get('/api/graph', requireAuth, requireScope('ssss:read'), (req, res) => {
146
160
  * Requires dashboard-enhanced feature flag.
147
161
  */
148
162
  router.get('/api/conflicts', requireAuth, requireScope('ssss:read'), async (req, res) => {
149
- if (!isDashboardEnhanced()) {
163
+ const vaultDir = resolveVaultFromQuery(req);
164
+ if (!isDashboardEnhanced(vaultDir)) {
150
165
  return res.status(404).json({ error: 'dashboard-enhanced feature flag not enabled' });
151
166
  }
152
167
  try {
153
- const conflictsDir = path.join(BRAIN_DIR, 'memory-inbox', 'conflicts');
168
+ const paths = pathsForVault(vaultDir);
169
+ const conflictsDir = path.join(paths.brainDir, 'memory-inbox', 'conflicts');
154
170
  const conflicts = [];
155
171
 
156
172
  if (fs.existsSync(conflictsDir)) {
@@ -166,9 +182,9 @@ router.get('/api/conflicts', requireAuth, requireScope('ssss:read'), async (req,
166
182
  }
167
183
  }
168
184
 
169
- // Dynamic scan
185
+ // Dynamic scan for the requested brain (not always global)
170
186
  const { detectSemanticConflicts } = await import('../../core/conflict-detector.mjs');
171
- const list = getNodes(VAULT_DIR);
187
+ const list = getNodes(vaultDir);
172
188
  const dynamicConflicts = [];
173
189
  for (let i = 0; i < list.length; i++) {
174
190
  const found = detectSemanticConflicts(list[i], list.slice(0, i));
@@ -207,15 +223,17 @@ router.post('/api/conflicts/resolve', requireAuth, requireScope('memory:write'),
207
223
  return badRequest(res, "action must be either 'keep' or 'supersede'");
208
224
  }
209
225
 
210
- const inboxDir = path.join(BRAIN_DIR, 'memory-inbox');
226
+ const vaultDir = resolveVaultFromQuery(req);
227
+ const paths = pathsForVault(vaultDir);
228
+ const inboxDir = path.join(paths.brainDir, 'memory-inbox');
211
229
  const { resolveConflict } = await import('../../core/conflict-detector.mjs');
212
230
  const result = resolveConflict(conflict_id, inboxDir, action, winner_slug);
213
231
  if (!result.resolved) {
214
232
  return badRequest(res, result.error || 'Failed to resolve conflict');
215
233
  }
216
234
 
217
- invalidate(); // clear cache
218
- res.json({ success: true, conflict_id });
235
+ invalidate(vaultDir);
236
+ res.json({ success: true, conflict_id, vault_dir: vaultDir });
219
237
  } catch (err) {
220
238
  serverError(res, err);
221
239
  }
@@ -1,39 +1,58 @@
1
1
  import { Router } from 'express';
2
- import fs from 'node:fs';
3
2
  import path from 'node:path';
4
- import crypto from 'node:crypto';
5
3
  import { requireAuth, requireScope } from '../auth.mjs';
6
- import { serverError, ROOT, BRAIN_DIR, VAULT_DIR, SKILLS_DIR, INSTRUCTIONS, badRequest } from './_shared.mjs';
7
- import { logger } from '../../core/logger.mjs';
4
+ import {
5
+ serverError,
6
+ ROOT,
7
+ VAULT_DIR,
8
+ badRequest,
9
+ resolveVaultFromQuery,
10
+ } from './_shared.mjs';
8
11
  import { detectRuleFiles, importRuleFiles } from '../../core/import-rules.mjs';
9
12
 
10
13
  const router = Router();
11
14
 
12
15
  router.get('/api/import/rules', requireAuth, requireScope('memory:read'), (req, res) => {
13
16
  try {
14
- const allowedRoots = [ROOT, VAULT_DIR];
15
- const rawDirs = req.query.dir ? (Array.isArray(req.query.dir) ? req.query.dir : [req.query.dir]) : allowedRoots;
16
- const dirs = rawDirs.filter(d => allowedRoots.some(root => path.resolve(d).startsWith(path.resolve(root))));
17
+ const vaultDir = resolveVaultFromQuery(req);
18
+ const allowedRoots = [ROOT, VAULT_DIR, vaultDir, path.dirname(ROOT)];
19
+ const rawDirs = req.query.dir ? (Array.isArray(req.query.dir) ? req.query.dir : [req.query.dir]) : [ROOT];
20
+ const dirs = rawDirs.filter(d =>
21
+ allowedRoots.some(root => path.resolve(d).startsWith(path.resolve(root))),
22
+ );
17
23
  if (dirs.length === 0) return badRequest(res, 'No permitted directories specified');
18
24
  const detected = detectRuleFiles(dirs);
19
- res.json({ dirs, detected });
20
-
21
- } catch (err) { serverError(res, err); }
25
+ res.json({ dirs, detected, vault_dir: vaultDir });
26
+ } catch (err) {
27
+ serverError(res, err);
28
+ }
22
29
  });
23
30
 
24
31
  router.post('/api/import/rules', requireAuth, requireScope('memory:write'), async (req, res) => {
25
32
  try {
26
33
  const { dirs, force = false, dryRun = false } = req.body || {};
27
- const searchDirs = dirs?.length ? dirs : [ROOT, VAULT_DIR];
34
+ const vaultDir = resolveVaultFromQuery(req);
35
+ const searchDirs = dirs?.length ? dirs : [ROOT];
28
36
  const detected = detectRuleFiles(searchDirs);
29
37
  const toImport = req.body?.files?.length
30
38
  ? detected.filter(f => req.body.files.includes(f.absolutePath))
31
39
  : detected.filter(f => !f.alreadyImported || force);
32
- if (dryRun) return res.json({ dryRun: true, detected, toImport, imported: [], skipped: [], failed: [] });
33
- const result = await importRuleFiles(toImport, { force, vaultDir: VAULT_DIR });
34
- res.json({ detected, ...result });
35
-
36
- } catch (err) { serverError(res, err); }
40
+ if (dryRun) {
41
+ return res.json({
42
+ dryRun: true,
43
+ detected,
44
+ toImport,
45
+ imported: [],
46
+ skipped: [],
47
+ failed: [],
48
+ vault_dir: vaultDir,
49
+ });
50
+ }
51
+ const result = await importRuleFiles(toImport, { force, vaultDir });
52
+ res.json({ detected, vault_dir: vaultDir, ...result });
53
+ } catch (err) {
54
+ serverError(res, err);
55
+ }
37
56
  });
38
57
 
39
58
  export default router;
@@ -1,15 +1,49 @@
1
1
  import { Router } from 'express';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import crypto from 'node:crypto';
5
4
  import { requireAuth, requireScope } from '../auth.mjs';
6
- import { serverError, ROOT, BRAIN_DIR, VAULT_DIR, SKILLS_DIR, INSTRUCTIONS } from './_shared.mjs';
5
+ import {
6
+ serverError,
7
+ INSTRUCTIONS,
8
+ ROOT,
9
+ resolveVaultFromQuery,
10
+ pathsForVault,
11
+ } from './_shared.mjs';
7
12
  import { logger } from '../../core/logger.mjs';
8
13
 
9
14
  const router = Router();
10
15
 
16
+ function resolveInstructionsPath(req) {
17
+ const vaultDir = resolveVaultFromQuery(req);
18
+ const paths = pathsForVault(vaultDir);
19
+ // Prefer brain-local INSTRUCTIONS, then shared constant, then package ROOT
20
+ const candidates = [
21
+ paths.instructionsFile,
22
+ // surface compile often writes to parent of .agent (repo root)
23
+ path.join(path.dirname(path.dirname(paths.skillsDir)), 'INSTRUCTIONS.md'),
24
+ INSTRUCTIONS,
25
+ path.join(ROOT, 'INSTRUCTIONS.md'),
26
+ ];
27
+ for (const p of candidates) {
28
+ if (p && fs.existsSync(p)) return p;
29
+ }
30
+ return paths.instructionsFile || INSTRUCTIONS;
31
+ }
32
+
33
+ function sendTextResource(res, filePath, label) {
34
+ try {
35
+ if (!fs.existsSync(filePath)) {
36
+ return res.status(404).json({ error: `${label} not found`, path: filePath });
37
+ }
38
+ const content = fs.readFileSync(filePath, 'utf8');
39
+ res.type('text/markdown').send(content);
40
+ } catch (err) {
41
+ serverError(res, err);
42
+ }
43
+ }
44
+
11
45
  router.get('/api/instructions', requireAuth, requireScope('instructions:read'), (req, res) => {
12
- return sendTextResource(res, path.join(ROOT, 'INSTRUCTIONS.md'), 'instructions');
46
+ return sendTextResource(res, resolveInstructionsPath(req), 'instructions');
13
47
  });
14
48
 
15
49
  router.put('/api/instructions', requireAuth, requireScope('instructions:write'), (req, res) => {
@@ -18,8 +52,10 @@ router.put('/api/instructions', requireAuth, requireScope('instructions:write'),
18
52
  return res.status(400).json({ error: 'Missing or invalid `content` field.' });
19
53
  }
20
54
  try {
21
- fs.writeFileSync(INSTRUCTIONS, content, 'utf8');
22
- return res.json({ success: true, message: 'Instructions updated successfully' });
55
+ const target = resolveInstructionsPath(req);
56
+ fs.mkdirSync(path.dirname(target), { recursive: true });
57
+ fs.writeFileSync(target, content, 'utf8');
58
+ return res.json({ success: true, message: 'Instructions updated successfully', path: target });
23
59
  } catch (err) {
24
60
  logger.error('api', `Failed to write instructions: ${err.message}`);
25
61
  return res.status(500).json({ error: err.message });