total-recall-brain 3.28.0 → 3.28.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,10 @@ vi.mock('./api', () => ({
11
11
  fetchHealth: vi.fn(),
12
12
  getApiBase: vi.fn(),
13
13
  setApiBase: vi.fn(),
14
+ // App calls fetchPlugins() on mount. A mock module that omits it makes
15
+ // vitest throw "No fetchPlugins export is defined on the ./api mock", which
16
+ // unmounts the whole tree and left every assertion staring at an empty div.
17
+ fetchPlugins: vi.fn().mockResolvedValue([]),
14
18
  }));
15
19
 
16
20
  vi.mock('./utils/onboarding', () => ({
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
  import { render, screen } from '@testing-library/react';
3
+ import { MemoryRouter } from 'react-router-dom';
3
4
  import PluginsPage from './PluginsPage';
4
5
 
5
6
  // Mock fetch globally
@@ -11,7 +12,14 @@ globalThis.fetch = mockFetch as unknown as typeof fetch;
11
12
 
12
13
  describe('PluginsPage', () => {
13
14
  it('renders plugins page', () => {
14
- render(<PluginsPage />);
15
- expect(screen.getByText(/Plugin/i)).toBeInTheDocument();
15
+ // PluginsPage reads useSearchParams(), so it must be rendered inside a
16
+ // Router — without one react-router throws and nothing mounts.
17
+ render(
18
+ <MemoryRouter>
19
+ <PluginsPage />
20
+ </MemoryRouter>,
21
+ );
22
+ // getByText(/Plugin/i) matched both the heading and the install button.
23
+ expect(screen.getByRole('heading', { name: /Plugins/i })).toBeInTheDocument();
16
24
  });
17
25
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.28.0",
3
+ "version": "3.28.2",
4
4
  "description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -0,0 +1,10 @@
1
+ # OpenWiki
2
+
3
+ Portable knowledge documentation for this Total Recall brain.
4
+
5
+ | Layer | Purpose |
6
+ |-------|---------|
7
+ | **Memory vault** | SSSS nodes (rules, prefs, facts) — machine-enforceable |
8
+ | **OpenWiki** | Human/agent docs (architecture, how-tos, stack notes) |
9
+
10
+ OpenWiki ships with Total Recall. Edit freely; do not put secrets here.
@@ -0,0 +1,5 @@
1
+ # Architecture (this brain)
2
+
3
+ Describe the systems this brain cares about: repos, stacks, deploy targets.
4
+
5
+ Project brains should document the local codebase; the global brain documents cross-repo identity and preferences.
@@ -0,0 +1,10 @@
1
+ # IDE personalization
2
+
3
+ ```bash
4
+ npx total-recall connect claude-code # CLAUDE.md
5
+ npx total-recall connect codex # AGENTS.md
6
+ npx total-recall connect cursor # .cursor/rules/
7
+ npx total-recall connect gemini # GEMINI.md
8
+ ```
9
+
10
+ Total Recall only writes inside managed inject markers. Your local rules stay intact.
@@ -0,0 +1,11 @@
1
+ # Memory
2
+
3
+ Use the Total Recall CLI for durable memory:
4
+
5
+ ```bash
6
+ npx total-recall remember invariant "Never commit secrets." --importance 5
7
+ npx total-recall recall "secrets"
8
+ npx total-recall compile
9
+ ```
10
+
11
+ Categories: invariants, preferences, facts, decisions, patterns, anti-patterns, concepts, lore.
@@ -0,0 +1,7 @@
1
+ # Secrets & API keys
2
+
3
+ Store credentials in Total Recall's secrets store (`config/secrets.enc` / future CLI).
4
+
5
+ **Never** put API keys in openwiki pages or compiled IDE instruction files.
6
+
7
+ Planned CLI surface (TR_CORE_FOCUS Phase 4): `secret set|list|rotate|audit` and usage/cost meters.
@@ -0,0 +1,7 @@
1
+ # Skills
2
+
3
+ - **Total Recall skill** (only nested agent skill): `.agent/skills/total-recall/SKILL.md`
4
+ - **User skills**: `.agent/skills/<name>/SKILL.md` — project or global
5
+ - **TR modules** (not skills): `.agent/skills/total-recall/modules/` — internal docs/scripts
6
+
7
+ TR will track skill installs across repos and support adaptive deploy (see TR_CORE_FOCUS).
@@ -79,6 +79,13 @@ const CLIENTS = {
79
79
  // Cline uses a .clinerules/ directory (primary); plain markdown, no frontmatter.
80
80
  target: path.join('.clinerules', 'total-recall.md'),
81
81
  render: instructions => instructions,
82
+ // Cline scans <project>/.clinerules/skills, <project>/.cline/skills,
83
+ // <project>/.claude/skills and <project>/.agents/skills (plus the global
84
+ // ~/.cline/skills and ~/.agents/skills). Only .clinerules/.cline/.agents are
85
+ // scanned by the `next` (SDK) bundle too — .claude/skills is VS Code-bundle
86
+ // only — so project into .agents/skills, which is also where Cline's own
87
+ // "New skill..." writes and where the codex/gemini/antigravity presets land.
88
+ skillsProjection: { scope: 'project', dir: path.join('.agents', 'skills') },
82
89
  },
83
90
  'claude-code': {
84
91
  label: 'Claude Code',
@@ -93,9 +100,8 @@ const CLIENTS = {
93
100
  label: 'Codex',
94
101
  mode: 'symlink',
95
102
  target: 'AGENTS.md',
96
- // Codex only discovers skills globally under $CODEX_HOME/skills (~/.codex/skills).
97
- // There is no project-local skills dir, so this projection is global by necessity.
98
- skillsProjection: { scope: 'home', dir: path.join('.codex', 'skills') }
103
+ // Codex discovers repository skills in <project>/.agents/skills.
104
+ skillsProjection: { scope: 'project', dir: path.join('.agents', 'skills') }
99
105
  },
100
106
  antigravity: {
101
107
  label: 'Antigravity',
@@ -954,8 +960,7 @@ export default async function connect(args) {
954
960
  }
955
961
  }
956
962
 
957
- // Project repo skills as native slash commands via the Agent Skills standard.
958
- // Each <agentDir>/skills/<name>/SKILL.md becomes /<name> in the target IDE.
963
+ // Project repo skills into the target IDE's Agent Skills directory.
959
964
  if (preset.skillsProjection) {
960
965
  const skills = discoverRepoSkills(agentDir);
961
966
  if (skills.length > 0) {
@@ -964,14 +969,14 @@ export default async function connect(args) {
964
969
  : path.join(cwd, preset.skillsProjection.dir);
965
970
  const skillResults = projectSkillsAsCommands(destDir, skills, opts);
966
971
  result.skill_commands = skillResults;
967
- const fresh = skillResults.filter(r => r.action === 'linked');
972
+ const fresh = skillResults.filter(r => r.action === 'linked' || r.action === 'copied');
968
973
  if (fresh.length > 0) {
969
974
  const destLabel = preset.skillsProjection.scope === 'home'
970
975
  ? destDir
971
976
  : (path.relative(cwd, destDir) || destDir);
972
977
  result.notes.push(
973
- ` Repo skills projected as slash commands → ${destLabel}/:\n` +
974
- fresh.map(r => ` /${r.name}`).join('\n')
978
+ ` Repo skills projected → ${destLabel}/:\n` +
979
+ fresh.map(r => ` ${r.name}`).join('\n')
975
980
  );
976
981
  }
977
982
  }
package/src/cli/init.mjs CHANGED
@@ -77,28 +77,52 @@ function copyDirMerge(src, dest, dryRun) {
77
77
  * Ship openwiki with the brain (global or project). Prefer packaged templates;
78
78
  * fall back to a minimal set of pages so every brain has docs.
79
79
  */
80
- function ensureOpenWiki(brainDir, isProject, dryRun) {
81
- const wikiDir = path.join(brainDir, 'openwiki');
82
- const templateRoot = path.join(ROOT, 'templates', 'openwiki');
83
- if (ensureDir(wikiDir, dryRun)) {
84
- logOk(`Created openwiki at ${path.relative(process.cwd(), wikiDir) || wikiDir}`);
80
+ async function ensureOpenWiki(brainDir, isProject, dryRun) {
81
+ const wikiDirs = [path.join(brainDir, 'openwiki')];
82
+ if (isProject) {
83
+ wikiDirs.push(path.join(process.cwd(), 'openwiki'));
85
84
  }
86
- if (fs.existsSync(templateRoot)) {
87
- copyDirMerge(templateRoot, wikiDir, dryRun);
88
- } else {
89
- // Minimal fallback pages when templates/ not packaged yet
90
- const pages = {
91
- 'README.md': `# OpenWiki\n\nPortable knowledge docs for this Total Recall brain (${isProject ? 'project' : 'global'}).\n\n- Not the SSSS memory vault — use vault nodes for rules/facts.\n- Use these pages for architecture, stack, and how-to knowledge.\n`,
92
- 'memory.md': `# Memory\n\nHow this brain stores invariants, preferences, and facts.\n\nUse \`npx total-recall remember\` / \`recall\` / \`compile\`.\n`,
93
- 'ide.md': `# IDE personalization\n\nConnect surfaces with \`npx total-recall connect <ide>\`.\nInjected blocks live inside BEGIN/END INJECTED MEMORY markers only.\n`,
94
- 'skills.md': `# Skills\n\nUser skills live in \`.agent/skills/<name>/\` (siblings of total-recall).\nTotal Recall tracks and deploys skills; it does not nest product skills inside itself.\n`,
95
- 'secrets.md': `# Secrets\n\nAPI keys and credentials belong in the encrypted secrets store — never in openwiki or instruction shims.\n`,
96
- };
97
- for (const [name, body] of Object.entries(pages)) {
98
- const dest = path.join(wikiDir, name);
99
- if (fs.existsSync(dest)) continue;
100
- if (!dryRun) fs.writeFileSync(dest, body);
101
- logOk(`Seeded openwiki/${name}`);
85
+ const templateRoot = fs.existsSync(path.join(ROOT, 'scaffold', 'openwiki'))
86
+ ? path.join(ROOT, 'scaffold', 'openwiki')
87
+ : path.join(ROOT, 'templates', 'openwiki');
88
+
89
+ for (const wikiDir of wikiDirs) {
90
+ if (ensureDir(wikiDir, dryRun)) {
91
+ logOk(`Created openwiki at ${path.relative(process.cwd(), wikiDir) || wikiDir}`);
92
+ }
93
+ if (fs.existsSync(templateRoot)) {
94
+ copyDirMerge(templateRoot, wikiDir, dryRun);
95
+ } else {
96
+ // Minimal fallback pages when templates/ not packaged yet
97
+ const pages = {
98
+ 'README.md': `# OpenWiki\n\nPortable knowledge docs for this Total Recall brain (${isProject ? 'project' : 'global'}).\n\n- Not the SSSS memory vault — use vault nodes for rules/facts.\n- Use these pages for architecture, stack, and how-to knowledge.\n`,
99
+ 'architecture.md': `# Architecture\n\nComponent overview, domain boundaries, and runtime execution flow.\n`,
100
+ 'stack.md': `# Stack\n\nRuntime dependencies, frameworks, and system tools.\n`,
101
+ 'memory.md': `# Memory\n\nHow this brain stores invariants, preferences, and facts.\n\nUse \`npx total-recall remember\` / \`recall\` / \`compile\`.\n`,
102
+ 'ide.md': `# IDE personalization\n\nConnect surfaces with \`npx total-recall connect <ide>\`.\nInjected blocks live inside BEGIN/END INJECTED MEMORY markers only.\n`,
103
+ 'skills.md': `# Skills\n\nUser skills live in \`.agent/skills/<name>/\` (siblings of total-recall).\nTotal Recall tracks and deploys skills; it does not nest product skills inside itself.\n`,
104
+ 'secrets.md': `# Secrets\n\nAPI keys and credentials belong in the encrypted secrets store — never in openwiki or instruction shims.\n`,
105
+ };
106
+ for (const [name, body] of Object.entries(pages)) {
107
+ const dest = path.join(wikiDir, name);
108
+ if (fs.existsSync(dest)) continue;
109
+ if (!dryRun) fs.writeFileSync(dest, body);
110
+ logOk(`Seeded openwiki/${name}`);
111
+ }
112
+ }
113
+ }
114
+
115
+ // Auto-ingest into memory graph when project is initialized
116
+ if (isProject && !dryRun) {
117
+ try {
118
+ const { runOpenWikiIngest } = await import('./ingest-openwiki.mjs');
119
+ const targetWiki = path.join(process.cwd(), 'openwiki');
120
+ if (fs.existsSync(targetWiki)) {
121
+ await runOpenWikiIngest([targetWiki], { silent: true });
122
+ logOk('Ingested openwiki into semantic knowledge graph');
123
+ }
124
+ } catch {
125
+ // Best-effort ingestion
102
126
  }
103
127
  }
104
128
  }
@@ -574,15 +598,15 @@ export default async function init(args) {
574
598
  // This is the non-interactive openwiki path. External `openwiki --init` is optional
575
599
  // and only offered interactively (that CLI has no --yes flag).
576
600
  logStep('3.6/4', 'Ensuring openwiki is present');
577
- ensureOpenWiki(brainDir, isProject, opts.dryRun);
601
+ await ensureOpenWiki(brainDir, isProject, opts.dryRun);
578
602
 
579
- // ── Step 3.7: Project skills as slash commands into the IDEs in use ──
603
+ // ── Step 3.7: Project skills into the IDEs in use ──
580
604
  // Scope matches the brain: `init --project` projects PROJECT skills into the
581
605
  // repo's IDE skill dirs for IDEs actually used here (.claude/, .agents/, or
582
606
  // the current process); `init` projects GLOBAL skills into installed IDEs'
583
- // global skill dirs. Codex (global-only) is opt-in for project skills.
607
+ // global skill dirs. Codex discovers project skills in .agents/skills.
584
608
  const skillScope = isProject ? 'project' : 'global';
585
- logStep('3.7/4', `Projecting ${skillScope} skills as slash commands (IDEs in use)`);
609
+ logStep('3.7/4', `Projecting ${skillScope} skills (IDEs in use)`);
586
610
  if (opts.dryRun) {
587
611
  const targets = detectActiveSkillTargets({ scope: skillScope, cwd });
588
612
  for (const t of targets.filter(t => t.supported)) {
@@ -600,10 +624,10 @@ export default async function init(args) {
600
624
  log(` No in-use IDE detected for ${skillScope} skills — run \`npx total-recall connect <ide>\` to wire them.`);
601
625
  } else {
602
626
  for (const t of wired) {
603
- const n = t.results.filter(r => ['linked', 'exists', 'source'].includes(r.action)).length;
627
+ const n = t.results.filter(r => ['linked', 'copied', 'exists', 'source'].includes(r.action)).length;
604
628
  logOk(`${t.label}: ${n} skill(s) → ${path.relative(cwd, t.destDir) || t.destDir}/`);
605
629
  }
606
- log(` Slash commands: ${skills.map(s => '/' + s.name).join(', ')}`);
630
+ log(` Discoverable skills: ${skills.map(s => s.name).join(', ')}`);
607
631
  if (available.length > 0) {
608
632
  log(` Also available (opt-in): ${available.map(t => t.clients.join('/')).join(', ')} — \`npx total-recall connect <ide>\``);
609
633
  }
@@ -2,8 +2,8 @@
2
2
  * Scope-aware skill → slash-command projection.
3
3
  *
4
4
  * A "repo skill" is any <skills-root>/<name>/SKILL.md directory (the Agent
5
- * Skills standard). Each IDE turns such a directory into a /<name> slash
6
- * command, but every IDE looks in a *different* place, and the right place
5
+ * Skills standard). Each IDE discovers skills in a different place, and that
6
+ * place
7
7
  * depends on the *scope* of the skill:
8
8
  *
9
9
  * PROJECT skills (authored in <repo>/.agent/skills or <repo>/.agents/skills)
@@ -16,9 +16,9 @@
16
16
  * machine. They are available in every repo.
17
17
  *
18
18
  * This mirrors the brain model: `init --project` ⇒ project skills; `init`
19
- * (global) ⇒ global skills. Codex has no project-local skills dir (it only
20
- * reads ~/.codex/skills globally), so it participates in GLOBAL projection only;
21
- * pushing project skills to Codex is opt-in via `connect codex`.
19
+ * (global) ⇒ global skills. Codex discovers project skills in
20
+ * <repo>/.agents/skills and global skills in ~/.codex/skills. In Codex,
21
+ * /skills opens the picker and $skill-name explicitly invokes a skill.
22
22
  */
23
23
  import fs from 'node:fs';
24
24
  import os from 'node:os';
@@ -54,9 +54,7 @@ export const IDE_SKILLS = [
54
54
  id: 'codex',
55
55
  label: 'Codex',
56
56
  clients: ['codex'],
57
- // Codex only discovers skills globally under ~/.codex/skills. There is no
58
- // project-local dir, so project skills are opt-in (namespaced) via connect.
59
- project: null,
57
+ project: { dir: ['.agents', 'skills'], markers: ['.agents', 'AGENTS.md'], env: ['CODEX_HOME'] },
60
58
  global: { dir: ['.codex', 'skills'], markers: ['.codex'] }
61
59
  },
62
60
  {
@@ -90,9 +88,25 @@ function pathExists(filePath) {
90
88
  // to heal back onto the canonical source, even without --force.
91
89
  const AUTO_GENERATED_MARKER = '**Auto-generated** by `npx total-recall';
92
90
 
91
+ // Written into a skill directory that projectSkillsAsCommands had to COPY
92
+ // (no symlink support — Windows without Developer Mode, some network/FAT
93
+ // volumes). A real directory carrying this file is Total Recall's own
94
+ // projection, never a user's skill, so it is refreshed on every run.
95
+ export const PROJECTION_MARKER_FILE = '.total-recall-projection.json';
96
+
97
+ function isProjectedCopy(entryPath) {
98
+ try {
99
+ return fs.statSync(entryPath).isDirectory()
100
+ && fs.existsSync(path.join(entryPath, PROJECTION_MARKER_FILE));
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
93
106
  function isStaleGeneratedDir(entryPath) {
94
107
  try {
95
108
  if (!fs.statSync(entryPath).isDirectory()) return false;
109
+ if (isProjectedCopy(entryPath)) return true;
96
110
  const content = fs.readFileSync(path.join(entryPath, 'SKILL.md'), 'utf8');
97
111
  return content.includes(AUTO_GENERATED_MARKER);
98
112
  } catch {
@@ -101,21 +115,55 @@ function isStaleGeneratedDir(entryPath) {
101
115
  }
102
116
 
103
117
  /**
104
- * Skill source roots, in priority order. The canonical Total Recall location
105
- * (.agent/skills, singular) wins, but we also scan the Antigravity-native
106
- * .agents/skills (plural) so a skill authored in either place is discovered.
118
+ * Put one skill at linkPath so the IDE can discover it:
119
+ * 1. a symlink RELATIVE to the destination dir — the projection then survives
120
+ * `git clone`, a moved checkout, or a different home directory (an absolute
121
+ * link only works on the machine that wrote it);
122
+ * 2. on Windows a directory junction (no Developer Mode / admin needed; Node
123
+ * resolves the target to an absolute path itself);
124
+ * 3. when the filesystem refuses links at all, a real COPY of the skill
125
+ * directory tagged with PROJECTION_MARKER_FILE so later runs refresh it.
126
+ */
127
+ function placeSkill(skillDir, linkPath) {
128
+ const source = path.resolve(skillDir);
129
+ const relativeTarget = path.relative(path.dirname(path.resolve(linkPath)), source) || '.';
130
+ try {
131
+ if (process.platform === 'win32') {
132
+ fs.symlinkSync(source, linkPath, 'junction');
133
+ } else {
134
+ fs.symlinkSync(relativeTarget, linkPath);
135
+ }
136
+ return 'linked';
137
+ } catch (err) {
138
+ const noLinks = ['EPERM', 'EACCES', 'ENOTSUP', 'EOPNOTSUPP', 'EINVAL', 'ENOSYS'];
139
+ if (!noLinks.includes(err?.code)) throw err;
140
+ }
141
+ fs.cpSync(source, linkPath, { recursive: true, dereference: true });
142
+ fs.writeFileSync(
143
+ path.join(linkPath, PROJECTION_MARKER_FILE),
144
+ JSON.stringify({ source: relativeTarget, projected_at: new Date().toISOString() }, null, 2) + '\n'
145
+ );
146
+ return 'copied';
147
+ }
148
+
149
+ /**
150
+ * Skill source roots, in priority order. The Total Recall location
151
+ * (.agent/skills, singular) wins, followed by repo-authored skills/ and
152
+ * already-projected .agents/skills. This avoids treating projected links as
153
+ * the source when the original is available.
107
154
  */
108
155
  export function skillSourceRoots(agentDir) {
109
156
  const roots = [path.join(agentDir, 'skills')];
110
157
  const parent = path.dirname(agentDir);
111
158
  if (path.basename(agentDir) === '.agent') {
159
+ roots.push(path.join(parent, 'skills'));
112
160
  roots.push(path.join(parent, '.agents', 'skills'));
113
161
  }
114
162
  return roots;
115
163
  }
116
164
 
117
165
  /**
118
- * Discover repo skills to expose as slash commands.
166
+ * Discover repo skills to expose to IDEs.
119
167
  * Every <root>/<name>/ directory containing a SKILL.md is a skill. When the
120
168
  * same name appears in more than one source root, the canonical root wins.
121
169
  */
@@ -141,7 +189,8 @@ export function discoverRepoSkills(agentDir) {
141
189
  }
142
190
 
143
191
  /**
144
- * Symlink each discovered skill dir into one IDE's Agent-Skills directory.
192
+ * Project each discovered skill dir into one IDE's Agent-Skills directory
193
+ * (relative symlink, Windows junction, or a marked copy — see placeSkill).
145
194
  * Idempotent, self-healing, and strictly additive: a symlink that is broken or
146
195
  * stale is refreshed even without --force; a *real* (non-symlink) entry is
147
196
  * never clobbered unless --force (so a user's own skill always wins a name
@@ -165,7 +214,10 @@ export function projectSkillsAsCommands(destDir, skills, opts = {}) {
165
214
  if (pathExists(linkPath)) {
166
215
  const stat = fs.lstatSync(linkPath);
167
216
  if (stat.isSymbolicLink()) {
168
- const current = path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath));
217
+ // Relative links resolve against the link's own directory; Windows
218
+ // junctions read back with a \\?\ long-path prefix that must be dropped.
219
+ const raw = fs.readlinkSync(linkPath).replace(/^\\\\\?\\/, '');
220
+ const current = path.resolve(path.dirname(linkPath), raw);
169
221
  if (current === wantTarget && fs.existsSync(linkPath) && !opts.force) {
170
222
  written.push({ name: skill.name, action: 'exists' });
171
223
  continue;
@@ -178,8 +230,7 @@ export function projectSkillsAsCommands(destDir, skills, opts = {}) {
178
230
  fs.rmSync(linkPath, { recursive: true, force: true });
179
231
  }
180
232
  }
181
- fs.symlinkSync(skill.skillDir, linkPath);
182
- written.push({ name: skill.name, action: 'linked' });
233
+ written.push({ name: skill.name, action: placeSkill(skill.skillDir, linkPath) });
183
234
  }
184
235
  return written;
185
236
  }
@@ -20,13 +20,8 @@ import fs from 'node:fs';
20
20
  import crypto from 'node:crypto';
21
21
  import { loadQueue } from '../core/research-queue.mjs';
22
22
 
23
- import { agentDir, brainDir, totalRecallToken } from '../core/config.mjs';
24
-
25
- const AGENT_DIR = agentDir;
26
- const BRAIN_CONFIG = path.join(brainDir, 'config', 'brain.json');
27
- const SYNC_STATE = path.join(brainDir, 'config', 'sync-state.json');
28
- const CLIENTS_REGISTRY = path.join(brainDir, 'config', 'clients.json');
29
- const INSTRUCTIONS_FILE = path.join(AGENT_DIR, 'INSTRUCTIONS.md');
23
+ import { totalRecallToken } from '../core/config.mjs';
24
+ import { resolveBrainDir } from './agent-dir.mjs';
30
25
 
31
26
  function parseArgs(args) {
32
27
  const opts = { json: false, brain: null, token: null, help: false };
@@ -55,21 +50,21 @@ function printHelp() {
55
50
  `);
56
51
  }
57
52
 
58
- function loadBrainConfig() {
59
- if (!fs.existsSync(BRAIN_CONFIG)) return null;
60
- try { return JSON.parse(fs.readFileSync(BRAIN_CONFIG, 'utf8')); }
53
+ function loadBrainConfig(brainConfigPath) {
54
+ if (!fs.existsSync(brainConfigPath)) return null;
55
+ try { return JSON.parse(fs.readFileSync(brainConfigPath, 'utf8')); }
61
56
  catch { return null; }
62
57
  }
63
58
 
64
- function loadSyncState() {
65
- if (!fs.existsSync(SYNC_STATE)) return {};
66
- try { return JSON.parse(fs.readFileSync(SYNC_STATE, 'utf8')); }
59
+ function loadSyncState(syncStatePath) {
60
+ if (!fs.existsSync(syncStatePath)) return {};
61
+ try { return JSON.parse(fs.readFileSync(syncStatePath, 'utf8')); }
67
62
  catch { return {}; }
68
63
  }
69
64
 
70
- function loadClientsRegistry() {
71
- if (!fs.existsSync(CLIENTS_REGISTRY)) return {};
72
- try { return JSON.parse(fs.readFileSync(CLIENTS_REGISTRY, 'utf8'))?.clients || {}; }
65
+ function loadClientsRegistry(clientsRegistryPath) {
66
+ if (!fs.existsSync(clientsRegistryPath)) return {};
67
+ try { return JSON.parse(fs.readFileSync(clientsRegistryPath, 'utf8'))?.clients || {}; }
73
68
  catch { return {}; }
74
69
  }
75
70
 
@@ -90,21 +85,31 @@ function sha256(buf) {
90
85
  return crypto.createHash('sha256').update(buf).digest('hex');
91
86
  }
92
87
 
93
- async function probeBrain(brainUrl, token) {
88
+ async function probeBrain(brainUrl, token, brainId) {
94
89
  const headers = {};
95
90
  if (token) headers.Authorization = `Bearer ${token}`;
96
91
  const out = { reachable: false, instructions_sha256: null, instructions_bytes: null, error: null };
97
92
  try {
98
- const res = await fetch(`${brainUrl.replace(/\/$/, '')}/api/instructions`, { headers });
93
+ const url = new URL(`${brainUrl.replace(/\/$/, '')}/api/instructions`);
94
+ if (brainId) url.searchParams.set('brain', brainId);
95
+ const res = await fetch(url, { headers });
99
96
  if (!res.ok) {
100
97
  out.error = `HTTP ${res.status}`;
101
98
  return out;
102
99
  }
103
- const body = await res.json();
100
+ const contentType = res.headers?.get?.('content-type') || '';
101
+ const body = contentType.includes('application/json') ? await res.json() : null;
104
102
  out.reachable = true;
105
- out.instructions_sha256 = body.sha256 || null;
106
- out.instructions_bytes = body.bytes || null;
107
- out.modified = body.modified || null;
103
+ if (body) {
104
+ out.instructions_sha256 = body.sha256 || (body.content ? sha256(body.content) : null);
105
+ out.instructions_bytes = body.bytes || (body.content ? Buffer.byteLength(body.content) : null);
106
+ out.modified = body.modified || null;
107
+ } else {
108
+ const content = await res.text();
109
+ out.instructions_sha256 = sha256(content);
110
+ out.instructions_bytes = Buffer.byteLength(content);
111
+ out.modified = res.headers?.get?.('last-modified') || null;
112
+ }
108
113
  } catch (err) {
109
114
  out.error = err.message;
110
115
  }
@@ -115,9 +120,12 @@ export default async function statusCmd(args) {
115
120
  const opts = parseArgs(args);
116
121
  if (opts.help) { printHelp(); return; }
117
122
 
118
- const brainConfig = loadBrainConfig();
119
- const syncState = loadSyncState();
120
- const clientsRegistry = loadClientsRegistry();
123
+ const brainDir = resolveBrainDir();
124
+ const agentDir = path.dirname(path.dirname(brainDir));
125
+ const instructionsFile = path.join(path.dirname(agentDir), 'INSTRUCTIONS.md');
126
+ const brainConfig = loadBrainConfig(path.join(brainDir, 'config', 'brain.json'));
127
+ const syncState = loadSyncState(path.join(brainDir, 'config', 'sync-state.json'));
128
+ const clientsRegistry = loadClientsRegistry(path.join(brainDir, 'config', 'clients.json'));
121
129
  const brainUrl = opts.brain || brainConfig?.url || null;
122
130
  const token = opts.token || brainConfig?.token || totalRecallToken || null;
123
131
 
@@ -175,11 +183,11 @@ export default async function statusCmd(args) {
175
183
  } catch {}
176
184
 
177
185
  let localInstructions = null;
178
- if (fs.existsSync(INSTRUCTIONS_FILE)) {
179
- const buf = fs.readFileSync(INSTRUCTIONS_FILE);
180
- const stat = fs.statSync(INSTRUCTIONS_FILE);
186
+ if (fs.existsSync(instructionsFile)) {
187
+ const buf = fs.readFileSync(instructionsFile);
188
+ const stat = fs.statSync(instructionsFile);
181
189
  localInstructions = {
182
- path: INSTRUCTIONS_FILE,
190
+ path: instructionsFile,
183
191
  sha256: sha256(buf),
184
192
  bytes: stat.size,
185
193
  modified: stat.mtime.toISOString()
@@ -188,14 +196,17 @@ export default async function statusCmd(args) {
188
196
 
189
197
  let remote = null;
190
198
  if (brainUrl) {
191
- remote = await probeBrain(brainUrl, token);
199
+ const brainId = brainConfig?.layer === 'project' && brainConfig?.name
200
+ ? `project:${brainConfig.name}`
201
+ : null;
202
+ remote = await probeBrain(brainUrl, token, brainId);
192
203
  }
193
204
 
194
205
  const inSync = !!(localInstructions && remote?.instructions_sha256 &&
195
206
  remote.instructions_sha256 === localInstructions.sha256);
196
207
 
197
208
  const instructionsMtime = localInstructions
198
- ? fs.statSync(INSTRUCTIONS_FILE).mtimeMs
209
+ ? fs.statSync(instructionsFile).mtimeMs
199
210
  : null;
200
211
  const clientProjections = projectionsStatus(clientsRegistry, instructionsMtime);
201
212