total-recall-brain 3.24.0 → 3.24.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.
@@ -1,83 +1,188 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sync-repo — refresh this brain's skill definitions from the upstream templates.
4
+ *
5
+ * Replaces a stub that printed "Synced and verified skill: <name>", "Merging core
6
+ * invariants non-destructively" and "Sync Completed Successfully!" without
7
+ * fetching, merging or writing anything — while the skill's Core Directive #4
8
+ * told agents to run it to keep skills current. It also probed
9
+ * <agent>/memory-vault, which is not where a brain lives, so on a correct
10
+ * install it aborted with a misleading "run init first".
11
+ *
12
+ * What it actually does now:
13
+ * - resolves a real template source (env override, installed package,
14
+ * running-from-source checkout, or a shallow clone of upstream)
15
+ * - copies skill files, PRESERVING each destination's compiled
16
+ * "<!-- BEGIN INJECTED MEMORY -->" block, which is per-repo and must not be
17
+ * overwritten by a template
18
+ * - never touches memory-vault/ — that is user memory, not a template
19
+ * - reports the files it really changed, and exits non-zero if it changed nothing
20
+ * because it could not find a source
21
+ *
22
+ * Usage: node sync-repo.mjs [--dry-run] [--source <dir>]
23
+ */
1
24
  import fs from 'node:fs';
2
25
  import path from 'node:path';
3
26
  import os from 'node:os';
27
+ import { execFileSync } from 'node:child_process';
4
28
  import { fileURLToPath } from 'node:url';
5
29
 
6
- // Resolve agent paths
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
+ const DRY = process.argv.includes('--dry-run');
32
+ const srcFlag = process.argv.indexOf('--source');
33
+ const SRC_OVERRIDE = srcFlag !== -1 ? process.argv[srcFlag + 1] : process.env.TR_SCAFFOLD_DIR;
34
+
7
35
  const home = os.homedir();
8
36
  const AGENT_DIR = process.env.AGENT_DIR || path.join(home, '.agent');
9
- const VAULT_DIR = path.join(AGENT_DIR, 'memory-vault');
37
+ // A brain is <agent>/skills/total-recall — memory-vault, config and the rest all
38
+ // live INSIDE it. The old code looked for <agent>/memory-vault and never found one.
39
+ const BRAIN_DIR = path.join(AGENT_DIR, 'skills', 'total-recall');
10
40
  const SKILLS_DIR = path.join(AGENT_DIR, 'skills');
11
41
 
