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 +2 -2
- package/src/cli/backup.mjs +71 -3
- package/src/cli/connect.mjs +34 -2
- package/src/cli/init.mjs +12 -1
- package/src/core/brain-updates.mjs +7 -1
- package/src/core/frontmatter.mjs +23 -16
- package/src/core/plugin-state-store.mjs +16 -2
- package/src/core/surface.mjs +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "total-recall-brain",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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",
|
package/src/cli/backup.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
package/src/cli/connect.mjs
CHANGED
|
@@ -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
|
-
|
|
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()
|
|
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);
|
package/src/core/frontmatter.mjs
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
39
|
-
return
|
|
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 {
|
|
75
|
-
|
|
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');
|
package/src/core/surface.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|