total-recall-brain 3.39.4 → 3.39.7

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 (43) hide show
  1. package/docs/reference/cli-reference.md +3 -2
  2. package/package.json +1 -1
  3. package/scaffold/.agent/skills/total-recall/SKILL.md +0 -1
  4. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +1 -1
  5. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
  6. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-sync.mjs +12 -9
  7. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +1 -1
  8. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +1 -1
  9. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/leader-election.mjs +19 -10
  10. package/src/cli/agent-dir.mjs +41 -23
  11. package/src/cli/config.mjs +23 -0
  12. package/src/cli/connect.mjs +3 -0
  13. package/src/cli/context.mjs +11 -1
  14. package/src/cli/deploy.mjs +4 -1
  15. package/src/cli/doctor.mjs +57 -0
  16. package/src/cli/dream.mjs +2 -2
  17. package/src/cli/forget.mjs +5 -3
  18. package/src/cli/init.mjs +14 -1
  19. package/src/cli/memory-init.mjs +11 -2
  20. package/src/cli/plugin/create.mjs +18 -2
  21. package/src/cli/plugin/install.mjs +11 -1
  22. package/src/cli/rebuild.mjs +26 -3
  23. package/src/cli/remember.mjs +2 -2
  24. package/src/cli/rules.mjs +32 -6
  25. package/src/cli/share.mjs +2 -2
  26. package/src/cli/snapshot.mjs +2 -2
  27. package/src/cli/status.mjs +2 -2
  28. package/src/cli/sync.mjs +14 -1
  29. package/src/core/auto-compile.mjs +7 -2
  30. package/src/core/config.mjs +43 -13
  31. package/src/core/daemon-loop.mjs +26 -4
  32. package/src/core/dream.mjs +4 -2
  33. package/src/core/embeddings.mjs +33 -5
  34. package/src/core/fact-seeker.mjs +5 -2
  35. package/src/core/logger.mjs +65 -0
  36. package/src/core/plugin-store.mjs +10 -1
  37. package/src/core/repo-sync.mjs +71 -0
  38. package/src/core/runtime.mjs +7 -2
  39. package/src/core/surface.mjs +40 -4
  40. package/src/server/api.mjs +6 -3
  41. package/src/server/index.mjs +5 -2
  42. package/src/server/routes/_shared.mjs +18 -2
  43. package/scaffold/.agent/skills/total-recall/references/crm.md +0 -21
@@ -128,8 +128,9 @@ action and the largest rules; `draft --out policy.json` scaffolds the policy wit
128
128
  each rule's original text; shorten the directives by hand (keep every operative
129
129
  constraint), narrow `actions`, or exclude locally with `enabled:false` plus a
130
130
  `reason`; `apply policy.json` validates hashes and merges into the single
131
- `context:policy` decision; `verify` exits 1 on stale or orphan curation or an
132
- action capsule over budget; `prune [--apply] [--archive]` drops stale entries and
131
+ `context:policy` decision; `verify [--json]` exits 1 on stale or orphan curation or
132
+ an action capsule over budget; `prune [--apply] [--archive] [--json]` drops stale
133
+ entries and
133
134
  lists expired, superseded or duplicate rules. Originals are never modified.
134
135
 
135
136
  ### `recall`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.39.4",
3
+ "version": "3.39.7",
4
4
  "description": "Portable SSSS memory, local retrieval and task instructions with explicit plugin loading.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,6 @@ Total Recall is working memory and the operation toolbox. Before decisions about
19
19
  | Remember, recall, context, compile, dream, proposals, vault | Core portable memory and SSSS contracts | [Memory and instructions](references/memory.md) |
20
20
  | Credentials, passwords, sessions, passkeys, PATs, encryption, rotation, projections, sync | `total-recall auth --help`; `auth secret --help` | [Auth and secrets](references/auth.md) |
21
21
  | Documentation hubs and graph ingestion | `total-recall openwiki --help`; `openwiki hubs --json` | [OpenWiki](references/openwiki.md) |
22
- | Accounts, buyers, records, branding, portable CRM instances | `total-recall crm status`; `crm menu` | [CRM](references/crm.md) |
23
22
  | Nodes, access, enrollment, control server, presence, dispatch | `total-recall mesh --help` | [Mesh](references/mesh.md) |
24
23
  | Install, author, configure and run plugins | `total-recall plugin --help`; `plugin list --json` | [Plugins](references/plugins.md) |
25
24
  | Machines, networks, fleet audits and verified hardening | `total-recall security status --json`; owning plugin skill | [Security](references/security.md) |
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tr-plugin-auth",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "White-label Total Recall auth plugin: Passkey biometric FIDO2 and Google OAuth2 authentication.",
5
5
  "type": "module",
