total-recall-brain 3.39.7 → 3.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.0",
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",
@@ -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,