total-recall-brain 3.39.7 → 3.40.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.39.7",
3
+ "version": "3.40.1",
4
4
  "description": "Portable SSSS memory, local retrieval and task instructions with explicit plugin loading.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -75,7 +75,7 @@
75
75
  "homepage": "https://github.com/gregiteen/total-recall#readme",
76
76
  "dependencies": {
77
77
  "@simplewebauthn/server": "^13.3.2",
78
- "@ssss/cli": "npm:@gregiteen/ssss-cli@0.11.0",
78
+ "@ssss/cli": "npm:@gregiteen/ssss-cli@0.11.2",
79
79
  "bcrypt": "^6.0.0",
80
80
  "better-sqlite3": "^12.11.1",
81
81
  "cookie-parser": "^1.4.7",
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { spawnSync } from 'node:child_process';
20
+ import crypto from 'node:crypto';
20
21
  import fs from 'node:fs';
21
22
  import os from 'node:os';
22
23
  import path from 'node:path';
@@ -64,8 +65,22 @@ function parseArgs(args) {
64
65
  return opts;
65
66
  }
66
67
 
68
+ /**
69
+ * The backup repo's git directory lives OUTSIDE the project tree, under
70
+ * ~/.agent/brain-backups. A `.git` inside `<project>/.agent/skills/total-recall`
71
+ * makes the project repo see an embedded repository: `git add -A` records it as a
72
+ * gitlink (mode 160000) with no .gitmodules, which cannot be cloned back.
73
+ */
74
+ export function brainGitDir(skillDir) {
75
+ const abs = path.resolve(skillDir);
76
+ const owner = path.basename(path.resolve(abs, '..', '..', '..')) || 'brain';
77
+ const tag = crypto.createHash('sha1').update(abs).digest('hex').slice(0, 8);
78
+ return path.join(os.homedir(), '.agent', 'brain-backups', `${owner}-${tag}.git`);
79
+ }
80
+
67
81
  function git(cwd, ...gitArgs) {
68
- const result = spawnSync('git', gitArgs, { cwd, stdio: 'pipe' });
82
+ const env = { ...process.env, GIT_DIR: brainGitDir(cwd), GIT_WORK_TREE: path.resolve(cwd) };
83
+ const result = spawnSync('git', gitArgs, { cwd, env, stdio: 'pipe' });
69
84
  return {
70
85
  ok: result.status === 0,
71
86
  stdout: result.stdout?.toString().trim() || '',
@@ -73,6 +88,56 @@ function git(cwd, ...gitArgs) {
73
88
  };
74
89
  }
75
90
 
91
+ /**
92
+ * Move a legacy in-tree `<brain>/.git` to the external git dir, and keep the
93
+ * enclosing project repo from absorbing the brain directory. Idempotent.
94
+ */
95
+ export function adoptExternalGitDir(skillDir) {
96
+ const inTree = path.join(skillDir, '.git');
97
+ const external = brainGitDir(skillDir);
98
+ let moved = false;
99
+ if (fs.existsSync(inTree) && fs.statSync(inTree).isDirectory() && !fs.existsSync(external)) {
100
+ fs.mkdirSync(path.dirname(external), { recursive: true });
101
+ try {
102
+ fs.renameSync(inTree, external);
103
+ } catch {
104
+ fs.cpSync(inTree, external, { recursive: true });
105
+ fs.rmSync(inTree, { recursive: true, force: true });
106
+ }
107
+ spawnSync('git', ['--git-dir', external, 'config', 'core.worktree', path.resolve(skillDir)], { stdio: 'pipe' });
108
+ moved = true;
109
+ }
110
+ if (fs.existsSync(external) && !fs.existsSync(inTree)) excludeFromParentRepo(skillDir);
111
+ return moved;
112
+ }
113
+
114
+ /** Add the brain dir to the enclosing repo's local exclude unless that repo tracks it on purpose. */
115
+ function excludeFromParentRepo(skillDir) {
116
+ try {
117
+ const top = spawnSync('git', ['-C', skillDir, 'rev-parse', '--show-toplevel'], { stdio: 'pipe' });
118
+ if (top.status !== 0) return;
119
+ const root = top.stdout.toString().trim();
120
+ const rel = path.relative(fs.realpathSync(root), fs.realpathSync(skillDir)).split(path.sep).join('/');
121
+ if (!rel || rel.startsWith('..')) return;
122
+ const tracked = spawnSync('git', ['-C', root, 'ls-files', '-s', '--', rel], { stdio: 'pipe' }).stdout.toString().trim();
123
+ if (/^160000 /.test(tracked) && !tracked.includes('\n')) {
124
+ // A stale gitlink (embedded repo with no .gitmodules) cannot be cloned back: unstage it.
125
+ spawnSync('git', ['-C', root, 'rm', '--cached', '-f', '-q', '--', rel], { stdio: 'pipe' });
126
+ } else if (tracked) {
127
+ return; // the project repo versions its brain files itself
128
+ }
129
+ const excludePath = path.join(root, '.git', 'info', 'exclude');
130
+ try {
131
+ const entry = `/${rel}/`;
132
+ const current = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, 'utf8') : '';
133
+ if (!current.split('\n').includes(entry)) {
134
+ fs.mkdirSync(path.dirname(excludePath), { recursive: true });
135
+ fs.appendFileSync(excludePath, `${current && !current.endsWith('\n') ? '\n' : ''}${entry}\n`);
136
+ }
137
+ } catch { /* parent exclude is best effort */ }
138
+ } catch { /* best effort: never fail a backup over the parent's exclude file */ }
139
+ }
140
+
76
141
  function defaultOutput(encrypt) {
77
142
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
78
143
  const suffix = encrypt ? '.tar.gz.gpg' : '.tar.gz';
@@ -169,8 +234,10 @@ async function pushGitBackup(remote, layer = 'auto') {
169
234
  // rule are corrected on their next backup.
170
235
  ensureBrainGitignore(SKILL_DIR);
171
236
 
172
- // Initialise the skill folder as a git repo if needed
173
- const gitDir = path.join(SKILL_DIR, '.git');
237
+ // Initialise the skill folder as a git repo if needed. The git dir lives outside
238
+ // the project tree; a legacy in-tree .git is moved there first.
239
+ adoptExternalGitDir(SKILL_DIR);
240
+ const gitDir = brainGitDir(SKILL_DIR);
174
241
  if (!fs.existsSync(gitDir)) {
175
242
  console.error(` 🔧 Initialising git repo in ${SKILL_DIR}`);
176
243
  const init = git(SKILL_DIR, 'init', '-b', 'main');
@@ -178,6 +245,7 @@ async function pushGitBackup(remote, layer = 'auto') {
178
245
  git(SKILL_DIR, 'init');
179
246
  git(SKILL_DIR, 'checkout', '-b', 'main');
180
247
  }
248
+ excludeFromParentRepo(SKILL_DIR);
181
249
  }
182
250
 
183
251
  // Resolve the remote: if the argument is an existing remote name, use its URL.
@@ -43,7 +43,15 @@ export const CLIENTS = {
43
43
  globalMemoryPath: path.join(os.homedir(), '.dsh', 'memory', 'MEMORY.md'),
44
44
  globalUserPath: path.join(os.homedir(), '.dsh', 'memory', 'user.md'),
45
45
  localTarget: 'AGENTS.md',
46
- skillsProjection: { scope: 'project', dir: path.join('.agents', 'skills') }
46
+ skillsProjection: { scope: 'project', dir: path.join('.agents', 'skills') },
47
+ // DSH's default agent preset mounts dsh-skill-filesystem with no config, so
48
+ // the roots it scans are its own defaults: <dshHome>/skills (user-dsh) plus
49
+ // <repo>/.agents/skills and <repo>/.dsh/skills. Its host-plane
50
+ // `skill-filesystem` row is disabled because presets own local discovery, so
51
+ // a profile patch cannot configure these roots. Projecting Total Recall's
52
+ // authored skills into <dshHome>/skills is therefore the supported way to
53
+ // make them reach the `/` slash menu, globally and for every repo.
54
+ globalSkillsProjection: { dir: path.join('.dsh', 'skills') }
47
55
  },
48
56
  'deepseek-harness': {
49
57
  label: 'DeepSeek Harness',
@@ -51,7 +59,8 @@ export const CLIENTS = {
51
59
  globalMemoryPath: path.join(os.homedir(), '.dsh', 'memory', 'MEMORY.md'),
52
60
  globalUserPath: path.join(os.homedir(), '.dsh', 'memory', 'user.md'),
53
61
  localTarget: 'AGENTS.md',
54
- skillsProjection: { scope: 'project', dir: path.join('.agents', 'skills') }
62
+ skillsProjection: { scope: 'project', dir: path.join('.agents', 'skills') },
63
+ globalSkillsProjection: { dir: path.join('.dsh', 'skills') }
55
64
  },
56
65
  openclaw: {
57
66
  label: 'OpenClaw',
@@ -1036,6 +1045,29 @@ export default async function connect(args) {
1036
1045
  }
1037
1046
  }
1038
1047
 
1048
+ // Project GLOBAL skills into the target IDE's home-scoped Agent Skills
1049
+ // directory. Some harnesses read only a home root for skills and never scan a
1050
+ // repo directory, so a client that declares `globalSkillsProjection` needs this
1051
+ // as well as the project projection above. `connect <ide>` must leave a client
1052
+ // usable for both scopes; `init` reaches the same destination through
1053
+ // projectSkillsForScope().
1054
+ if (preset.globalSkillsProjection) {
1055
+ const globalAgentDir = path.join(os.homedir(), '.agent');
1056
+ const skills = discoverRepoSkills(globalAgentDir);
1057
+ if (skills.length > 0) {
1058
+ const destDir = path.join(os.homedir(), preset.globalSkillsProjection.dir);
1059
+ const skillResults = projectSkillsAsCommands(destDir, skills, opts);
1060
+ result.global_skill_commands = skillResults;
1061
+ const fresh = skillResults.filter(r => r.action === 'linked' || r.action === 'copied');
1062
+ if (fresh.length > 0) {
1063
+ result.notes.push(
1064
+ ` Global skills projected → ${destDir}/:\n` +
1065
+ fresh.map(r => ` ${r.name}`).join('\n')
1066
+ );
1067
+ }
1068
+ }
1069
+ }
1070
+
1039
1071
  // Record this client in the registry so `status` can report freshness
1040
1072
  registerClient(brainDir, opts.client, preset, result.projection?.targetPath || null);
1041
1073
 
package/src/cli/init.mjs CHANGED
@@ -318,7 +318,18 @@ export default async function init(args) {
318
318
  for (const id of (fs.existsSync(installedPluginsDir) ? fs.readdirSync(installedPluginsDir) : [])) {
319
319
  const target = path.join(installedPluginsDir, id);
320
320
  if (!fs.existsSync(path.join(target, 'package.json'))) continue;
321
- preparePluginDependencies(target);
321
+ // One plugin whose declared dependency cannot be wired must not abort the
322
+ // whole run: the steps after this one — global skill projection into each
323
+ // IDE, then the vault compile — are independent of it. Before this guard a
324
+ // single unresolvable optional dependency (observed: '@gregiteen/ssss-cli'
325
+ // in a vendored plugin) killed `init` before skill projection, so no IDE
326
+ // ever received the global skills and their slash menus stayed empty with
327
+ // no error the user could connect to the cause.
328
+ try {
329
+ preparePluginDependencies(target);
330
+ } catch (err) {
331
+ logWarn(`Could not wire dependencies for plugin '${id}': ${err.message}`);
332
+ }
322
333
  }
323
334
  }
324
335
  logStep('2.5/4', 'Creating user data directories inside meta-skill');
@@ -48,7 +48,13 @@ export function discoverBrainConsumers(roots, { onError } = {}) {
48
48
  if (deps.length) consumers.push({ root: real, file, pkg, deps });
49
49
  }
50
50
  for (const entry of fs.readdirSync(real, { withFileTypes: true })) {
51
- if (entry.isDirectory() && !entry.name.startsWith('.') && !ignored.has(entry.name)) walk(path.join(real, entry.name));
51
+ if (!entry.isDirectory() || ignored.has(entry.name)) continue;
52
+ // Dot-directories are skipped to avoid descending into .git and similar,
53
+ // but .agent holds the brain and its plugin installs. A plugin that runs
54
+ // `npm install` keeps its own node_modules there, and skipping the whole
55
+ // dot tree left those copies stranded on an old version.
56
+ if (entry.name.startsWith('.') && entry.name !== '.agent') continue;
57
+ walk(path.join(real, entry.name));
52
58
  }
53
59
  }
54
60
  for (const root of roots) walk(root);
@@ -1,18 +1,21 @@
1
- /** Markdown metadata is data: YAML/JSON only, with no executable engines. */
2
- import * as yamlModule from 'js-yaml';
3
- const yaml = yamlModule.default || yamlModule;
4
- // Existing source checkouts can still have the legacy CommonJS parser installed.
5
- const load = yaml.safeLoad || yaml.load;
6
- const dump = yaml.safeDump || yaml.dump;
7
- // Preserve the existing YAML 1.2 scalar rules plus dates/merge/collection tags.
8
- const schema = yaml.CORE_SCHEMA?.withTags
9
- ? yaml.CORE_SCHEMA.withTags(yaml.timestampTag, yaml.mergeTag, yaml.binaryTag, yaml.omapTag, yaml.pairsTag, yaml.setTag)
10
- : yaml.DEFAULT_SAFE_SCHEMA;
1
+ /**
2
+ * Markdown metadata is data: YAML/JSON only, with no executable engines.
3
+ *
4
+ * YAML frontmatter is read and written by the SSSS parser (@ssss/cli/frontmatter),
5
+ * the single implementation shared with every SSSS host, so Total Recall and SSSS
6
+ * can never disagree about a vault file. This wrapper keeps the gray-matter-style
7
+ * API, the fence/language checks and the CRLF handling.
8
+ */
9
+ import { parseFrontmatter, serializeDocument, repairEscapedNewlines } from '@ssss/cli/frontmatter';
10
+
11
+ // YAML tags (`!!js/function`, `!!python/object`, ...) can request code; SSSS reads
12
+ // them as plain text, so refuse them up front instead of storing them as data.
13
+ const YAML_TAG = /(?:^|[:,\[{-])[ \t]*!!\S/m;
11
14
 
12
15
  export default function matter(input) {
13
16
  const original = Buffer.isBuffer(input) ? input.toString('utf8') : typeof input === 'string' ? input : input?.content;
14
17
  if (typeof original !== 'string') throw new TypeError('Markdown content must be a string');
15
- const text = original.replace(/^\uFEFF/, '');
18
+ const text = repairEscapedNewlines(original.replace(/^/, ''));
16
19
  const file = { data: {}, content: text, excerpt: '', orig: original };
17
20
  const opening = /^---([^\r\n]*)\r?\n/.exec(text);
18
21
  if (!opening || opening[1].startsWith('-')) return file;
@@ -22,7 +25,8 @@ export default function matter(input) {
22
25
  const remainder = text.slice(opening[0].length);
23
26
  const closing = /^---(?:\r?\n|$)/m.exec(remainder);
24
27
  const metadata = closing ? remainder.slice(0, closing.index) : remainder;
25
- file.data = (language === 'json' ? JSON.parse(metadata || '{}') : load(metadata, { schema })) || {};
28
+ if (language !== 'json' && YAML_TAG.test(metadata)) throw new Error('YAML tags are not allowed in frontmatter');
29
+ file.data = (language === 'json' ? JSON.parse(metadata || '{}') : parseFrontmatter(metadata.replace(/\r\n/g, '\n'), { strict: true })) || {};
26
30
  file.content = closing ? remainder.slice(closing.index + closing[0].length) : '';
27
31
  file.matter = metadata;
28
32
  file.language = language;
@@ -30,11 +34,14 @@ export default function matter(input) {
30
34
  }
31
35
 
32
36
  const newline = value => value.endsWith('\n') ? value : value + '\n';
33
- matter.stringify = (input, data, options = {}) => {
37
+ // Dates become ISO strings (SSSS keeps timestamps as strings); undefined is dropped.
38
+ const plain = value => JSON.parse(JSON.stringify(value, (_key, v) => (v instanceof Date ? v.toISOString() : v)));
39
+
40
+ matter.stringify = (input, data) => {
34
41
  if (typeof input === 'string' && data == null) return input;
35
42
  const file = typeof input === 'string' ? matter(input) : input;
36
43
  if (!file || typeof file.content !== 'string') throw new TypeError('Markdown content must be a string');
37
- const metadata = { ...file.data, ...(data ?? {}) };
38
- const serialized = dump(metadata, options).trim();
39
- return (serialized === '{}' ? '' : `---\n${serialized}\n---\n`) + newline(file.content);
44
+ const metadata = plain({ ...file.data, ...(data ?? {}) });
45
+ const head = Object.keys(metadata).length ? serializeDocument(metadata, '') : '';
46
+ return head + newline(file.content);
40
47
  };
@@ -71,8 +71,22 @@ export async function executePluginState(request) {
71
71
  }
72
72
  // A guard serializes state read/compare/commit across native worker processes.
73
73
  const lock = path.join(root, '.mutation-lock');
74
- try { fs.mkdirSync(lock, { mode: 0o700 }); }
75
- catch { throw new Error('Plugin state mutation is busy or its ownership guard is unreadable'); }
74
+ try {
75
+ fs.mkdirSync(lock, { mode: 0o700 });
76
+ } catch (err) {
77
+ // Distinguish a genuine concurrent mutation from an environment failure so
78
+ // callers are not told the store is busy when it is actually inaccessible.
79
+ if (err && err.code === 'EEXIST') {
80
+ throw new Error('Plugin state mutation is busy; another mutation holds the guard');
81
+ }
82
+ if (err && (err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'EROFS')) {
83
+ throw new Error(
84
+ `Plugin state store is not writable (${err.code}): cannot create the mutation guard at ${lock}. ` +
85
+ 'Grant write access to this brain root, or point TR_SECRETS_BRAIN at a store you can write to.'
86
+ );
87
+ }
88
+ throw new Error(`Plugin state mutation guard could not be created at ${lock}: ${err?.message || err}`);
89
+ }
76
90
  try {
77
91
  const durableKeys = new FileIdempotencyStore(path.join(root, '.idempotency'));
78
92
  const intentHash = crypto.createHash('sha256').update(JSON.stringify({ path: relative, action, value, expectedVersion, format: request.format })).digest('hex');
@@ -758,7 +758,10 @@ export async function compileSurface({ vaultDir, skillsDir, derivedDir, instruct
758
758
  logger.info('surface', 'Semantic embeddings explicitly disabled; local indexes updated.', { derivedDir });
759
759
  } else try {
760
760
  const { buildEmbeddingsIndex } = await import('./embeddings.mjs');
761
- const built = await buildEmbeddingsIndex(nodes, derivedDir);
761
+ // Forward `force`: the builder is content-hash incremental and skips a node
762
+ // whose text is unchanged, so without this `compile --force` rebuilt nothing
763
+ // and rows kept an outdated model label.
764
+ const built = await buildEmbeddingsIndex(nodes, derivedDir, { force });
762
765
  semanticResult = {
763
766
  indexed: built.built,
764
767
  skipped: built.skipped,