6
6
  "main": "cli.mjs",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
3
3
  "id": "auth",
4
4
  "name": "Auth & Secrets",
5
- "version": "1.0.0",
5
+ "version": "1.0.1",
6
6
  "description": "Hardware WebAuthn biometric FIDO2 passkeys, OAuth session security, and access control.",
7
7
  "author": "Total Recall contributors",
8
8
  "license": "MIT",
@@ -7,17 +7,20 @@ import { resolveSecretsPath, loadSecrets, saveSecrets, ensureMeta, appendAudit,
7
7
 
8
8
  const REQUEST_TIMEOUT_MS = 10_000;
9
9
 
10
- function serverPort() {
11
- const value = Number(process.env.TR_SERVER_PORT || process.env.PORT || 3000);
12
- return Number.isInteger(value) && value > 0 && value <= 65535 ? value : 3000;
10
+ function serverPort(override) {
11
+ // The leader's port is whatever it advertised on the mesh. Assuming the local
12
+ // default made sync fail with "fetch failed" against a leader that was up and
13
+ // reachable on a different port (the cloud brain serves on 3900, not 3000).
14
+ const candidate = Number(override ?? process.env.TR_SERVER_PORT ?? process.env.PORT ?? 3000);
15
+ return Number.isInteger(candidate) && candidate > 0 && candidate <= 65535 ? candidate : 3000;
13
16
  }
14
17
 
15
- function meshUrl(ip, route) {
18
+ function meshUrl(ip, route, port) {
16
19
  const octets = String(ip || '').split('.').map(Number);
17
20
  if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255) || octets[0] !== 100 || octets[1] < 64 || octets[1] > 127) {
18
21
  throw new Error('Leader IP is outside the Tailscale CGNAT range');
19
22
  }
20
- return `http://${ip}:${serverPort()}${route}`;
23
+ return `http://${ip}:${serverPort(port)}${route}`;
21
24
  }
22
25
 
23
26
  export function getSecretsChecksum() {
@@ -39,8 +42,8 @@ export function secretsSyncMode(env = process.env) {
39
42
  * anything. The node's store is never replaced wholesale and the node keeps
40
43
  * its own master password.
41
44
  */
42
- export async function syncGrantedSecrets(leaderIp, { dir = brainDir, fetchImpl = fetch } = {}) {
43
- const base = meshUrl(leaderIp, '/api/secrets/mesh');
45
+ export async function syncGrantedSecrets(leaderIp, { dir = brainDir, fetchImpl = fetch, port } = {}) {
46
+ const base = meshUrl(leaderIp, '/api/secrets/mesh', port);
44
47
  const listResponse = await fetchImpl(base, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
45
48
  if (!listResponse.ok) throw new Error(`Leader returned ${listResponse.status}`);
46
49
  const { keys } = await listResponse.json();
@@ -76,6 +79,6 @@ export async function syncLoop() {
76
79
  if (await isLeader()) return;
77
80
  const leaderInfo = await getLeaderInfo();
78
81
  if (!leaderInfo?.ip) return;
79
- try { await syncGrantedSecrets(leaderInfo.ip); }
80
- catch (err) { logger.error('secrets-sync', 'Failed to sync granted secrets', { error: err.message }); }
82
+ try { await syncGrantedSecrets(leaderInfo.ip, { port: leaderInfo.brain_port }); }
83
+ catch (err) { logger.error('secrets-sync', 'Failed to sync granted secrets', { error: err.message, leader: leaderInfo.ip, port: leaderInfo.brain_port ?? 'default' }); }
81
84
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tr-plugin-mesh",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Headscale and Tailscale SSH control-server mesh administration and node discovery",
6
6
  "scripts": {
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
3
3
  "id": "mesh",
4
4
  "name": "Headscale",
5
- "version": "0.2.0",
5
+ "version": "0.2.1",
6
6
  "description": "Control-server mesh administration, Tailscale SSH, and peer node discovery.",
7
7
  "license": "MIT",
8
8
  "branding": {
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { getMeshPeers, getMeshSelf } from './mesh.mjs';
3
+ import { getMeshPeers, getMeshSelf, listEnrichedMeshNodes } from './mesh.mjs';
4
4
  import { brainDir } from './config.mjs';
5
5
 
6
6
  /**
@@ -26,12 +26,12 @@ export const FAILOVER_BOUND_MS = 12_000;
26
26
  * Operator pin for which node leads.
27
27
  *
28
28
  * Lowest-online-IP is deterministic, but it says nothing about whether that node
29
- * can actually run the daemon. On this mesh the shared droplet holds the lowest
30
- * IP (100.64.0.1) and runs no Total Recall at all — it captured leadership
31
- * permanently, so it could never serve `/api/secrets/sync` and every other node
32
- * idled as a follower: no research, no dream cycle, no surface recompile,
33
- * anywhere. Followers skip the task loop entirely, so "lowest IP" quietly became
34
- * "nobody works".
29
+ * can actually run the daemon. A shared or infrastructure node can hold the
30
+ * lowest address while running no Total Recall at all, so it captures
31
+ * leadership permanently and can never serve `/api/secrets/sync`; every other
32
+ * node idles as a follower: no research, no dream cycle, no surface recompile,
33
+ * anywhere. Followers skip the task loop entirely, so "lowest IP" quietly
34
+ * became "nobody works".
35
35
  *
36
36
  * Set `TR_LEADER_IP` to the mesh IP of the machine that owns the canonical brain
37
37
  * on EVERY node that runs Total Recall, so they all agree on the answer. A node
@@ -70,18 +70,27 @@ function compareNodes(a, b) {
70
70
  * each node wrote a private, node-local lease document and could elect itself.
71
71
  */
72
72
  export async function getLeaderInfo() {
73
- const nodes = getMeshPeers({ includeSelf: true }).filter((node) => node.online);
73
+ // Use the ENTITY-ENRICHED node list, not getMeshPeers(). `brain_port` is an
74
+ // entity variable: it lives in the vault's mesh-node document and is absent
75
+ // from raw Tailscale status. getMeshPeers() reads only that status, so its
76
+ // `brain_port` was always null and every caller fell back to port 3000 --
77
+ // while the leader was reachable the whole time on its real port.
78
+ const nodes = listEnrichedMeshNodes().filter((node) => node.online);
74
79
  if (nodes.length === 0) return null;
75
80
 
76
81
  const pin = leaderPin();
77
82
  if (pin) {
78
83
  const pinned = nodes.find((node) => node.ip === pin);
79
84
  if (!pinned) return null;
80
- return { hostname: pinned.hostname, ip: pinned.ip, strategy: 'pinned-mesh-ip' };
85
+ return { hostname: pinned.hostname, ip: pinned.ip, brain_port: pinned.brain_port ?? null, strategy: 'pinned-mesh-ip' };
81
86
  }
82
87
 
83
88
  const leader = [...nodes].sort(compareNodes)[0];
84
- return { hostname: leader.hostname, ip: leader.ip, strategy: 'lowest-mesh-ip' };
89
+ // `brain_port` is carried through because the leader does not necessarily
90
+ // serve on the default port. It was dropped here, so callers such as
91
+ // secrets-sync fell back to 3000 and reported "fetch failed" against a leader
92
+ // that was up and reachable the whole time on a different port.
93
+ return { hostname: leader.hostname, ip: leader.ip, brain_port: leader.brain_port ?? null, strategy: 'lowest-mesh-ip' };
85
94
  }
86
95
 
87
96
  /**
@@ -12,6 +12,7 @@
12
12
  import fs from 'node:fs';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
+ import { detectProjectBrain as detectProjectBrainInConfig } from '../core/config.mjs';
15
16
 
16
17
  /**
17
18
  * Parse --global / --project layer flags from CLI args.
@@ -39,32 +40,18 @@ export function getGlobalBrainDir() {
39
40
  }
40
41
 
41
42
  /**
42
- * Detect a project-level brain by checking <cwd>/.agent/skills/total-recall/.
43
- * Returns { agentDir, brainDir } or null.
43
+ * Detect a project-level brain by checking `<dir>/.agent/skills/total-recall/`
44
+ * (and the `.agents` spelling). Returns `{ agentDir, brainDir, projectRoot }`.
45
+ *
46
+ * Delegates to core/config.mjs so there is exactly ONE implementation of the
47
+ * rule. These were two independent copies that had already diverged: one
48
+ * checked `.agent` only, ignored `AGENT_DIR`, and required `SKILL.md`, so they
49
+ * could disagree about whether a project brain exists at all — and their
50
+ * callers then disagreed about where the surface belongs.
44
51
  */
45
52
  export function detectProjectBrain(startDir = process.cwd()) {
46
53
  if (process.env._TR_TEST_AGENT_DIR) return null;
47
- let dir = startDir;
48
- const homeDir = os.homedir();
49
- while (dir !== path.dirname(dir)) {
50
- if (dir === homeDir) break; // don't detect global as project
51
- const candidates = [
52
- { dir: path.join(dir, '.agent'), candidate: path.join(dir, '.agent', 'skills', 'total-recall') },
53
- { dir: path.join(dir, '.agents'), candidate: path.join(dir, '.agents', 'skills', 'total-recall') }
54
- ];
55
- for (const c of candidates) {
56
- if (fs.existsSync(path.join(c.candidate, 'SKILL.md')) || fs.existsSync(path.join(c.candidate, 'memory-vault'))) {
57
- return {
58
- agentDir: c.dir,
59
- // Project brain lives at the detected candidate — never alias to global.
60
- brainDir: c.candidate,
61
- projectRoot: dir,
62
- };
63
- }
64
- }
65
- dir = path.dirname(dir);
66
- }
67
- return null;
54
+ return detectProjectBrainInConfig(startDir);
68
55
  }
69
56
 
70
57
  /**
@@ -122,6 +109,37 @@ export function getBothBrains(targetPath = process.cwd()) {
122
109
  };
123
110
  }
124
111
 
112
+ /**
113
+ * The directory an instruction surface belongs in, which is NOT always the
114
+ * agent dir. Harnesses read `AGENTS.md`/`CLAUDE.md` at the workspace root:
115
+ * global -> <home>/.agent (the agent dir IS the workspace root)
116
+ * project -> <projectRoot> (the repo root, NOT <repo>/.agent)
117
+ *
118
+ * Writing a project surface into `<repo>/.agent/` puts it where no harness
119
+ * looks, while the compile still reports success and "0 drift". Every caller
120
+ * that writes INSTRUCTIONS.md or client shims must use this instead of
121
+ * `resolveAgentDir`, which names the brain's own directory.
122
+ */
123
+ export function resolveSurfaceDir(layer = 'auto', targetPath = process.cwd()) {
124
+ if (process.env.AGENT_DIR || process.env._TR_TEST_AGENT_DIR) {
125
+ return process.env.AGENT_DIR || process.env._TR_TEST_AGENT_DIR;
126
+ }
127
+ if (layer === 'global') return getGlobalAgentDir();
128
+ const project = detectProjectBrain(targetPath);
129
+ if (!project) {
130
+ if (layer === 'project') throw new Error('No project brain found. Run `npx total-recall init --project` to create one.');
131
+ return getGlobalAgentDir();
132
+ }
133
+ return project.projectRoot || project.agentDir;
134
+ }
135
+
136
+ /**
137
+ * The path of the canonical instruction surface for a layer.
138
+ */
139
+ export function resolveInstructionsFile(layer = 'auto', targetPath = process.cwd()) {
140
+ return path.join(resolveSurfaceDir(layer, targetPath), 'INSTRUCTIONS.md');
141
+ }
142
+
125
143
  /**
126
144
  * Determine the default layer for a memory category.
127
145
  * Always defaults to project brain. Use --global to explicitly target global.
@@ -25,6 +25,11 @@ const KEY_MAP = {
25
25
  'rules_invariants_budget_chars': { file: 'surface', path: 'rules.invariants_budget_chars', type: 'number' },
26
26
  'rules_preferences_budget_chars': { file: 'surface', path: 'rules.preferences_budget_chars', type: 'number' },
27
27
  'rules_corrections_budget_chars': { file: 'surface', path: 'rules.corrections_budget_chars', type: 'number' },
28
+
29
+ // Token budget for the `context` instruction capsule. Raise it above the
30
+ // default when every rule should load for every action instead of being
31
+ // narrowed by applicability.
32
+ 'context_budget': { file: 'surface', path: 'context.budget', type: 'number' },
28
33
  };
29
34
 
30
35
  function resolveKeyInfo(key) {
@@ -113,6 +118,23 @@ function castAndValidate(valueStr, type, allowedValues) {
113
118
  return valueStr;
114
119
  }
115
120
 
121
+ export function readConfigValue(key, layer = 'global') {
122
+ const info = KEY_MAP[key];
123
+ if (!info) throw new Error(`Unknown config key: ${key}`);
124
+ const configDir = path.join(resolveBrainDir(layer), 'config');
125
+ const filePath = path.join(configDir, `${info.file}.yml`);
126
+ if (!fs.existsSync(filePath)) return undefined;
127
+ let parsed = {};
128
+ try { parsed = yaml.parse(fs.readFileSync(filePath, 'utf8')) || {}; } catch { return undefined; }
129
+ const parts = info.path.split('.');
130
+ let current = parsed;
131
+ for (const part of parts) {
132
+ if (current === null || current === undefined) return undefined;
133
+ current = current[part];
134
+ }
135
+ return current;
136
+ }
137
+
116
138
  export default async function configCommand(args) {
117
139
  const { layer, remainingArgs } = parseLayerFlag(args);
118
140
  const command = remainingArgs[0] || 'get';
@@ -140,6 +162,7 @@ export default async function configCommand(args) {
140
162
  rules_invariants_budget_chars Characters for the invariants section of CLAUDE.md/AGENTS.md (number; default 9000)
141
163
  rules_preferences_budget_chars Same for preferences (default 4000)
142
164
  rules_corrections_budget_chars Same for corrections (default 8000)
165
+ context_budget Token budget for the context instruction capsule (number; default 4000)
143
166
  allowed_origins CORS origins (e.g. "http://localhost:5173,http://localhost:3000")
144
167
 
145
168
  Examples:
@@ -315,6 +315,9 @@ function bootstrapAgentDir(agentDir) {
315
315
  }
316
316
 
317
317
  function readInstructions(cwd, agentDir) {
318
+ // `cwd` IS the project root the caller means, so read the surface there.
319
+ // resolveSurfaceDir() would re-detect from process.cwd() and could name a
320
+ // different project than the caller is operating on.
318
321
  const candidates = [
319
322
  path.join(cwd, 'INSTRUCTIONS.md'),
320
323
  path.join(agentDir, 'INSTRUCTIONS.md')
@@ -5,6 +5,7 @@ import { mergeGlobalRuleNodes, legacyRuleContributions } from '../core/surface.m
5
5
  import { compileContext, capsuleResponse, renderCapsule } from '../core/context-compiler.mjs';
6
6
  import { isBrainEnabled } from '../core/brain-registry.mjs';
7
7
  import { surfaceInputsHash } from '../core/command-surface.mjs';
8
+ import { readConfigValue } from './config.mjs';
8
9
 
9
10
  export default async function context(args = []) {
10
11
  if (!args.length || args.includes('--help')) {
@@ -13,7 +14,16 @@ export default async function context(args = []) {
13
14
  }
14
15
  const { layer, remainingArgs } = parseLayerFlag(args);
15
16
  const query = remainingArgs[0];
16
- let actions = [], budget = 4000, format = 'text', includeKnowledge = false, debug = false;
17
+ const configuredBudget = (() => {
18
+ for (const candidate of [layer === 'global' ? 'global' : 'project', 'global']) {
19
+ try {
20
+ const value = readConfigValue('context_budget', candidate);
21
+ if (Number.isFinite(value) && value > 0) return value;
22
+ } catch { /* fall through to the next layer */ }
23
+ }
24
+ return undefined;
25
+ })();
26
+ let actions = [], budget = configuredBudget ?? 4000, format = 'text', includeKnowledge = false, debug = false;
17
27
  for (let i = 1; i < remainingArgs.length; i++) {
18
28
  const flag = remainingArgs[i];
19
29
  if (flag === '--knowledge') { includeKnowledge = true; continue; }
@@ -40,6 +40,7 @@ import fs from 'node:fs';
40
40
  import path from 'node:path';
41
41
  import os from 'node:os';
42
42
  import { fileURLToPath } from 'node:url';
43
+ import { resolveSurfaceDir } from './agent-dir.mjs';
43
44
  import yaml from 'yaml';
44
45
  import crypto from 'node:crypto';
45
46
  import { loadPluginModule } from '../core/plugin-runtime.mjs';
@@ -1689,7 +1690,9 @@ ${fmtLine('Target VFS: ', '~/.agent/')}
1689
1690
  vaultDir: path.join(BRAIN_DIR, 'memory-vault'),
1690
1691
  skillsDir: path.join(AGENT_DIR, 'skills'),
1691
1692
  derivedDir: path.join(BRAIN_DIR, 'memory-derived'),
1692
- instructionsFile: path.join(AGENT_DIR, 'INSTRUCTIONS.md'),
1693
+ // This compile reads the hardcoded global brain above, so the surface
1694
+ // must be the global one too; auto-detect would resolve a project.
1695
+ instructionsFile: path.join(resolveSurfaceDir('global'), 'INSTRUCTIONS.md'),
1693
1696
  });
1694
1697
  logOk('Initial compile complete');
1695
1698
  } catch (err) {
@@ -260,6 +260,63 @@ export default async function doctor() {
260
260
  }
261
261
  }
262
262
 
263
+ // ── Instruction surface freshness ──
264
+ hr();
265
+ logInfo('INSTRUCTION SURFACE FRESHNESS');
266
+
267
+ // A surface that stopped being rewritten is invisible from the exit code:
268
+ // `compile` printed "Post-build verification passed: 0 drift" for months while
269
+ // the file harnesses actually read stayed frozen, because drift detection
270
+ // compares the vault to the jsonl indexes and never inspects the surface.
271
+ // This compares the surface against the vault hash it was built from.
272
+ try {
273
+ const { instructionsFilePath } = await import('../core/config.mjs');
274
+ const { computeVaultHash } = await import('../core/surface.mjs');
275
+ const { resolveBrainDir } = await import('./agent-dir.mjs');
276
+
277
+ const surfacePath = instructionsFilePath();
278
+ const stat = fs.statSync(surfacePath, { throwIfNoEntry: false });
279
+
280
+ // Compare against the vault that OWNS this surface. Using the global brain
281
+ // unconditionally compared a project surface against the global vault and
282
+ // warned on every run, even straight after a rebuild.
283
+ const brainDir = resolveBrainDir();
284
+ const vaultDir = path.join(brainDir, 'memory-vault');
285
+ const hashFile = path.join(brainDir, 'memory-derived', 'vault-hash.txt');
286
+ const brainExists = fs.existsSync(vaultDir);
287
+
288
+ if (!stat || !stat.isFile() || stat.size === 0) {
289
+ if (brainExists) {
290
+ // A brain exists but produced no surface: exactly the silent failure
291
+ // this check exists to catch, so it is a real failure.
292
+ logFail(`No instruction surface at ${surfacePath} — this is the file harnesses read.`);
293
+ failures++;
294
+ } else {
295
+ // Nothing initialised here yet; a missing surface is expected.
296
+ logOk(`No brain at ${brainDir} yet; no surface required.`);
297
+ }
298
+ } else {
299
+ logOk(`Surface present: ${surfacePath} (${stat.size} bytes)`);
300
+
301
+ if (!brainExists || !fs.existsSync(hashFile)) {
302
+ logWarn(`No compiled vault hash at ${hashFile}; freshness not verified.`);
303
+ warnings++;
304
+ } else {
305
+ const stored = fs.readFileSync(hashFile, 'utf8').trim();
306
+ const current = computeVaultHash(vaultDir);
307
+ if (stored && current && stored !== current) {
308
+ logWarn(`Vault ${brainDir} changed since the surface was compiled — run \`total-recall rebuild\`.`);
309
+ warnings++;
310
+ } else {
311
+ logOk(`Surface is current with ${brainDir}.`);
312
+ }
313
+ }
314
+ }
315
+ } catch (err) {
316
+ logWarn(`Surface freshness could not be determined: ${err.message}`);
317
+ warnings++;
318
+ }
319
+
263
320
  // ── Diagnostics Summary ──
264
321
  hr();
265
322
  console.log('\n ┌─────────────────────────────────────────────────────────┐');
package/src/cli/dream.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import path from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
- import { resolveAgentDir, resolveBrainDir, parseLayerFlag } from './agent-dir.mjs';
16
+ import { resolveAgentDir, resolveBrainDir, parseLayerFlag, resolveSurfaceDir } from './agent-dir.mjs';
17
17
 
18
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
19
 
@@ -59,7 +59,7 @@ export default async function dream(args) {
59
59
  const skillsDir = path.join(agentDir, 'skills');
60
60
  const derivedDir = path.join(brainDir, 'memory-derived');
61
61
  const conflictsDir = path.join(brainDir, 'memory-inbox', 'conflicts');
62
- const instructionsFile = path.join(agentDir, 'INSTRUCTIONS.md');
62
+ const instructionsFile = path.join(resolveSurfaceDir(effectiveLayer), 'INSTRUCTIONS.md');
63
63
 
64
64
  console.error('\n 🌙 Starting dream cycle...\n');
65
65
  const startTime = Date.now();
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { resolveAgentDir, resolveBrainDir, parseLayerFlag, getBothBrains } from './agent-dir.mjs';
3
+ import { resolveAgentDir, resolveBrainDir, parseLayerFlag, getBothBrains, resolveSurfaceDir } from './agent-dir.mjs';
4
4
  import { compileSurface } from '../core/surface.mjs';
5
5
  import { isSafeVaultName } from '../core/vault.mjs';
6
6
  import { getNodes } from '../core/vault-cache.mjs';
@@ -65,8 +65,10 @@ export default async function forget(args) {
65
65
  if (!remainingArgs.includes('--no-compile')) {
66
66
  const brainDir = path.dirname(globalVaultDir);
67
67
  const agentDir = path.dirname(path.dirname(brainDir));
68
+ // This branch operates on the GLOBAL vault, so its surface is the global
69
+ // one. `layer` is not in scope here and must not be invented.
68
70
  await compileSurface({ vaultDir: globalVaultDir, skillsDir: path.join(agentDir, 'skills'),
69
- derivedDir: path.join(brainDir, 'memory-derived'), instructionsFile: path.join(agentDir, 'INSTRUCTIONS.md'), semantic: false });
71
+ derivedDir: path.join(brainDir, 'memory-derived'), instructionsFile: path.join(resolveSurfaceDir('global'), 'INSTRUCTIONS.md'), semantic: false });
70
72
  }
71
73
  return;
72
74
  }
@@ -109,7 +111,7 @@ export default async function forget(args) {
109
111
  const vaultDir = path.join(resolvedBrainDir, 'memory-vault');
110
112
  const derivedDir = path.join(resolvedBrainDir, 'memory-derived');
111
113
  const skillsDir = path.join(resolvedAgentDir, 'skills');
112
- const instructionsFile = path.join(resolvedAgentDir, 'INSTRUCTIONS.md');
114
+ const instructionsFile = path.join(resolveSurfaceDir(layer), 'INSTRUCTIONS.md');
113
115
  const layerLabel = layer === 'project' ? '[project]' : '[global]';
114
116
 
115
117
  // Attempt deletion
package/src/cli/init.mjs CHANGED
@@ -249,6 +249,9 @@ export default async function init(args) {
249
249
  if (!fs.existsSync(path.join(authSource, 'plugin.json'))) throw new Error('Required auth scaffold is missing');
250
250
  if (!fs.existsSync(authTarget) && !opts.dryRun) fs.cpSync(authSource, authTarget, { recursive: true, errorOnExist: true, force: false });
251
251
 
252
+ // Every scaffold plugin's node_modules are wired in Step 2.5, after the skill
253
+ // tree has been copied into the brain (see the note there).
254
+
252
255
  // ── Step 2: Seed core skills ──
253
256
  logStep('2/4', 'Installing core skills into .agent/skills/');
254
257
 
@@ -304,9 +307,19 @@ export default async function init(args) {
304
307
  }
305
308
 
306
309
  // ── Step 2.5: Create data directories inside the meta-skill (brain dir) ──
310
+ // Wire every scaffold plugin's dependencies AFTER the skill copy above: the
311
+ // plugin directories only exist once the skill tree has been copied, so an
312
+ // earlier pass silently skipped everything except auth (copied separately).
313
+ // A plugin shipped without its node_modules aborts plugin loading for the whole
314
+ // scope — the symptom was 'Cannot find package express' on an unrelated command.
307
315
  if (!opts.dryRun) {
308
316
  const { preparePluginDependencies } = await import('../core/plugin-dependencies.mjs');
309
- preparePluginDependencies(path.join(brainDir, 'plugins', 'auth'));
317
+ const installedPluginsDir = path.join(brainDir, 'plugins');
318
+ for (const id of (fs.existsSync(installedPluginsDir) ? fs.readdirSync(installedPluginsDir) : [])) {
319
+ const target = path.join(installedPluginsDir, id);
320
+ if (!fs.existsSync(path.join(target, 'package.json'))) continue;
321
+ preparePluginDependencies(target);
322
+ }
310
323
  }
311
324
  logStep('2.5/4', 'Creating user data directories inside meta-skill');
312
325
 
@@ -1,6 +1,7 @@
1
1
  /** Initialize portable memory without provisioning feature runtimes. */
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
4
5
  import { getGlobalAgentDir } from './agent-dir.mjs';
5
6
  import { registerProjectBrain, writeBrainIdentity, VAULT_CATEGORIES } from '../core/memory-brain.mjs';
6
7
 
@@ -25,7 +26,12 @@ export default async function init(args = []) {
25
26
  }
26
27
  const skillFile = path.join(brainDir, 'SKILL.md');
27
28
  if (!fs.existsSync(skillFile)) {
28
- fs.copyFileSync(new URL('../../templates/memory/SKILL.md', import.meta.url), skillFile);
29
+ // Resolve the template by PATH, not `new URL(..., import.meta.url)`. Under a
30
+ // transform (vitest, and any loader that does not hand back a file: URL)
31
+ // import.meta.url is not a file URL and the copy throws
32
+ // "The URL must be of scheme file", which aborted the whole init.
33
+ const template = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates', 'memory', 'SKILL.md');
34
+ fs.copyFileSync(template, skillFile);
29
35
  }
30
36
  const existingIdentity = fs.existsSync(path.join(brainDir, 'config', 'brain.json'));
31
37
  writeBrainIdentity(brainDir, { name: existingIdentity ? undefined : project ? path.basename(process.cwd()) : 'global',
@@ -38,7 +44,10 @@ export default async function init(args = []) {
38
44
  const { compileSurface } = await import('../core/surface.mjs');
39
45
  const stats = await compileSurface({
40
46
  vaultDir: path.join(brainDir, 'memory-vault'), skillsDir: path.join(agentDir, 'skills'),
41
- derivedDir: path.join(brainDir, 'memory-derived'), instructionsFile: path.join(agentDir, 'INSTRUCTIONS.md'),
47
+ derivedDir: path.join(brainDir, 'memory-derived'),
48
+ // This function knows the layer as a boolean, not a `layer` variable, and
49
+ // already passes projectRoot below. Use the same source of truth.
50
+ instructionsFile: path.join(project ? process.cwd() : agentDir, 'INSTRUCTIONS.md'),
42
51
  projectRoot: project ? process.cwd() : path.dirname(agentDir), semantic: false,
43
52
  });
44
53
  console.log(`Initialized ${project ? 'project' : 'global'} memory: ${brainDir} (${stats.nodesProcessed} nodes)`);
@@ -300,6 +300,22 @@ export async function run(argv = []) {
300
300
  const args = Array.isArray(argv) ? argv.slice(3) : [];
301
301
  const sub = args[0] || "status";
302
302
  const json = args.includes("--json");
303
+ const wantsHelp = args.includes("--help") || args.includes("-h");
304
+
305
+ if (wantsHelp || sub === "help") {
306
+ const subs = ["status", ...Object.keys(IMPLEMENTED)];
307
+ console.log(\`${pluginName} v${manifest.version}
308
+
309
+ Usage: total-recall ${id} <subcommand> [options]
310
+
311
+ Subcommands:
312
+ \${subs.map((s) => \` \${s}\`).join("\\n")}
313
+
314
+ Options:
315
+ --json Machine-readable output
316
+ --help, -h Show this help\`);
317
+ return;
318
+ }
303
319
 
304
320
  if (sub === "status") {
305
321
  const status = { plugin: ${JSON.stringify(id)}, implemented: Object.keys(IMPLEMENTED) };
@@ -312,7 +328,7 @@ export async function run(argv = []) {
312
328
 
313
329
  if (IMPLEMENTED[sub]) return IMPLEMENTED[sub](args.slice(1));
314
330
 
315
- console.error(\`${pluginName}: "\${sub}" is not implemented.\`);
331
+ console.error(\`${pluginName}: "\${sub}" is not implemented. Run 'total-recall ${id} --help' for available subcommands.\`);
316
332
  process.exitCode = 2;
317
333
  }
318
334
  export default run;
@@ -494,7 +510,7 @@ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'plugin.json'), 'utf
494
510
 
495
511
  test('manifest identifies the plugin', () => {
496
512
  assert.equal(manifest.id, ${JSON.stringify(id)});
497
- assert.match(manifest.version, /^\\\\d+\\\\.\\\\d+\\\\.\\\\d+/);
513
+ assert.match(manifest.version, /^\\d+\\.\\d+\\.\\d+/);
498
514
  assert.equal(manifest.license, 'MIT');
499
515
  });
500
516
 
@@ -33,7 +33,17 @@ export async function installPlugin(args = []) {
33
33
  return;
34
34
  }
35
35
  const { autoCompileSurfaces } = await import('../../core/auto-compile.mjs');
36
- if (!await autoCompileSurfaces({ layer: isGlobal ? 'global' : 'project', semantic: false, force: true })) process.exitCode = 1;
36
+ // Compile the layer the plugin actually landed in. `scope` is not enough on
37
+ // its own: from a directory with no project brain, projectPluginsDir(cwd)
38
+ // resolves to the global plugins dir, so a plugin installed into the global
39
+ // brain still reports scope 'project'. Ask the resolver whether a project
40
+ // brain exists at all, and otherwise fall back to the global layer — the one
41
+ // that was really written. Assuming 'project' compiled nothing and printed
42
+ // "Auto-compile failed: No project brain found" after a successful install.
43
+ const { resolveBrainDir } = await import('../agent-dir.mjs');
44
+ let targetLayer = 'global';
45
+ try { resolveBrainDir('project'); targetLayer = 'project'; } catch { targetLayer = 'global'; }
46
+ if (!await autoCompileSurfaces({ layer: targetLayer, semantic: false, force: true })) process.exitCode = 1;
37
47
  } catch (err) {
38
48
  console.error(`❌ ${err.message}`);
39
49
  process.exit(1);