total-recall-brain 3.30.1 → 3.31.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/bin/total-recall.mjs +2 -1
- package/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/SKILL.md +8 -4
- package/src/cli/edit.mjs +206 -0
- package/src/cli/help.mjs +1 -1
- package/src/cli/update.mjs +29 -0
- package/src/core/autopull-install.mjs +177 -0
- package/src/server/index.mjs +4 -1
package/bin/total-recall.mjs
CHANGED
|
@@ -58,6 +58,7 @@ const COMMANDS = {
|
|
|
58
58
|
status: 'status.mjs',
|
|
59
59
|
remember: 'remember.mjs',
|
|
60
60
|
forget: 'forget.mjs',
|
|
61
|
+
edit: 'edit.mjs',
|
|
61
62
|
recall: 'recall.mjs',
|
|
62
63
|
search: 'recall.mjs',
|
|
63
64
|
research: 'research.mjs',
|
|
@@ -111,7 +112,7 @@ function printHelp() {
|
|
|
111
112
|
Core (default product path):
|
|
112
113
|
init [--project] Bootstrap global or project brain + openwiki
|
|
113
114
|
connect <client> Wire IDE / Obsidian / http-api host
|
|
114
|
-
remember / forget Write-path memory (SSSS vault)
|
|
115
|
+
remember / forget / edit Write-path memory (SSSS vault)
|
|
115
116
|
recall Read-path hybrid search
|
|
116
117
|
compile Rebuild instruction surfaces
|
|
117
118
|
dream Memory consolidation cycle
|
package/package.json
CHANGED
|
@@ -71,7 +71,9 @@ Use the **MESH / CONTROL SERVER** section. Node details are entity fields on
|
|
|
71
71
|
SSSS `mesh_node` documents — never hardcoded hostnames.
|
|
72
72
|
|
|
73
73
|
### 5d. When running tests, typechecks, or builds
|
|
74
|
-
See **TESTS**.
|
|
74
|
+
See **TESTS**. The full suite and the quality gates run only on the Mac Mini —
|
|
75
|
+
never on a laptop or the cloud droplet. A single spec file may run on any mesh node. Builds and installs are fine on any machine; build large apps
|
|
76
|
+
on the Mac Mini.
|
|
75
77
|
|
|
76
78
|
### 6. When troubleshooting connections, port blocks, or sync errors
|
|
77
79
|
* **Trigger**: If the REST server is unreachable, ports are blocked, or the upstream sync tool encounters errors.
|
|
@@ -602,9 +604,11 @@ npx vitest run <path> # one spec file
|
|
|
602
604
|
|
|
603
605
|
Current baseline (3.30.1): **349 spec files / 2024 tests, ~160 s** on the Mac Mini.
|
|
604
606
|
|
|
605
|
-
> **
|
|
606
|
-
> laptop
|
|
607
|
-
>
|
|
607
|
+
> **The full suite and the quality gates run only on the Mac Mini** — never on a
|
|
608
|
+
> laptop or the cloud droplet; they cause slowdowns and OOM kills. A single spec
|
|
609
|
+
> file may run on any mesh node. Builds
|
|
610
|
+
> and installs are fine anywhere; build large apps on the Mac Mini. Sync and run
|
|
611
|
+
> remotely:
|
|
608
612
|
>
|
|
609
613
|
> ```bash
|
|
610
614
|
> REMOTE=build-host; REMOTE_DIR=code/total-recall # your own host and path
|
package/src/cli/edit.mjs
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
import { resolveBrainDir, parseLayerFlag, getBothBrains } from './agent-dir.mjs';
|
|
5
|
+
import { writeNodeValidatedAsync } from '../core/validated-write.mjs';
|
|
6
|
+
import { isSafeVaultName } from '../core/vault.mjs';
|
|
7
|
+
|
|
8
|
+
const PRIORITIES = ['absolute', 'high', 'normal', 'low'];
|
|
9
|
+
const MODALITIES = ['must', 'must_not', 'should', 'should_not', 'descriptive', 'preference'];
|
|
10
|
+
const STATUSES = ['active', 'archived', 'deprecated'];
|
|
11
|
+
|
|
12
|
+
function printHelp() {
|
|
13
|
+
console.log(`
|
|
14
|
+
total-recall edit — Change an existing memory node in place (same slug)
|
|
15
|
+
|
|
16
|
+
Usage: total-recall edit <slug> ["<new content>"] [options]
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--title <text> Replace the title
|
|
20
|
+
--importance, -i <1-5> Replace the importance
|
|
21
|
+
--priority, -p <level> absolute | high | normal | low
|
|
22
|
+
--modality, -m <type> must | must_not | should | should_not | descriptive | preference
|
|
23
|
+
--tags, -t <list> Replace the tags (comma-separated)
|
|
24
|
+
--status <status> active | archived | deprecated
|
|
25
|
+
--global Edit the node in the global brain
|
|
26
|
+
--project Edit the node in the project brain
|
|
27
|
+
--no-compile Skip auto-recompilation after the edit
|
|
28
|
+
--help, -h Show this help
|
|
29
|
+
|
|
30
|
+
Pass "-" as the content to read the new body from stdin. Omit the content to
|
|
31
|
+
keep the body and change only the options given.
|
|
32
|
+
|
|
33
|
+
If neither --global nor --project is specified, the node is looked up in the
|
|
34
|
+
project brain first (if one exists), then in the global brain.
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
npx total-recall edit anti-patterns-7a7fb6a5 "Never run tests on the MacBook."
|
|
38
|
+
npx total-recall edit my-rule --priority absolute --modality must --global
|
|
39
|
+
cat new-body.md | npx total-recall edit my-fact -
|
|
40
|
+
`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Path of `<slug>.md` inside any category directory of `vaultDir`, or null. */
|
|
44
|
+
export function findNodeFile(slug, vaultDir) {
|
|
45
|
+
if (!isSafeVaultName(slug) || !fs.existsSync(vaultDir)) return null;
|
|
46
|
+
for (const entry of fs.readdirSync(vaultDir, { withFileTypes: true })) {
|
|
47
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
48
|
+
const filePath = path.join(vaultDir, entry.name, `${slug}.md`);
|
|
49
|
+
if (fs.existsSync(filePath)) return filePath;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse edit arguments into { slug, body, changes, noCompile }.
|
|
56
|
+
* Throws with a user-facing message on invalid input.
|
|
57
|
+
*/
|
|
58
|
+
export function parseEditArgs(args) {
|
|
59
|
+
const result = { slug: null, body: undefined, changes: {}, noCompile: false };
|
|
60
|
+
const positional = [];
|
|
61
|
+
for (let i = 0; i < args.length; i++) {
|
|
62
|
+
const arg = args[i];
|
|
63
|
+
const next = () => {
|
|
64
|
+
const value = args[++i];
|
|
65
|
+
if (value === undefined) throw new Error(`${arg} requires a value.`);
|
|
66
|
+
return value;
|
|
67
|
+
};
|
|
68
|
+
if (arg === '--no-compile') result.noCompile = true;
|
|
69
|
+
else if (arg === '--title') result.changes.title = next();
|
|
70
|
+
else if (arg === '--importance' || arg === '-i') {
|
|
71
|
+
const value = Number(next());
|
|
72
|
+
if (!Number.isInteger(value) || value < 1 || value > 5) {
|
|
73
|
+
throw new Error('--importance must be an integer from 1 to 5.');
|
|
74
|
+
}
|
|
75
|
+
result.changes.importance = value;
|
|
76
|
+
} else if (arg === '--priority' || arg === '-p') {
|
|
77
|
+
const value = next().toLowerCase();
|
|
78
|
+
if (!PRIORITIES.includes(value)) throw new Error(`--priority must be one of: ${PRIORITIES.join(', ')}.`);
|
|
79
|
+
result.changes.priority = value;
|
|
80
|
+
} else if (arg === '--modality' || arg === '-m') {
|
|
81
|
+
const value = next().toLowerCase();
|
|
82
|
+
if (!MODALITIES.includes(value)) throw new Error(`--modality must be one of: ${MODALITIES.join(', ')}.`);
|
|
83
|
+
result.changes.modality = value;
|
|
84
|
+
} else if (arg === '--tags' || arg === '-t') {
|
|
85
|
+
result.changes.tags = next().split(',').map((t) => t.trim()).filter(Boolean);
|
|
86
|
+
} else if (arg === '--status') {
|
|
87
|
+
const value = next().toLowerCase();
|
|
88
|
+
if (!STATUSES.includes(value)) throw new Error(`--status must be one of: ${STATUSES.join(', ')}.`);
|
|
89
|
+
result.changes.status = value;
|
|
90
|
+
} else if (/^--?[a-z]/i.test(arg)) {
|
|
91
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
92
|
+
} else positional.push(arg);
|
|
93
|
+
}
|
|
94
|
+
if (positional.length > 2) throw new Error('Quote the new content as a single argument.');
|
|
95
|
+
[result.slug, result.body] = positional;
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Apply an edit to a parsed node. Returns the updated node and the list of
|
|
101
|
+
* changed field names. `immutable` follows `priority`, as in `remember`.
|
|
102
|
+
*/
|
|
103
|
+
export function applyEdit(node, { body, changes }, now = new Date().toISOString()) {
|
|
104
|
+
const updated = { ...node };
|
|
105
|
+
const changed = [];
|
|
106
|
+
if (body !== undefined && body.trim() !== (node.body || '').trim()) {
|
|
107
|
+
updated.body = body.trim();
|
|
108
|
+
changed.push('body');
|
|
109
|
+
}
|
|
110
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
111
|
+
if (JSON.stringify(node[key]) === JSON.stringify(value)) continue;
|
|
112
|
+
updated[key] = value;
|
|
113
|
+
changed.push(key);
|
|
114
|
+
}
|
|
115
|
+
if (changed.includes('title') && node.description === node.title) {
|
|
116
|
+
updated.description = updated.title;
|
|
117
|
+
}
|
|
118
|
+
if (changed.includes('priority')) {
|
|
119
|
+
if (updated.priority === 'absolute') updated.immutable = true;
|
|
120
|
+
else delete updated.immutable;
|
|
121
|
+
}
|
|
122
|
+
if (changed.length) updated.updated = now;
|
|
123
|
+
return { node: updated, changed };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function locate(slug, explicitLayer) {
|
|
127
|
+
const layers = explicitLayer === 'auto' ? ['project', 'global'] : [explicitLayer];
|
|
128
|
+
const brains = getBothBrains();
|
|
129
|
+
for (const layer of layers) {
|
|
130
|
+
if (layer === 'project' && !brains.project) continue;
|
|
131
|
+
const vaultDir = path.join(resolveBrainDir(layer), 'memory-vault');
|
|
132
|
+
const filePath = findNodeFile(slug, vaultDir);
|
|
133
|
+
if (filePath) return { layer, vaultDir, filePath };
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function readStdin() {
|
|
139
|
+
const chunks = [];
|
|
140
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
141
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export default async function edit(args) {
|
|
145
|
+
const { layer: explicitLayer, remainingArgs } = parseLayerFlag(args);
|
|
146
|
+
if (!remainingArgs.length || remainingArgs.includes('--help') || remainingArgs.includes('-h')) {
|
|
147
|
+
printHelp();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let parsed;
|
|
152
|
+
try {
|
|
153
|
+
parsed = parseEditArgs(remainingArgs);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error(` ❌ ${err.message}`);
|
|
156
|
+
process.exitCode = 1;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (parsed.body === '-') parsed.body = await readStdin();
|
|
160
|
+
if (parsed.body !== undefined && !parsed.body.trim()) {
|
|
161
|
+
console.error(' ❌ The new content is empty. Use `total-recall forget` to delete a node.');
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const found = locate(parsed.slug, explicitLayer);
|
|
167
|
+
if (!found) {
|
|
168
|
+
const where = explicitLayer === 'auto' ? 'project or global' : explicitLayer;
|
|
169
|
+
console.error(` ❌ Node "${parsed.slug}" not found in the ${where} vault.`);
|
|
170
|
+
process.exitCode = 1;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const { data, content } = matter(fs.readFileSync(found.filePath, 'utf8'));
|
|
175
|
+
const { node, changed } = applyEdit({ ...data, body: content.trim() }, parsed);
|
|
176
|
+
const layerLabel = `[${found.layer}]`;
|
|
177
|
+
const relPath = path.relative(path.dirname(found.vaultDir), found.filePath);
|
|
178
|
+
|
|
179
|
+
if (!changed.length) {
|
|
180
|
+
console.log(` ℹ️ Nothing to change in ${layerLabel} ${relPath}.`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const result = await writeNodeValidatedAsync(node, found.vaultDir);
|
|
185
|
+
if (!result.success) {
|
|
186
|
+
const errors = result.validation?.errors || [result.error || 'unknown validation failure'];
|
|
187
|
+
console.error(` ❌ Validation failed: ${errors.join('; ')}`);
|
|
188
|
+
process.exitCode = 1;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
console.log(` ✅ Updated ${layerLabel} ${relPath} (${changed.join(', ')})`);
|
|
192
|
+
|
|
193
|
+
if (parsed.noCompile) return;
|
|
194
|
+
console.log(' ⏳ Recompiling active memory surfaces and indexes in the background...');
|
|
195
|
+
try {
|
|
196
|
+
const { spawn } = await import('node:child_process');
|
|
197
|
+
const child = spawn(process.argv[0], [process.argv[1], 'compile', `--${found.layer}`], {
|
|
198
|
+
detached: true,
|
|
199
|
+
stdio: 'ignore',
|
|
200
|
+
});
|
|
201
|
+
child.unref();
|
|
202
|
+
console.log(' ✅ Background compilation started.');
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.warn(` ⚠️ Node updated, but background recompilation spawn failed: ${err.message}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
package/src/cli/help.mjs
CHANGED
|
@@ -146,7 +146,7 @@ function printGeneralHelp() {
|
|
|
146
146
|
npx total-recall help architecture
|
|
147
147
|
|
|
148
148
|
\x1b[1mCore Memory Operations:\x1b[0m
|
|
149
|
-
\x1b[33mremember\x1b[0m, \x1b[33mrecall\x1b[0m, \x1b[33mforget\x1b[0m, \x1b[33mbrain\x1b[0m, \x1b[33mmap\x1b[0m
|
|
149
|
+
\x1b[33mremember\x1b[0m, \x1b[33mrecall\x1b[0m, \x1b[33mforget\x1b[0m, \x1b[33medit\x1b[0m, \x1b[33mbrain\x1b[0m, \x1b[33mmap\x1b[0m
|
|
150
150
|
|
|
151
151
|
\x1b[1mCompilation & System Rules:\x1b[0m
|
|
152
152
|
\x1b[33mcompile\x1b[0m, \x1b[33mrebuild\x1b[0m, \x1b[33mconnect\x1b[0m, \x1b[33mskill\x1b[0m, \x1b[33mimport-rules\x1b[0m
|
package/src/cli/update.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* npx total-recall update --apply # install latest where needed
|
|
7
7
|
* npx total-recall update --apply --force
|
|
8
8
|
* npx total-recall update --repo /path/to/app
|
|
9
|
+
* npx total-recall update --install-autopull [--port N] [--no-build]
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import {
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
PACKAGE_NAME,
|
|
16
17
|
} from '../core/package-auto-update.mjs';
|
|
17
18
|
import { brainDir } from '../core/config.mjs';
|
|
19
|
+
import { installAutoPull } from '../core/autopull-install.mjs';
|
|
18
20
|
|
|
19
21
|
/** @param {string[]} args */
|
|
20
22
|
export function parseArgs(args) {
|
|
@@ -24,6 +26,9 @@ export function parseArgs(args) {
|
|
|
24
26
|
dryRun: false,
|
|
25
27
|
help: false,
|
|
26
28
|
repos: [],
|
|
29
|
+
installAutopull: false,
|
|
30
|
+
port: null,
|
|
31
|
+
noBuild: false,
|
|
27
32
|
};
|
|
28
33
|
for (let i = 0; i < args.length; i++) {
|
|
29
34
|
const a = args[i];
|
|
@@ -32,6 +37,9 @@ export function parseArgs(args) {
|
|
|
32
37
|
else if (a === '--force') opts.force = true;
|
|
33
38
|
else if (a === '--dry-run') opts.dryRun = true;
|
|
34
39
|
else if (a === '--repo' && args[i + 1]) opts.repos.push(args[++i]);
|
|
40
|
+
else if (a === '--install-autopull') opts.installAutopull = true;
|
|
41
|
+
else if (a === '--port' && args[i + 1]) opts.port = Number(args[++i]);
|
|
42
|
+
else if (a === '--no-build') opts.noBuild = true;
|
|
35
43
|
}
|
|
36
44
|
return opts;
|
|
37
45
|
}
|
|
@@ -48,6 +56,15 @@ function printHelp() {
|
|
|
48
56
|
total-recall update --apply --force Ignore throttle / disabled flag
|
|
49
57
|
total-recall update --repo <path> Include extra root (repeatable)
|
|
50
58
|
|
|
59
|
+
This brain's own checkout (git):
|
|
60
|
+
total-recall update --install-autopull [--port N] [--no-build] [--dry-run]
|
|
61
|
+
Every 5 minutes fast-forward this checkout to origin/main, install and
|
|
62
|
+
rebuild what changed, restart the brain and verify /health reports the
|
|
63
|
+
new version (scripts/auto-pull.sh). macOS: LaunchAgent
|
|
64
|
+
com.totalrecall.autopull; Linux: crontab. A dev checkout (other branch,
|
|
65
|
+
uncommitted or unpushed work) is never touched. --no-build: never build
|
|
66
|
+
the dashboard on this host (laptops).
|
|
67
|
+
|
|
51
68
|
Env:
|
|
52
69
|
TR_AUTO_UPDATE_PACKAGE=0 Disable daemon cron auto-update (default: on)
|
|
53
70
|
TR_SYNC_REPOS=/a:/b Extra project roots
|
|
@@ -63,6 +80,18 @@ export default async function update(args = []) {
|
|
|
63
80
|
return;
|
|
64
81
|
}
|
|
65
82
|
|
|
83
|
+
if (opts.installAutopull) {
|
|
84
|
+
const result = installAutoPull({ port: opts.port || undefined, noBuild: opts.noBuild, dryRun: opts.dryRun });
|
|
85
|
+
if (opts.dryRun) {
|
|
86
|
+
console.log(JSON.stringify(result, null, 2));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const where = result.plistPath ? `LaunchAgent ${result.plistPath}` : `crontab: ${result.cronLine}`;
|
|
90
|
+
console.log(`\n ✅ Auto-pull installed (${where}), checking port ${result.port} every 5 minutes.`);
|
|
91
|
+
console.log(' Log: ~/.agent/logs/auto-pull.log\n');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
66
95
|
const latest = fetchLatestNpmVersion();
|
|
67
96
|
console.log(`\n Package: ${PACKAGE_NAME}`);
|
|
68
97
|
console.log(` Latest on npm: ${latest || '(unavailable)'}`);
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install the auto-pull timer on a host that serves a brain from a git checkout.
|
|
3
|
+
*
|
|
4
|
+
* The daemon's package auto-update only reaches npm installs in registered
|
|
5
|
+
* projects; it deliberately skips source checkouts. A brain running from a
|
|
6
|
+
* checkout therefore never updated unless someone pulled and restarted it by
|
|
7
|
+
* hand, and the one host with a timer (a droplet cron) pointed at a script that
|
|
8
|
+
* could not restart anything. This installs scripts/auto-pull.sh — the repo's
|
|
9
|
+
* own copy, so the updater updates itself — on a 5-minute timer:
|
|
10
|
+
* - macOS: a LaunchAgent (com.totalrecall.autopull) with StartInterval;
|
|
11
|
+
* - Linux: a crontab line, replacing any earlier auto-pull line.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here names a machine or a repository: paths come from where this
|
|
14
|
+
* package is running, ports from flags, the environment or the brain's own
|
|
15
|
+
* LaunchAgent.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
export const AUTOPULL_LABEL = 'com.totalrecall.autopull';
|
|
25
|
+
export const AUTOPULL_INTERVAL_SECONDS = 300;
|
|
26
|
+
|
|
27
|
+
/** The checkout this CLI runs from (…/src/core → repo root). */
|
|
28
|
+
export function packageRoot() {
|
|
29
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function xmlEscape(value) {
|
|
33
|
+
return String(value)
|
|
34
|
+
.replace(/&/g, '&')
|
|
35
|
+
.replace(/</g, '<')
|
|
36
|
+
.replace(/>/g, '>')
|
|
37
|
+
.replace(/"/g, '"');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** LaunchAgent that runs the checkout's auto-pull.sh every `interval` seconds. */
|
|
41
|
+
export function buildLaunchAgentPlist({ script, env = {}, logFile, interval = AUTOPULL_INTERVAL_SECONDS, label = AUTOPULL_LABEL }) {
|
|
42
|
+
const envEntries = Object.entries(env)
|
|
43
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
44
|
+
.map(([k, v]) => `\t\t<key>${xmlEscape(k)}</key>\n\t\t<string>${xmlEscape(v)}</string>`)
|
|
45
|
+
.join('\n');
|
|
46
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
47
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
48
|
+
<plist version="1.0">
|
|
49
|
+
<dict>
|
|
50
|
+
\t<key>Label</key>
|
|
51
|
+
\t<string>${xmlEscape(label)}</string>
|
|
52
|
+
\t<key>ProgramArguments</key>
|
|
53
|
+
\t<array>
|
|
54
|
+
\t\t<string>/bin/bash</string>
|
|
55
|
+
\t\t<string>${xmlEscape(script)}</string>
|
|
56
|
+
\t</array>
|
|
57
|
+
\t<key>EnvironmentVariables</key>
|
|
58
|
+
\t<dict>
|
|
59
|
+
${envEntries}
|
|
60
|
+
\t</dict>
|
|
61
|
+
\t<key>StartInterval</key>
|
|
62
|
+
\t<integer>${Number(interval)}</integer>
|
|
63
|
+
\t<key>RunAtLoad</key>
|
|
64
|
+
\t<true/>
|
|
65
|
+
\t<key>StandardOutPath</key>
|
|
66
|
+
\t<string>${xmlEscape(logFile)}</string>
|
|
67
|
+
\t<key>StandardErrorPath</key>
|
|
68
|
+
\t<string>${xmlEscape(logFile)}</string>
|
|
69
|
+
</dict>
|
|
70
|
+
</plist>
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function shellQuote(value) {
|
|
75
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The crontab line that runs auto-pull.sh every 5 minutes. */
|
|
79
|
+
export function buildCronLine({ script, env = {} }) {
|
|
80
|
+
const assignments = Object.entries(env)
|
|
81
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
82
|
+
.map(([k, v]) => `${k}=${shellQuote(v)}`)
|
|
83
|
+
.join(' ');
|
|
84
|
+
return `*/5 * * * * ${assignments ? `${assignments} ` : ''}/bin/bash ${shellQuote(script)} >/dev/null 2>&1`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Replace every earlier auto-pull line with `line`; keep everything else. */
|
|
88
|
+
export function mergeCrontab(existing, line) {
|
|
89
|
+
const kept = String(existing || '')
|
|
90
|
+
.split('\n')
|
|
91
|
+
.filter((l) => l.trim() && !/auto-pull\.sh/.test(l));
|
|
92
|
+
return `${[...kept, line].join('\n')}\n`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The port the brain from `repoDir` serves: the PORT of the LaunchAgent that
|
|
97
|
+
* runs it (macOS), else null.
|
|
98
|
+
*/
|
|
99
|
+
export function detectLaunchAgentPort(repoDir, { home = os.homedir(), readPlist = readPlistValue } = {}) {
|
|
100
|
+
const dir = path.join(home, 'Library', 'LaunchAgents');
|
|
101
|
+
if (!fs.existsSync(dir)) return null;
|
|
102
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
103
|
+
if (!entry.endsWith('.plist') || entry === `${AUTOPULL_LABEL}.plist`) continue;
|
|
104
|
+
const file = path.join(dir, entry);
|
|
105
|
+
const cwd = readPlist(file, 'WorkingDirectory');
|
|
106
|
+
const args = readPlist(file, 'ProgramArguments') || '';
|
|
107
|
+
if (cwd !== repoDir && !args.includes(`${repoDir}/`)) continue;
|
|
108
|
+
const port = readPlist(file, 'EnvironmentVariables:PORT');
|
|
109
|
+
if (port && /^\d+$/.test(port.trim())) return Number(port.trim());
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readPlistValue(file, key) {
|
|
115
|
+
try {
|
|
116
|
+
return execFileSync('/usr/libexec/PlistBuddy', ['-c', `Print :${key}`, file], {
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
119
|
+
}).trim();
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Install (or refresh) the timer. Returns what it did; `dryRun` returns the
|
|
127
|
+
* plan without writing.
|
|
128
|
+
*/
|
|
129
|
+
export function installAutoPull({
|
|
130
|
+
repoDir = packageRoot(),
|
|
131
|
+
port,
|
|
132
|
+
noBuild = false,
|
|
133
|
+
platform = process.platform,
|
|
134
|
+
home = os.homedir(),
|
|
135
|
+
dryRun = false,
|
|
136
|
+
} = {}) {
|
|
137
|
+
const script = path.join(repoDir, 'scripts', 'auto-pull.sh');
|
|
138
|
+
if (!fs.existsSync(path.join(repoDir, '.git'))) {
|
|
139
|
+
throw new Error(`${repoDir} is not a git checkout; npm installs update through the daemon (total-recall update --apply).`);
|
|
140
|
+
}
|
|
141
|
+
if (!fs.existsSync(script)) throw new Error(`Missing ${script}`);
|
|
142
|
+
|
|
143
|
+
const resolvedPort =
|
|
144
|
+
port || Number(process.env.TR_PORT) || (platform === 'darwin' ? detectLaunchAgentPort(repoDir, { home }) : null) || 3000;
|
|
145
|
+
const env = {
|
|
146
|
+
TR_REPO_DIR: repoDir,
|
|
147
|
+
TR_PORT: String(resolvedPort),
|
|
148
|
+
TR_AUTOPULL_NO_BUILD: noBuild ? '1' : undefined,
|
|
149
|
+
};
|
|
150
|
+
const logDir = path.join(home, '.agent', 'logs');
|
|
151
|
+
|
|
152
|
+
if (platform === 'darwin') {
|
|
153
|
+
const plistPath = path.join(home, 'Library', 'LaunchAgents', `${AUTOPULL_LABEL}.plist`);
|
|
154
|
+
const plist = buildLaunchAgentPlist({
|
|
155
|
+
script,
|
|
156
|
+
env: { ...env, HOME: home, PATH: process.env.PATH },
|
|
157
|
+
logFile: path.join(logDir, 'auto-pull.launchd.log'),
|
|
158
|
+
});
|
|
159
|
+
if (dryRun) return { platform, plistPath, plist, port: resolvedPort };
|
|
160
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
161
|
+
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
162
|
+
fs.writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
163
|
+
const domain = `gui/${process.getuid()}`;
|
|
164
|
+
spawnSync('launchctl', ['bootout', `${domain}/${AUTOPULL_LABEL}`], { stdio: 'ignore' });
|
|
165
|
+
const boot = spawnSync('launchctl', ['bootstrap', domain, plistPath], { encoding: 'utf8' });
|
|
166
|
+
if (boot.status !== 0) throw new Error(`launchctl bootstrap failed: ${(boot.stderr || '').trim()}`);
|
|
167
|
+
return { platform, plistPath, port: resolvedPort, installed: true };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const line = buildCronLine({ script, env });
|
|
171
|
+
const current = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
|
|
172
|
+
const merged = mergeCrontab(current.status === 0 ? current.stdout : '', line);
|
|
173
|
+
if (dryRun) return { platform, cronLine: line, crontab: merged, port: resolvedPort };
|
|
174
|
+
const write = spawnSync('crontab', ['-'], { input: merged, encoding: 'utf8' });
|
|
175
|
+
if (write.status !== 0) throw new Error(`crontab write failed: ${(write.stderr || '').trim()}`);
|
|
176
|
+
return { platform, cronLine: line, port: resolvedPort, installed: true };
|
|
177
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -280,7 +280,10 @@ app.get('/health', requireAuthOrLocal, async (req, res) => {
|
|
|
280
280
|
const unembeddedBrains = (embeddingCoverage || []).filter(c => c.nodes > 0 && c.embedded === 0);
|
|
281
281
|
|
|
282
282
|
// Determine overall status
|
|
283
|
-
|
|
283
|
+
// No agent CLIs on PATH is normal for a headless server brain (a droplet has
|
|
284
|
+
// none), so it is reported in cli_agents but does not make the brain
|
|
285
|
+
// "degraded" — that status is for things that break memory itself.
|
|
286
|
+
const hasCriticalIssue = emergencyAlerts.length > 0 || daemonStatus === 'dead'
|
|
284
287
|
|| unembeddedBrains.length > 0;
|
|
285
288
|
|
|
286
289
|
// Check Caddy and Cloudflare status
|