12
- console.log('🔄 Starting Total Recall Upstream Skill Synchronization...');
13
- console.log(`📂 Local Agent Directory: ${AGENT_DIR}`);
42
+ const INJECT_START = '<!-- BEGIN INJECTED MEMORY';
43
+ const INJECT_END = '<!-- END INJECTED MEMORY -->';
44
+
45
+ function log(m) { console.log(m); }
46
+ function fail(m) { console.error(`❌ ${m}`); process.exit(1); }
47
+
48
+ /** Locate real template files. Returns { dir, origin } or null. */
49
+ function resolveTemplateSource() {
50
+ if (SRC_OVERRIDE) {
51
+ if (!fs.existsSync(SRC_OVERRIDE)) fail(`--source path does not exist: ${SRC_OVERRIDE}`);
52
+ return { dir: SRC_OVERRIDE, origin: `explicit: ${SRC_OVERRIDE}` };
53
+ }
54
+ // Walk up looking for the package root rather than counting '..' segments:
55
+ // this script legitimately runs from two different depths (a brain at
56
+ // <agent>/skills/total-recall/scripts, and the template copy at
57
+ // <repo>/scaffold/.agent/skills/total-recall/scripts). A fixed relative path
58
+ // is right for one and silently wrong for the other — which resolved to the
59
+ // INSTALLED (older) package and would have reverted newer local templates.
60
+ const fromCheckout = (() => {
61
+ let dir = __dirname;
62
+ for (let i = 0; i < 8; i += 1) {
63
+ const pkg = path.join(dir, 'package.json');
64
+ const scaffold = path.join(dir, 'scaffold', '.agent', 'skills');
65
+ if (fs.existsSync(pkg) && fs.existsSync(scaffold)) {
66
+ try {
67
+ if (JSON.parse(fs.readFileSync(pkg, 'utf8')).name === 'total-recall-brain') return scaffold;
68
+ } catch { /* keep walking */ }
69
+ }
70
+ const up = path.dirname(dir);
71
+ if (up === dir) break;
72
+ dir = up;
73
+ }
74
+ return null;
75
+ })();
14
76
 
15
- // 1. Resolve upstream config
16
- let upstreamUrl = 'https://github.com/gregiteen/total-recall';
17
- try {
18
- const brainConfigPath = path.join(AGENT_DIR, 'config', 'brain.json');
19
- if (fs.existsSync(brainConfigPath)) {
20
- const config = JSON.parse(fs.readFileSync(brainConfigPath, 'utf8'));
21
- if (config.upstream) upstreamUrl = config.upstream;
77
+ const candidates = [
78
+ [fromCheckout, 'source checkout'],
79
+ [path.join(process.cwd(), 'node_modules', 'total-recall-brain', 'scaffold', '.agent', 'skills'), 'installed package'],
80
+ [path.join(home, 'node_modules', 'total-recall-brain', 'scaffold', '.agent', 'skills'), 'installed package (home)'],
81
+ ];
82
+ for (const [dir, origin] of candidates) {
83
+ if (dir && fs.existsSync(dir) && fs.statSync(dir).isDirectory()) return { dir, origin };
22
84
  }
23
- } catch {
24
- // Use default
85
+ return null;
25
86
  }
26
- console.log(`🌐 Upstream Repository URL: ${upstreamUrl}`);
27
87
 
28
- // 2. Perform synchronization simulation / stub logic with absolute robustness
29
- async function runSync() {
88
+ /** Last resort: shallow-clone upstream into a temp dir. */
89
+ function cloneUpstream() {
90
+ let upstream = 'https://github.com/gregiteen/total-recall';
30
91
  try {
31
- // Check if network is available / validate environment
32
- console.log('🔎 Auditing local skill installations...');
33
-
34
- if (!fs.existsSync(VAULT_DIR) || !fs.existsSync(SKILLS_DIR)) {
35
- throw new Error('Total Recall directories are missing. Run "npx total-recall init" first.');
92
+ const cfg = path.join(BRAIN_DIR, 'config', 'brain.json');
93
+ if (fs.existsSync(cfg)) {
94
+ const parsed = JSON.parse(fs.readFileSync(cfg, 'utf8'));
95
+ if (parsed.upstream) upstream = parsed.upstream;
36
96
  }
97
+ } catch { /* default */ }
98
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-sync-'));
99
+ log(`🌐 No local templates — shallow-cloning ${upstream}`);
100
+ try {
101
+ execFileSync('git', ['clone', '--depth', '1', '--quiet', upstream, tmp], { stdio: 'pipe' });
102
+ } catch (err) {
103
+ fs.rmSync(tmp, { recursive: true, force: true });
104
+ fail(`could not clone upstream (${err.message.split('\n')[0]}). Pass --source <dir> to sync offline.`);
105
+ }
106
+ const dir = path.join(tmp, 'scaffold', '.agent', 'skills');
107
+ if (!fs.existsSync(dir)) {
108
+ fs.rmSync(tmp, { recursive: true, force: true });
109
+ fail('upstream clone has no scaffold/.agent/skills — cannot sync.');
110
+ }
111
+ return { dir, origin: `clone of ${upstream}`, cleanup: tmp };
112
+ }
37
113
 
38
- const localSkills = fs.readdirSync(SKILLS_DIR);
39
- console.log(`✅ Found ${localSkills.length} local skill packages: ${localSkills.join(', ')}`);
40
-
41
- // In a real implementation, this would git fetch or fetch zip bundle from upstreamUrl.
42
- // For local resilience, we perform a non-destructive merge of scaffolded presets.
43
- console.log('📥 Fetching upstream skill templates...');
44
-
45
- // Simulate fetching core templates (e.g. ssss, project-management, total-recall itself)
46
- const templates = ['ssss', 'project-management', 'total-recall'];
47
- let updatedCount = 0;
48
-
49
- for (const t of templates) {
50
- console.log(`📦 Synced and verified skill: ${t}`);
51
- updatedCount++;
114
+ /** Copy src over dst, carrying dst's compiled injected-memory block across. */
115
+ function writePreservingInjectedBlock(srcFile, dstFile) {
116
+ let next = fs.readFileSync(srcFile, 'utf8');
117
+ if (fs.existsSync(dstFile)) {
118
+ const cur = fs.readFileSync(dstFile, 'utf8');
119
+ const s = cur.indexOf(INJECT_START);
120
+ const e = cur.indexOf(INJECT_END);
121
+ if (s !== -1 && e !== -1 && e > s) {
122
+ const block = cur.slice(s, e + INJECT_END.length);
123
+ const ns = next.indexOf(INJECT_START);
124
+ const ne = next.indexOf(INJECT_END);
125
+ next = (ns !== -1 && ne !== -1 && ne > ns)
126
+ ? next.slice(0, ns) + block + next.slice(ne + INJECT_END.length)
127
+ : `${next.replace(/\s*$/, '')}\n\n${block}\n`;
52
128
  }
129
+ if (cur === next) return 'identical';
130
+ }
131
+ if (!DRY) {
132
+ fs.mkdirSync(path.dirname(dstFile), { recursive: true });
133
+ fs.writeFileSync(dstFile, next);
134
+ }
135
+ return 'updated';
136
+ }
53
137
 
54
- console.log('📝 Merging core invariants non-destructively...');
55
- console.log('🛡️ Preserving custom user-authored memory nodes inside memory-vault/');
56
-
57
- // Trigger local surface compile to apply all changes
58
- console.log('⚙️ Re-compiling system memory surfaces...');
59
- try {
60
- const { compileSurface } = await import('../../../src/core/surface.mjs');
61
- const instructionsFile = path.join(process.cwd(), 'INSTRUCTIONS.md');
62
- const derivedDir = path.join(AGENT_DIR, 'memory-derived');
63
-
64
- const result = await compileSurface({
65
- vaultDir: VAULT_DIR,
66
- skillsDir: SKILLS_DIR,
67
- derivedDir,
68
- instructionsFile
69
- });
70
-
71
- console.log(`🚀 Compilation Complete: Processed ${result.nodesProcessed} nodes, injected ${result.skillsInjected} skills.`);
72
- } catch (err) {
73
- console.warn(`⚠️ Compilation skipped or ran into error: ${err.message}`);
74
- }
138
+ function walk(dir) {
139
+ const out = [];
140
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
141
+ const p = path.join(dir, e.name);
142
+ if (e.isDirectory()) {
143
+ // memory-vault is the user's own memory. A template must never overwrite it.
144
+ if (e.name === 'memory-vault' || e.name === 'memory-derived' || e.name === 'sessions') continue;
145
+ if (e.name === '.git' || e.name === 'node_modules') continue;
146
+ out.push(...walk(p));
147
+ } else out.push(p);
148
+ }
149
+ return out;
150
+ }
75
151
 
76
- console.log('🎉 Total Recall Skill Sync Completed Successfully!');
77
- } catch (error) {
78
- console.error(`❌ Sync Failed: ${error.message}`);
79
- process.exit(1);
152
+ log('🔄 Total Recall — syncing skill definitions from upstream templates');
153
+ log(`📂 Brain: ${BRAIN_DIR}`);
154
+ if (DRY) log('🔍 DRY RUN — nothing will be written');
155
+
156
+ if (!fs.existsSync(BRAIN_DIR)) fail(`no brain at ${BRAIN_DIR}. Run "npx total-recall init" first.`);
157
+
158
+ let source = resolveTemplateSource();
159
+ if (!source) source = cloneUpstream();
160
+ log(`📦 Template source: ${source.origin}`);
161
+
162
+ const skills = fs.readdirSync(source.dir, { withFileTypes: true })
163
+ .filter((e) => e.isDirectory()).map((e) => e.name);
164
+ if (skills.length === 0) fail(`template source has no skills: ${source.dir}`);
165
+
166
+ let updated = 0, identical = 0, created = 0;
167
+ const touched = [];
168
+ for (const skill of skills) {
169
+ const from = path.join(source.dir, skill);
170
+ const to = path.join(SKILLS_DIR, skill);
171
+ const isNew = !fs.existsSync(to);
172
+ for (const f of walk(from)) {
173
+ const rel = path.relative(from, f);
174
+ const res = writePreservingInjectedBlock(f, path.join(to, rel));
175
+ if (res === 'updated') { updated++; touched.push(`${skill}/${rel}`); } else identical++;
80
176
  }
177
+ if (isNew) created++;
81
178
  }
82
179
 
83
- runSync();
180
+ if (source.cleanup) fs.rmSync(source.cleanup, { recursive: true, force: true });
181
+
182
+ log('');
183
+ log(`${DRY ? '🔍' : '✅'} ${updated} file(s) ${DRY ? 'would change' : 'updated'}, ${identical} already current, ${created} new skill(s)`);
184
+ for (const t of touched.slice(0, 20)) log(` ${DRY ? '~' : '↳'} ${t}`);
185
+ if (touched.length > 20) log(` … and ${touched.length - 20} more`);
186
+ if (updated === 0) log(' Nothing to do — every skill file already matches the templates.');
187
+ log('');
188
+ log(DRY ? 'Re-run without --dry-run to apply.' : 'Run "npx total-recall compile" to rebuild the instruction surfaces.');
@@ -116,6 +116,33 @@ function printHelp() {
116
116
  `);
117
117
  }
118
118
 
119
+ function ensureBrainGitignore(SKILL_DIR) {
120
+ const gitignorePath = path.join(SKILL_DIR, '.gitignore');
121
+ const entries = [
122
+ 'memory-derived/', 'sessions/', '*.backup', 'logs/', 'node_modules/',
123
+ 'memory-vault/.events/', 'browser-profile/', '.snapshots/', 'daemon.pid',
124
+ ];
125
+ let existing = '';
126
+ if (fs.existsSync(gitignorePath)) existing = fs.readFileSync(gitignorePath, 'utf8');
127
+ const missing = entries.filter((e) => !existing.split('\n').some((l) => l.trim() === e));
128
+ if (missing.length === 0) return 0;
129
+ fs.appendFileSync(
130
+ gitignorePath,
131
+ '\n# Auto-added by total-recall backup — runtime/derived/sensitive, never versioned\n'
132
+ + missing.join('\n') + '\n',
133
+ );
134
+ console.error(` \u2705 .gitignore: added ${missing.length} protected entr${missing.length === 1 ? 'y' : 'ies'}`);
135
+ // Anything already tracked must also be untracked, or the ignore is inert.
136
+ for (const e of missing) {
137
+ const tracked = git(SKILL_DIR, 'ls-files', e.replace(/\/$/, ''));
138
+ if (tracked.ok && tracked.stdout) {
139
+ git(SKILL_DIR, 'rm', '-r', '--cached', '-f', '-q', e.replace(/\/$/, ''));
140
+ console.error(` \u2705 untracked ${e} (kept on disk)`);
141
+ }
142
+ }
143
+ return missing.length;
144
+ }
145
+
119
146
  async function pushGitBackup(remote, layer = 'auto') {
120
147
  const SKILL_DIR = getSkillDir(layer);
121
148
  if (!commandExists('git')) {
@@ -129,6 +156,19 @@ async function pushGitBackup(remote, layer = 'auto') {
129
156
  process.exit(1);
130
157
  }
131
158
 
159
+ // A brain is memory, not runtime state. Three classes of file have each
160
+ // broken a real backup by being versioned:
161
+ // memory-vault/.events/ append-only logs; default.jsonl reached 306MB and
162
+ // GitHub hard-rejects >100MB, silently killing every
163
+ // global backup for 5 weeks (2026-07-14 -> 08-19).
164
+ // browser-profile/ the rotation Chromium profile — live Stripe/GitHub
165
+ // cookies and Login Data. Was tracked and one
166
+ // successful push from landing on GitHub.
167
+ // .snapshots/ ~46MB vault tarballs stored inside the vault.
168
+ // Enforced on every push, not just --install, so brains created before this
169
+ // rule are corrected on their next backup.
170
+ ensureBrainGitignore(SKILL_DIR);
171
+
132
172
  // Initialise the skill folder as a git repo if needed
133
173
  const gitDir = path.join(SKILL_DIR, '.git');
134
174
  if (!fs.existsSync(gitDir)) {
@@ -170,6 +210,29 @@ async function pushGitBackup(remote, layer = 'auto') {
170
210
  const stamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
171
211
 
172
212
  if (!status.stdout) {
213
+ // A clean working tree does NOT mean the remote is current. Two scheduled
214
+ // jobs pointing the shared "backup" remote at different URLs is enough to
215
+ // break this: whichever ran second re-pointed the remote, found the tree
216
+ // clean, and returned "nothing to push" — so that destination silently
217
+ // froze for months while every run reported success. Only skip when the
218
+ // remote actually has our HEAD.
219
+ const head = git(SKILL_DIR, 'rev-parse', 'HEAD');
220
+ const localSha = head.ok ? (head.stdout || '').trim() : '';
221
+ const remoteHead = git(SKILL_DIR, 'ls-remote', remote, 'main');
222
+ const remoteSha = remoteHead.ok ? (remoteHead.stdout || '').split(/\s+/)[0] : '';
223
+ // No local commit to compare, or the remote already has it: genuinely
224
+ // nothing to do. Only a KNOWN mismatch justifies pushing a clean tree.
225
+ if (!localSha || remoteSha === localSha) {
226
+ console.error(` ✅ Brain unchanged — nothing to push (${stamp})`);
227
+ return;
228
+ }
229
+ if (remoteSha) {
230
+ console.error(` 📤 Working tree clean but ${remote} is behind — pushing existing commits...`);
231
+ const p = git(SKILL_DIR, 'push', remote, 'HEAD:main');
232
+ if (p.ok) console.error(` ✅ Brain pushed to backup (${stamp})`);
233
+ else console.error(` ❌ Push failed: ${p.stderr || p.stdout}`);
234
+ return;
235
+ }
173
236
  console.error(` ✅ Brain unchanged — nothing to push (${stamp})`);
174
237
  return;
175
238
  }
@@ -169,20 +169,24 @@ export function inspectProjectPackage(projectRoot) {
169
169
  } catch {
170
170
  return { declared: null, installed: null, isSourceTree: false, packageName: null };
171
171
  }
172
+ const isSourceTree =
173
+ pkg.name === PACKAGE_NAME &&
174
+ fs.existsSync(path.join(projectRoot, 'src', 'server', 'index.mjs'));
172
175
  const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
173
176
  const declared = deps[PACKAGE_NAME] || null;
174
177
  let installed = null;
175
- const nm = path.join(projectRoot, 'node_modules', PACKAGE_NAME, 'package.json');
176
- if (fs.existsSync(nm)) {
177
- try {
178
- installed = JSON.parse(fs.readFileSync(nm, 'utf8')).version || null;
179
- } catch {
180
- installed = null;
178
+ if (isSourceTree) {
179
+ installed = pkg.version || null;
180
+ } else {
181
+ const nm = path.join(projectRoot, 'node_modules', PACKAGE_NAME, 'package.json');
182
+ if (fs.existsSync(nm)) {
183
+ try {
184
+ installed = JSON.parse(fs.readFileSync(nm, 'utf8')).version || null;
185
+ } catch {
186
+ installed = null;
187
+ }
181
188
  }
182
189
  }
183
- const isSourceTree =
184
- pkg.name === PACKAGE_NAME &&
185
- fs.existsSync(path.join(projectRoot, 'src', 'server', 'index.mjs'));
186
190
  return { declared, installed, isSourceTree, packageName: pkg.name || null };
187
191
  }
188
192
 
@@ -321,38 +321,67 @@ export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide',
321
321
  // Note: Only invariants, preferences, and anti-patterns are included in instructions,
322
322
  // enforcing Category Partitioning (concepts, decisions, facts are search-only).
323
323
  const isImportant = (n) => n.importance === undefined || n.importance >= 3;
324
- const invariants = nodes.filter(n => n.category === 'invariants' && n.status === 'active' && !isExpired(n) && isImportant(n));
325
- const preferences = nodes.filter(n => n.category === 'preferences' && n.status === 'active' && !isExpired(n) && isImportant(n));
326
- const corrections = nodes.filter(n => n.category === 'anti-patterns' && n.status === 'active' && !isExpired(n) && isImportant(n));
324
+
325
+ // Determine current project/repo context to prevent cross-repo pollution
326
+ const currentRepoName = path.basename(process.cwd()).toLowerCase();
327
+
328
+ const isRelevantToRepo = (n) => {
329
+ // If explicitly marked global or no repo restriction, it applies everywhere
330
+ if (n.scope === 'global' || n._layer === 'global') {
331
+ // If the node text explicitly binds to specific other repos, skip it
332
+ if (Array.isArray(n.repos) && n.repos.length > 0) {
333
+ return n.repos.some(r => r.toLowerCase() === currentRepoName);
334
+ }
335
+ return true;
336
+ }
337
+ // If tagged with specific repos or project name
338
+ if (Array.isArray(n.repos) && n.repos.length > 0) {
339
+ return n.repos.some(r => r.toLowerCase() === currentRepoName);
340
+ }
341
+ if (n.project && typeof n.project === 'string') {
342
+ return n.project.toLowerCase() === currentRepoName;
343
+ }
344
+ return true;
345
+ };
327
346
 
328
- // Deduplicate nodes by compacted content hash (OKF non-destructive merge pattern).
329
- // When two nodes produce identical compacted text, keep the one with higher importance.
330
- const deduplicateNodes = (list) => {
347
+ const rawInvariants = nodes.filter(n => n.category === 'invariants' && n.status === 'active' && !isExpired(n) && isImportant(n) && isRelevantToRepo(n));
348
+ const rawPreferences = nodes.filter(n => n.category === 'preferences' && n.status === 'active' && !isExpired(n) && isImportant(n) && isRelevantToRepo(n));
349
+ const rawCorrections = nodes.filter(n => n.category === 'anti-patterns' && n.status === 'active' && !isExpired(n) && isImportant(n) && isRelevantToRepo(n));
350
+
351
+ // Deduplicate nodes by normalized content key and rank by priority/importance
352
+ const deduplicateAndRankNodes = (list, maxCount = 15) => {
331
353
  const seen = new Map();
332
354
  const deduped = [];
333
- for (const n of list) {
334
- const key = (n.body || n.content || '').trim().toLowerCase().substring(0, 200);
335
- if (seen.has(key)) {
336
- const existing = seen.get(key);
337
- if ((n.importance || 3) > (existing.importance || 3)) {
338
- // Replace with higher-importance node
339
- const idx = deduped.indexOf(existing);
340
- if (idx !== -1) deduped[idx] = n;
341
- seen.set(key, n);
342
- }
343
- // else skip the lower-importance duplicate
344
- } else {
345
- seen.set(key, n);
355
+
356
+ // Sort by importance (descending) and priority (absolute first)
357
+ const sorted = [...list].sort((a, b) => {
358
+ const aAbs = a.priority === 'absolute' ? 10 : 0;
359
+ const bAbs = b.priority === 'absolute' ? 10 : 0;
360
+ const aScore = (a.importance || 3) + aAbs;
361
+ const bScore = (b.importance || 3) + bAbs;
362
+ return bScore - aScore;
363
+ });
364
+
365
+ for (const n of sorted) {
366
+ // Normalize content to catch paraphrased/duplicate directives
367
+ const rawText = (n.body || n.content || n.title || '').trim().toLowerCase();
368
+ const normKey = rawText.replace(/[^a-z0-9]/g, '').substring(0, 120);
369
+
370
+ if (!seen.has(normKey)) {
371
+ seen.set(normKey, n);
346
372
  deduped.push(n);
347
373
  }
348
374
  }
349
- return deduped;
375
+ return deduped.slice(0, maxCount);
350
376
  };
351
377
 
378
+ const invariants = deduplicateAndRankNodes(rawInvariants, 15);
379
+ const preferences = deduplicateAndRankNodes(rawPreferences, 10);
380
+ const corrections = deduplicateAndRankNodes(rawCorrections, 15);
381
+
352
382
  const formatNodes = async (list) => {
353
- const unique = deduplicateNodes(list);
354
383
  const formatted = [];
355
- for (const n of unique) {
384
+ for (const n of list) {
356
385
  const snippet = await compactNode(n, derivedDir, force);
357
386
  formatted.push(snippet.startsWith('-') ? snippet : `- ${snippet}`);
358
387
  }
@@ -103,6 +103,10 @@
103
103
  "method": "POST",
104
104
  "path": "/api/context/stream"
105
105
  },
106
+ {
107
+ "method": "POST",
108
+ "path": "/api/daemon/restart"
109
+ },
106
110
  {
107
111
  "method": "GET",
108
112
  "path": "/api/dashboard/instructions"
@@ -202,5 +202,20 @@ router.get('/api/usage/providers', requireAuth, async (req, res) => {
202
202
  } catch (err) { serverError(res, err); }
203
203
  });
204
204
 
205
+ /**
206
+ * POST /api/daemon/restart
207
+ * Restarts the Active Intelligence background daemon process.
208
+ */
209
+ router.post('/api/daemon/restart', requireAuth, requireScope('config:write'), async (_req, res) => {
210
+ try {
211
+ const { stopDaemon, startDaemon } = await import('../../core/daemon-control.mjs');
212
+ stopDaemon();
213
+ const pid = startDaemon();
214
+ res.json({ success: true, message: `Daemon restarted successfully (PID ${pid})`, pid });
215
+ } catch (err) {
216
+ serverError(res, err);
217
+ }
218
+ });
219
+
205
220
  export default router;
206
221
  export { router as systemRouter };
@@ -45,7 +45,7 @@ router.get('/api/update/check', requireAuth, async (_req, res) => {
45
45
 
46
46
  const consumersBehind = projects.filter((p) => p.update_available).length;
47
47
  const updateAvailable =
48
- Boolean(latest && current && current !== latest) || consumersBehind > 0;
48
+ Boolean(latest && current && needsUpdate(current, latest)) || consumersBehind > 0;
49
49
 
50
50
  res.json({
51
51
  package: PACKAGE_NAME,