total-recall-brain 3.43.2 → 3.44.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-server-release.mjs +4 -0
- package/docs/reference/cli-reference.md +14 -0
- package/package.json +5 -4
- package/scaffold/.agent/skills/plugin/SKILL.md +7 -2
- package/scaffold/.agent/skills/plugin/evals/README.md +5 -0
- package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/07-missing-reuse.mjs +11 -0
- package/scaffold/.agent/skills/plugin/evals/cases/08-unresolved-owner-command.mjs +12 -0
- package/scaffold/.agent/skills/plugin/evals/cases/09-missing-brick.mjs +12 -0
- package/scaffold/.agent/skills/plugin/prompts/01-function.md +4 -3
- package/scaffold/.agent/skills/plugin/prompts/08-verify.md +1 -0
- package/scaffold/.agent/skills/plugin/references/surfaces.md +12 -2
- package/scaffold/.agent/skills/plugin/scripts/record.mjs +59 -2
- package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +8 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-gh-login.mjs +102 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +18 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/login-projections.mjs +157 -13
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-sync.mjs +161 -17
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/README.md +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/check.mjs +39 -6
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/config.schema.json +2 -1
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +38 -5
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +38 -7
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +85 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +2 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/adoption.mjs +104 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +12 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +15 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch-request.mjs +38 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +42 -11
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +40 -13
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/included-usage.mjs +249 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +3 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +16 -8
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/process-manager.mjs +1 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/reasoning.mjs +5 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/remote-operation.mjs +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +27 -11
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +28 -6
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/usage-queue.mjs +110 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +322 -40
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/security/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/ssss/assets/scaffold.json +5 -5
- package/scaffold/.agent/skills/total-recall/plugins/ssss/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/ssss/package.json +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/ssss/plugin.json +1 -1
- package/src/cli/connect.mjs +2 -29
- package/src/cli/context.mjs +1 -0
- package/src/cli/ingest-okf.mjs +4 -4
- package/src/cli/release.mjs +27 -2
- package/src/cli/startup.mjs +1 -0
- package/src/cli/update.mjs +14 -3
- package/src/core/automatic-brain-updates.mjs +13 -3
- package/src/core/autopull-install.mjs +14 -0
- package/src/core/brain-updates.mjs +12 -2
- package/src/core/context-compactor.mjs +7 -2
- package/src/core/context-compiler.mjs +6 -1
- package/src/core/context-policy.mjs +7 -3
- package/src/core/daemon-control.mjs +22 -0
- package/src/core/global-brain-updates.mjs +3 -3
- package/src/core/mesh-brain-updates.mjs +20 -5
- package/src/core/running-server.mjs +76 -0
- package/src/core/server-release.mjs +487 -0
- package/src/core/server-restart.mjs +128 -7
- package/src/core/source-brain-updates.mjs +35 -6
- package/src/core/ssss-kernel-bridge.mjs +1 -2
- package/src/core/startup-health.mjs +13 -2
- package/src/core/surface.mjs +3 -3
- package/src/core/task-context.mjs +9 -8
- package/src/core/validated-write.mjs +4 -0
- package/src/core/vault-files.mjs +8 -2
- package/src/core/vault.mjs +1 -1
- package/src/server/index.mjs +17 -1
|
@@ -12,9 +12,14 @@
|
|
|
12
12
|
* Exiting an unsupervised server is not a restart, it is an outage, so
|
|
13
13
|
* supervision is proven (the job's reported PID is ours) rather than assumed.
|
|
14
14
|
*/
|
|
15
|
-
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import path from 'node:path';
|
|
18
|
+
import { processIdentity } from './startup-health.mjs';
|
|
19
|
+
import { runningServerStatus } from './running-server.mjs';
|
|
20
|
+
|
|
21
|
+
const GLOBAL_PACKAGE = 'total-recall-brain';
|
|
22
|
+
const HOLD_LIMIT_MS = 6 * 60 * 60 * 1000;
|
|
18
23
|
|
|
19
24
|
const LAUNCHD_LABELS = ['com.totalrecall.brain', 'com.totalrecall.server'];
|
|
20
25
|
const SYSTEMD_UNITS = ['total-recall-server', 'total-recall-server.service'];
|
|
@@ -47,30 +52,33 @@ function systemdPidFor(unit) {
|
|
|
47
52
|
|
|
48
53
|
/**
|
|
49
54
|
* Prove that a supervisor owns THIS process and will restart it.
|
|
50
|
-
*
|
|
55
|
+
* `scope` says whose supervisor it is: `user` belongs to one login, `system` to the machine.
|
|
56
|
+
* @returns {{supervised: boolean, kind: string|null, label: string|null, scope: string|null, reason: string}}
|
|
51
57
|
*/
|
|
52
58
|
export function detectSupervisor(pid = process.pid) {
|
|
53
59
|
// An explicit opt-in for hosts running under a supervisor we cannot query
|
|
54
60
|
// (docker restart policies, pm2, runit). The operator asserts respawn.
|
|
55
61
|
if (process.env.TR_SUPERVISED === '1') {
|
|
56
|
-
return { supervised: true, kind: 'declared', label: 'TR_SUPERVISED=1', reason: 'declared by operator' };
|
|
62
|
+
return { supervised: true, kind: 'declared', label: 'TR_SUPERVISED=1', scope: null, reason: 'declared by operator' };
|
|
57
63
|
}
|
|
58
64
|
if (process.platform === 'darwin') {
|
|
59
65
|
for (const label of LAUNCHD_LABELS) {
|
|
60
66
|
const jobPid = launchdPidFor(label);
|
|
61
67
|
if (jobPid === pid) {
|
|
62
|
-
|
|
68
|
+
// launchctl answers for the caller's domain, so as root this is a system daemon.
|
|
69
|
+
const scope = process.getuid?.() === 0 ? 'system' : 'user';
|
|
70
|
+
return { supervised: true, kind: 'launchd', label, scope, reason: `launchd job ${label} owns pid ${pid}` };
|
|
63
71
|
}
|
|
64
72
|
}
|
|
65
|
-
return { supervised: false, kind: null, label: null, reason: 'no launchd job reports this pid' };
|
|
73
|
+
return { supervised: false, kind: null, label: null, scope: null, reason: 'no launchd job reports this pid' };
|
|
66
74
|
}
|
|
67
75
|
for (const unit of SYSTEMD_UNITS) {
|
|
68
76
|
const found = systemdPidFor(unit);
|
|
69
77
|
if (found && found.pid === pid) {
|
|
70
|
-
return { supervised: true, kind: 'systemd', label: unit, reason: `systemd ${found.scope} unit ${unit} owns pid ${pid}` };
|
|
78
|
+
return { supervised: true, kind: 'systemd', label: unit, scope: found.scope, reason: `systemd ${found.scope} unit ${unit} owns pid ${pid}` };
|
|
71
79
|
}
|
|
72
80
|
}
|
|
73
|
-
return { supervised: false, kind: null, label: null, reason: 'no systemd unit reports this pid' };
|
|
81
|
+
return { supervised: false, kind: null, label: null, scope: null, reason: 'no systemd unit reports this pid' };
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
/**
|
|
@@ -128,3 +136,116 @@ export function packageVersionOnDisk(rootDir) {
|
|
|
128
136
|
return null;
|
|
129
137
|
}
|
|
130
138
|
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Can maintenance replace the server on this host, and how?
|
|
142
|
+
*
|
|
143
|
+
* `npm install --global` moves the files under a running server and nothing else
|
|
144
|
+
* moves the process, so a host that serves from that install stays on the release it
|
|
145
|
+
* booted on. Replacing it has a gap, so maintenance does it only where a gap is allowed
|
|
146
|
+
* and a restart is certain to help:
|
|
147
|
+
* - a per-user supervisor owns the process. A LaunchAgent or user unit belongs to one
|
|
148
|
+
* login; a system service is production, and `deploy release` replaces that with no gap.
|
|
149
|
+
* - the process runs the global install's entry, and that install is not a checkout
|
|
150
|
+
* (auto-pull owns a checkout) and is at the release. Otherwise a restart starts the
|
|
151
|
+
* same old code, or code nobody released.
|
|
152
|
+
*
|
|
153
|
+
* @returns {{method: 'restart', kind: string, label: string, pid: number}|{method: 'deploy'|'none', reason: string}}
|
|
154
|
+
*/
|
|
155
|
+
export function planServerReplacement({ brainDir, globalRoot, latest, env = process.env, identity = processIdentity, supervisor = detectSupervisor, versionOnDisk = packageVersionOnDisk } = {}) {
|
|
156
|
+
const leave = (reason, method = 'none') => ({ method, reason });
|
|
157
|
+
if (env.TR_SERVER_AUTO_RESTART === '0') return leave('TR_SERVER_AUTO_RESTART=0 leaves this server to the operator.');
|
|
158
|
+
if (!globalRoot) return leave('The global npm root could not be read, so what a restart would start is unknown.');
|
|
159
|
+
const install = path.join(globalRoot, GLOBAL_PACKAGE);
|
|
160
|
+
const observed = identity(path.join(brainDir, 'server.pid'), path.join(install, 'src', 'server', 'index.mjs'));
|
|
161
|
+
if (!observed.pid) return leave('The process serving this brain could not be identified.');
|
|
162
|
+
const owner = supervisor(observed.pid);
|
|
163
|
+
if (owner.supervised && owner.scope === 'system') return leave(`${owner.label} is a system service: replace it with total-recall deploy release.`, 'deploy');
|
|
164
|
+
if (observed.status !== 'running') return leave(`The server does not run from the global install${observed.entry ? ` (it runs ${observed.entry})` : ''}, so a restart would not bring it to the release.`);
|
|
165
|
+
if (fs.existsSync(path.join(install, '.git'))) return leave('The global install is a link to a git checkout, which auto-pull keeps current.');
|
|
166
|
+
const onDisk = versionOnDisk(install);
|
|
167
|
+
if (onDisk !== latest) return leave(`The global install is ${onDisk ?? 'unreadable'} and the release is ${latest}.`);
|
|
168
|
+
if (!owner.supervised || owner.scope !== 'user') return leave(`Nothing maintenance can drive would bring the server back: ${owner.reason}.`);
|
|
169
|
+
return { method: 'restart', kind: owner.kind, label: owner.label, pid: observed.pid };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Connections open to the server right now, or null when that cannot be read. */
|
|
173
|
+
export function openServerConnections({ pid, port, platform = process.platform, run = spawnSync } = {}) {
|
|
174
|
+
const options = { encoding: 'utf8', timeout: 10000 };
|
|
175
|
+
if (platform === 'linux') {
|
|
176
|
+
const result = run('ss', ['-tnH', 'state', 'established', 'sport', '=', `:${port}`], options);
|
|
177
|
+
if (result.error || result.status !== 0) return null;
|
|
178
|
+
return result.stdout.split('\n').filter((line) => line.trim()).length;
|
|
179
|
+
}
|
|
180
|
+
const result = run('lsof', ['-nP', '-a', '-p', String(pid), '-iTCP', '-sTCP:ESTABLISHED', '-Fn'], options);
|
|
181
|
+
if (result.error) return null;
|
|
182
|
+
// lsof exits 1 when nothing matches, and also on a real failure, which is the one that writes to stderr.
|
|
183
|
+
if (result.status !== 0 && (result.stdout?.trim() || result.stderr?.trim())) return null;
|
|
184
|
+
return (result.stdout || '').split('\n').filter((line) => line.startsWith('n') && line.split('->')[0].endsWith(`:${port}`)).length;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The command that stops the server and starts it again, or null when the job is not there. */
|
|
188
|
+
function restartCommand(plan, { run, uid }) {
|
|
189
|
+
if (plan.kind === 'systemd') return ['systemctl', ['--user', 'restart', plan.label]];
|
|
190
|
+
// `gui` exists while the user is logged in; a Mac with no login session has only `user`.
|
|
191
|
+
const target = [`gui/${uid}/${plan.label}`, `user/${uid}/${plan.label}`]
|
|
192
|
+
.find((candidate) => run('launchctl', ['print', candidate], { encoding: 'utf8', timeout: 10000 }).status === 0);
|
|
193
|
+
// kickstart -k delivers SIGTERM and waits (measured, launchd does not document it), so the
|
|
194
|
+
// server drains and releases its PID lock, and it needs no KeepAlive to come back.
|
|
195
|
+
return target ? ['launchctl', ['kickstart', '-k', target]] : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Bring a stale server to the release, when `planServerReplacement` allows it.
|
|
200
|
+
*
|
|
201
|
+
* An open connection holds this back so nobody's work is cut off, for as long as
|
|
202
|
+
* `holdLimitMs`: an open dashboard tab must not keep a host on an old release forever.
|
|
203
|
+
* Replaced means /health reports the release, never that the command returned.
|
|
204
|
+
*
|
|
205
|
+
* @returns the server status to report, with `replacement` saying what happened.
|
|
206
|
+
*/
|
|
207
|
+
export async function replaceStaleServer({
|
|
208
|
+
brainDir, server, globalRoot, previous, uid = process.getuid?.(), plan = planServerReplacement, connections = openServerConnections,
|
|
209
|
+
run = spawnSync, observe = runningServerStatus, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now,
|
|
210
|
+
idleWaitMs = 30000, healthWaitMs = 120000, pollMs = 2000, holdLimitMs = HOLD_LIMIT_MS,
|
|
211
|
+
} = {}) {
|
|
212
|
+
let replacement;
|
|
213
|
+
try {
|
|
214
|
+
replacement = plan({ brainDir, globalRoot, latest: server.latest });
|
|
215
|
+
if (replacement.method !== 'restart') return { ...server, replacement };
|
|
216
|
+
|
|
217
|
+
const port = new URL(server.url).port;
|
|
218
|
+
const idleBy = now() + idleWaitMs;
|
|
219
|
+
let open = connections({ pid: replacement.pid, port });
|
|
220
|
+
while (open !== 0 && now() < idleBy) {
|
|
221
|
+
await sleep(pollMs);
|
|
222
|
+
open = connections({ pid: replacement.pid, port });
|
|
223
|
+
}
|
|
224
|
+
if (open !== 0) {
|
|
225
|
+
const heldSince = previous?.replacement?.state === 'held' ? previous.replacement.held_since : now();
|
|
226
|
+
if (now() - heldSince < holdLimitMs) {
|
|
227
|
+
const reason = open === null ? 'Open connections could not be read.' : `${open} connection(s) are open on the server.`;
|
|
228
|
+
return { ...server, replacement: { ...replacement, state: 'held', held_since: heldSince, reason } };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const command = restartCommand(replacement, { run, uid });
|
|
233
|
+
if (!command) return { ...server, replacement: { ...replacement, state: 'failed', reason: `launchd has no job ${replacement.label} for this user.` } };
|
|
234
|
+
const restarted = run(command[0], command[1], { encoding: 'utf8', timeout: 60000 });
|
|
235
|
+
if (restarted.error || restarted.status !== 0) {
|
|
236
|
+
const reason = `${command[0]} ${command[1].join(' ')} failed: ${restarted.error?.message || restarted.stderr?.trim() || `exit ${restarted.status}`}`;
|
|
237
|
+
return { ...server, replacement: { ...replacement, state: 'failed', reason } };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const healthyBy = now() + healthWaitMs;
|
|
241
|
+
let after;
|
|
242
|
+
do {
|
|
243
|
+
await sleep(pollMs);
|
|
244
|
+
after = await observe({ brainDir, latest: server.latest, timeoutMs: 5000 });
|
|
245
|
+
} while (after.status !== 'current' && now() < healthyBy);
|
|
246
|
+
if (after.status === 'current') return { ...after, replacement: { ...replacement, state: 'replaced', from: server.running } };
|
|
247
|
+
return { ...after, replacement: { ...replacement, state: 'failed', reason: `/health did not report ${server.latest} within ${healthWaitMs / 1000} seconds of the restart.` } };
|
|
248
|
+
} catch (error) {
|
|
249
|
+
return { ...server, replacement: { ...replacement, state: 'failed', reason: error.message } };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -5,6 +5,33 @@ import {spawnSync} from 'node:child_process';
|
|
|
5
5
|
import {listUpdateRoots} from './package-auto-update.mjs';
|
|
6
6
|
import {PACKAGE_ROOT} from './plugin-loader.mjs';
|
|
7
7
|
import {comparePackageVersions} from './package-version.mjs';
|
|
8
|
+
import {installEnv} from './brain-updates.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether a live server runs from this checkout. systemd starts one with an absolute
|
|
12
|
+
* path and launchd with a relative one, so the arguments decide when they can and the
|
|
13
|
+
* working directory otherwise. A process that cannot be read counts as serving: leaving
|
|
14
|
+
* the files alone is the safe answer, and the next run asks again.
|
|
15
|
+
*/
|
|
16
|
+
export function serverRunsFrom(directory,{exec=spawnSync,platform=process.platform}={}) {
|
|
17
|
+
const read=(binary,args)=>exec(binary,args,{encoding:'utf8',timeout:5000});
|
|
18
|
+
const listed=read('pgrep',['-f','src/server/index\\.mjs']);
|
|
19
|
+
if(listed.status!==0)return false;
|
|
20
|
+
let real=directory;try{real=fs.realpathSync(directory);}catch{}
|
|
21
|
+
const roots=new Set([directory,real]);
|
|
22
|
+
for(const pid of String(listed.stdout||'').split(/\s+/).filter(Boolean)){
|
|
23
|
+
const args=read('ps',['-o','args=','-p',pid]);
|
|
24
|
+
if(args.status!==0)return true;
|
|
25
|
+
const prefix=/(\S*)src\/server\/index\.mjs/.exec(args.stdout||'')?.[1];
|
|
26
|
+
if(prefix===undefined)continue;
|
|
27
|
+
if(prefix.startsWith('/')){if(roots.has(prefix.replace(/\/+$/,'')))return true;continue;}
|
|
28
|
+
const cwd=platform==='linux'?read('readlink',[`/proc/${pid}/cwd`]):read('lsof',['-a','-p',pid,'-d','cwd','-Fn']);
|
|
29
|
+
if(cwd.status!==0)return true;
|
|
30
|
+
const dir=platform==='linux'?String(cwd.stdout||'').trim():/^n(.+)$/m.exec(cwd.stdout||'')?.[1];
|
|
31
|
+
if(!dir || roots.has(dir))return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
8
35
|
|
|
9
36
|
export function updateSourceBrainPackages({brainDir,versions,roots,update=updateSourceBrainPackage}={}) {
|
|
10
37
|
const results=[];
|
|
@@ -16,15 +43,17 @@ export function updateSourceBrainPackages({brainDir,versions,roots,update=update
|
|
|
16
43
|
if(pkg.version===latest)continue;
|
|
17
44
|
if(comparePackageVersions(pkg.version,latest)===1){row.status='failed';row.error='Newer source preview; refusing implicit downgrade';continue;}
|
|
18
45
|
const result=update({directory,name:pkg.name,version:latest});
|
|
19
|
-
row.status=result.ok?'updated':'failed';
|
|
20
|
-
if(result.ok){row.installed=result.installed;row.commit=result.commit;}else row.error=result.error;
|
|
46
|
+
row.status=result.ok?'updated':result.serving?'serving':'failed';
|
|
47
|
+
if(result.ok){row.installed=result.installed;row.commit=result.commit;}else if(result.serving)row.note=result.error;else row.error=result.error;
|
|
21
48
|
}
|
|
22
49
|
return {failed:results.filter(row=>row.status==='failed').length,results};
|
|
23
50
|
}
|
|
24
51
|
|
|
25
|
-
export function updateSourceBrainPackage({directory,name,version,exec=spawnSync}={}) {
|
|
26
|
-
|
|
27
|
-
|
|
52
|
+
export function updateSourceBrainPackage({directory,name,version,exec=spawnSync,serving=serverRunsFrom}={}) {
|
|
53
|
+
// Merging and npm ci rewrite the code and delete node_modules under the process using them.
|
|
54
|
+
if(serving(directory))return {ok:false,serving:true,error:'A live server runs from this checkout. Replacing that server moves it.'};
|
|
55
|
+
const run=(binary,args,timeout=30000,env=process.env)=>{
|
|
56
|
+
const result=exec(binary,args,{cwd:directory,encoding:'utf8',timeout,maxBuffer:8*1024*1024,env});
|
|
28
57
|
if(result.status!==0)throw new Error(result.error?.message || result.stderr?.trim().slice(-1000) || `${binary} failed`);
|
|
29
58
|
return result.stdout?.trim() || '';
|
|
30
59
|
};
|
|
@@ -40,7 +69,7 @@ export function updateSourceBrainPackage({directory,name,version,exec=spawnSync}
|
|
|
40
69
|
const candidate=JSON.parse(run('git',['show',`${commit}:package.json`]));
|
|
41
70
|
if(candidate.name!==name || candidate.version!==version)throw new Error('Published source commit does not match the package version');
|
|
42
71
|
run('git',['merge','--ff-only',commit]);
|
|
43
|
-
run('npm',['ci','--no-audit','--no-fund'],600000);
|
|
72
|
+
run('npm',['ci','--no-audit','--no-fund'],600000,installEnv());
|
|
44
73
|
if(read().version!==version || run('git',['rev-parse','HEAD'])!==commit)throw new Error('Source release readback failed');
|
|
45
74
|
return {ok:true,installed:version,commit};
|
|
46
75
|
}catch(error){return {ok:false,error:error.message};}
|
|
@@ -235,9 +235,8 @@ export function isProtocolPath(vfsPath = '') {
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
function stringifyDocument(data, body) {
|
|
238
|
-
// Prefer gray-matter when available; fall back to simple frontmatter dump.
|
|
239
238
|
try {
|
|
240
|
-
return matter.stringify(body || '', data);
|
|
239
|
+
return matter.stringify({ content: body || '', data });
|
|
241
240
|
} catch {
|
|
242
241
|
const yaml = Object.entries(data)
|
|
243
242
|
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
|
|
@@ -96,6 +96,16 @@ export function healthVerdict(body) {
|
|
|
96
96
|
return { status: 'ready', version: body.version };
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/** A server older than the code beside it: healthy, and not what was installed. */
|
|
100
|
+
export function serverCodeDrift(running, onDisk) {
|
|
101
|
+
if (!running || !onDisk || running === onDisk) return null;
|
|
102
|
+
return { running, on_disk: onDisk };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function packageVersionOnDisk() {
|
|
106
|
+
try { return JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')).version; } catch { return null; }
|
|
107
|
+
}
|
|
108
|
+
|
|
99
109
|
export function appVerdict(result) {
|
|
100
110
|
if (result.reason || result.code !== 0) return { status: 'failed', reason: result.reason || 'check-exit-failed' };
|
|
101
111
|
let body;
|
|
@@ -120,7 +130,7 @@ export async function fetchStartup(url, options = {}, fetchImpl = fetch) {
|
|
|
120
130
|
}
|
|
121
131
|
|
|
122
132
|
export async function startupHealth({ cwd = process.cwd(), brainDir, brainUrl = null, token = null, brainId = null, ensure = false,
|
|
123
|
-
appCheck = null, appStart = null, run = runBounded, fetchImpl = fetch, listener = verifyListener, platform = process.platform, home = os.homedir(), identity = processIdentity } = {}) {
|
|
133
|
+
appCheck = null, appStart = null, run = runBounded, fetchImpl = fetch, onDiskVersion = packageVersionOnDisk, listener = verifyListener, platform = process.platform, home = os.homedir(), identity = processIdentity } = {}) {
|
|
124
134
|
const actions = [];
|
|
125
135
|
const serverFile = path.join(brainDir, 'server.pid');
|
|
126
136
|
const report = { checked_at: new Date().toISOString(), server: { status: 'not-configured' }, brain: { status: 'unknown' }, daemon: { status: 'unknown' },
|
|
@@ -141,7 +151,8 @@ export async function startupHealth({ cwd = process.cwd(), brainDir, brainUrl =
|
|
|
141
151
|
if (own.status !== 'running') return { status: own.status === 'conflict' ? 'conflict' : 'unknown', reason: 'server-process-unverified' };
|
|
142
152
|
if (!await listener(own.pid, new URL(brainUrl), { run, cwd })) return { status: 'unknown', reason: 'server-listener-unverified' };
|
|
143
153
|
}
|
|
144
|
-
|
|
154
|
+
const drift = local && verdict.status === 'ready' ? serverCodeDrift(verdict.version, onDiskVersion()) : null;
|
|
155
|
+
return { ...verdict, ...(drift ? { stale: drift } : {}), source: local ? 'local-verified-listener' : 'remote-health' };
|
|
145
156
|
} catch { return { status: 'offline', reason: 'health-unreachable' }; }
|
|
146
157
|
};
|
|
147
158
|
report.server = await probeServer();
|
package/src/core/surface.mjs
CHANGED
|
@@ -620,7 +620,7 @@ const CLIENT_SHIMS = {
|
|
|
620
620
|
'hermes-agent': ['AGENTS.md'],
|
|
621
621
|
dsh: ['AGENTS.md'],
|
|
622
622
|
'deepseek-harness': ['AGENTS.md'],
|
|
623
|
-
openclaw: ['
|
|
623
|
+
openclaw: ['AGENTS.md']
|
|
624
624
|
};
|
|
625
625
|
|
|
626
626
|
/**
|
|
@@ -739,13 +739,13 @@ async function compilePointers(instructionsFile, skillsDir, nodes = [], { vaultD
|
|
|
739
739
|
*/
|
|
740
740
|
/**
|
|
741
741
|
* The rule nodes a project's instruction surfaces are built from: the global
|
|
742
|
-
* brain's rules plus the project's own. A project
|
|
742
|
+
* brain's rules plus the project's own. A project rule with the same slug
|
|
743
743
|
* wins, so a repo can override a global rule; everything else in the global
|
|
744
744
|
* vault stays search-only. Without this every repo carried only its own vault,
|
|
745
745
|
* and a rule saved with `remember --global` reached no repo at all.
|
|
746
746
|
*/
|
|
747
747
|
export function mergeGlobalRuleNodes(projectNodes, globalNodes) {
|
|
748
|
-
const own = new Set(projectNodes.map((n) => n.slug));
|
|
748
|
+
const own = new Set(projectNodes.filter((n) => RULE_CATEGORIES.has(n.category)).map((n) => n.slug));
|
|
749
749
|
const inherited = globalNodes
|
|
750
750
|
.filter((n) => RULE_CATEGORIES.has(n.category) && !own.has(n.slug))
|
|
751
751
|
.map((n) => ({ ...n, _layer: 'global' }));
|
|
@@ -9,7 +9,9 @@ import fs from 'node:fs';
|
|
|
9
9
|
const hash = text => crypto.createHash('sha256').update(String(text)).digest('hex');
|
|
10
10
|
const STOP = new Set('a an and are as at be before by can do for from how i in is it of on or our should so that the their this to use we what when which with you'.split(' '));
|
|
11
11
|
export const taskTerms = task => [...new Set((String(task).toLowerCase().match(/[\p{L}\p{N}_-]{3,}/gu) || []).filter(term => !STOP.has(term)))];
|
|
12
|
-
|
|
12
|
+
// Task terms contain only letters, digits, underscores and hyphens. Hyphens also delimit source-reference segments.
|
|
13
|
+
const taskMatchers = task => taskTerms(task).map(term => new RegExp(`(?<![\\p{L}\\p{N}_])${term}(?![\\p{L}\\p{N}_])`, 'iu'));
|
|
14
|
+
const overlap = (matchers, text) => matchers.filter(matcher => matcher.test(text)).length / Math.max(1, matchers.length);
|
|
13
15
|
export const summarizeRetrieval = summary => summary ? { mode: summary.mode, selected: summary.selected,
|
|
14
16
|
candidates: summary.candidates || 0, providers: (summary.coverage || []).map(row => row.provider),
|
|
15
17
|
...(summary.duplicatePassages ? { duplicatePassages: summary.duplicatePassages } : {}),
|
|
@@ -18,11 +20,11 @@ export const summarizeRetrieval = summary => summary ? { mode: summary.mode, sel
|
|
|
18
20
|
|
|
19
21
|
/** Preserve the best matching line and bounded neighboring text, including a match late in a long body. */
|
|
20
22
|
export function extractTaskPassage(text, task, { maxChars = 1000, anchorLine } = {}) {
|
|
21
|
-
const lines = String(text || '').split('\n'),
|
|
23
|
+
const lines = String(text || '').split('\n'), matchers = taskMatchers(task);
|
|
22
24
|
let anchor = Number.isInteger(anchorLine) ? Math.max(0, Math.min(lines.length - 1, anchorLine - 1)) : 0;
|
|
23
25
|
if (!Number.isInteger(anchorLine)) {
|
|
24
26
|
let best = -1;
|
|
25
|
-
lines.forEach((line, index) => { const score = overlap(
|
|
27
|
+
lines.forEach((line, index) => { const score = overlap(matchers, line); if (score > best) { best = score; anchor = index; } });
|
|
26
28
|
}
|
|
27
29
|
let start = Math.max(0, anchor - 2), end = Math.min(lines.length, anchor + 3);
|
|
28
30
|
while (lines.slice(start, end).join('\n').length > maxChars && (start < anchor || end > anchor + 1)) {
|
|
@@ -32,8 +34,7 @@ export function extractTaskPassage(text, task, { maxChars = 1000, anchorLine } =
|
|
|
32
34
|
let passage = lines.slice(start, end).join('\n');
|
|
33
35
|
let startColumn = 1;
|
|
34
36
|
if (passage.length > maxChars) {
|
|
35
|
-
const
|
|
36
|
-
const hits = terms.map(term => lower.indexOf(term)).filter(index => index >= 0);
|
|
37
|
+
const hits = matchers.map(matcher => passage.search(matcher)).filter(index => index >= 0);
|
|
37
38
|
const focus = hits.length ? Math.min(...hits) : 0;
|
|
38
39
|
const offset = Math.max(0, Math.min(passage.length - maxChars, focus - Math.floor(maxChars / 3)));
|
|
39
40
|
passage = passage.slice(offset, offset + maxChars); startColumn += offset;
|
|
@@ -50,8 +51,8 @@ function renderEvidence(item) {
|
|
|
50
51
|
export async function retrieveTaskKnowledge({ query, nodes = [], derivedDir, indexScopes,
|
|
51
52
|
projectRoot = process.cwd(), actions = [], semantic = true, budget = 1200, limit = 6,
|
|
52
53
|
embedding = getEmbedding, loadIndex = loadEmbeddingsIndex, providers = retrievePluginContext } = {}) {
|
|
53
|
-
const
|
|
54
|
-
if (!
|
|
54
|
+
const matchers = taskMatchers(query);
|
|
55
|
+
if (!matchers.length) return { contributions: [], summary: { mode: 'none', selected: 0, diagnostics: [], coverage: [] }, queryVector: null };
|
|
55
56
|
const diagnostics = [], indexes = new Map();
|
|
56
57
|
const scopes = indexScopes || (derivedDir ? [{ layer: '*', derivedDir }] : []);
|
|
57
58
|
for (const scope of scopes) {
|
|
@@ -77,7 +78,7 @@ export async function retrieveTaskKnowledge({ query, nodes = [], derivedDir, ind
|
|
|
77
78
|
if (stale) staleVectors++;
|
|
78
79
|
const valid = !stale && Array.isArray(entry?.embedding) && Array.isArray(queryVector) && entry.embedding.length === queryVector.length;
|
|
79
80
|
if (valid) vectors++;
|
|
80
|
-
const lexical = overlap(
|
|
81
|
+
const lexical = overlap(matchers, `${node.title || ''}\n${body}`);
|
|
81
82
|
const similarity = valid ? cosineSimilarity(queryVector, entry.embedding) : 0;
|
|
82
83
|
const score = valid ? Math.max(0, similarity) * .75 + lexical * .25 : lexical * .7;
|
|
83
84
|
return { node, layer, body, score, relevant: lexical > 0 || similarity >= .35 };
|
|
@@ -15,6 +15,8 @@ import { processOperationAsync } from './operation-validator.mjs';
|
|
|
15
15
|
import { safeStringify, isSafeVaultName } from './vault.mjs';
|
|
16
16
|
import { invalidate } from './vault-cache.mjs';
|
|
17
17
|
|
|
18
|
+
export const supportsExpectedBeforeHash = true;
|
|
19
|
+
|
|
18
20
|
/**
|
|
19
21
|
* Normalize a vault node so package universal frontmatter + TR memory v2 pass.
|
|
20
22
|
*/
|
|
@@ -101,6 +103,7 @@ function buildEnvelope(node, options = {}) {
|
|
|
101
103
|
const {
|
|
102
104
|
workspaceId = 'default',
|
|
103
105
|
idempotencyKey,
|
|
106
|
+
expectedBeforeHash,
|
|
104
107
|
dryRun = false,
|
|
105
108
|
path: pathOverride,
|
|
106
109
|
} = options;
|
|
@@ -124,6 +127,7 @@ function buildEnvelope(node, options = {}) {
|
|
|
124
127
|
path: vfsPath,
|
|
125
128
|
workspace_id: workspaceId,
|
|
126
129
|
content,
|
|
130
|
+
...(expectedBeforeHash === undefined ? {} : { expected_before_hash: expectedBeforeHash }),
|
|
127
131
|
dry_run: !!dryRun,
|
|
128
132
|
actor: { role: options.agentRole || 'system' },
|
|
129
133
|
};
|
package/src/core/vault-files.mjs
CHANGED
|
@@ -8,6 +8,12 @@ export function isSafeVaultName(name) {
|
|
|
8
8
|
|
|
9
9
|
export function atomicWrite(filePath, content) {
|
|
10
10
|
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
try {
|
|
12
|
+
fs.writeFileSync(tmpPath, content, 'utf8');
|
|
13
|
+
fs.renameSync(tmpPath, filePath);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
// A leftover copy is untracked and unignored, so a commit of everything would publish it.
|
|
16
|
+
fs.rmSync(tmpPath, { force: true });
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
13
19
|
}
|
package/src/core/vault.mjs
CHANGED
|
@@ -33,7 +33,7 @@ function stripUndefined(obj) {
|
|
|
33
33
|
* @returns {string} The serialized markdown with frontmatter
|
|
34
34
|
*/
|
|
35
35
|
export function safeStringify(content, data) {
|
|
36
|
-
return matter.stringify(content ?? '', stripUndefined(data ?? {}));
|
|
36
|
+
return matter.stringify({ content: content ?? '', data: stripUndefined(data ?? {}) });
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
package/src/server/index.mjs
CHANGED
|
@@ -34,7 +34,8 @@ import { readProcessCommand, entryPathHint, shouldHonorPidLock } from '../core/p
|
|
|
34
34
|
import { createCoverageCache } from './health-coverage.mjs';
|
|
35
35
|
import { drainActiveEmbeddings } from './routes/sessions.mjs';
|
|
36
36
|
import { agentDir as configAgentDir, brainDir as configBrainDir, port as configPort, host as configHost, nodeEnv, instructionsFilePath } from '../core/config.mjs';
|
|
37
|
-
import { getDaemonStatus, ensureDaemonRunning } from '../core/daemon-control.mjs';
|
|
37
|
+
import { getDaemonStatus, ensureDaemonRunning, clearStaleDaemonLock } from '../core/daemon-control.mjs';
|
|
38
|
+
import { deploymentHealth } from '../core/running-server.mjs';
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -46,6 +47,13 @@ const PACKAGE_VERSION = (() => {
|
|
|
46
47
|
return 'unknown';
|
|
47
48
|
}
|
|
48
49
|
})();
|
|
50
|
+
const PACKAGE_NAME = (() => {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).name || 'total-recall-brain';
|
|
53
|
+
} catch {
|
|
54
|
+
return 'total-recall-brain';
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
49
57
|
|
|
50
58
|
// ─── PID Lock ───────────────────────────────────────────────────────────────────
|
|
51
59
|
// A second instance that loses the listen() race for PORT has historically kept
|
|
@@ -229,6 +237,12 @@ const embeddingCoverageSnapshot = () => coverageCache.snapshot();
|
|
|
229
237
|
// Warm it at boot so the first probe already has a real answer.
|
|
230
238
|
embeddingCoverageSnapshot();
|
|
231
239
|
|
|
240
|
+
// Identity only, for a deploy telling one instance from another. /health forks and reads
|
|
241
|
+
// the disk, so it can take seconds under load; this does neither.
|
|
242
|
+
app.get('/health/deployment', requireAuthOrLocal, (_req, res) => {
|
|
243
|
+
res.json(deploymentHealth({ name: PACKAGE_NAME, version: PACKAGE_VERSION, port: PORT }));
|
|
244
|
+
});
|
|
245
|
+
|
|
232
246
|
app.get('/health', requireAuthOrLocal, async (req, res) => {
|
|
233
247
|
let disk = { free: 0, total: 0 };
|
|
234
248
|
try {
|
|
@@ -990,6 +1004,8 @@ if (HOST !== '127.0.0.1' && HOST !== '0.0.0.0') {
|
|
|
990
1004
|
|
|
991
1005
|
// Unref the interval so it doesn't block process shutdown
|
|
992
1006
|
daemonWatchdogInterval.unref();
|
|
1007
|
+
} else if (nodeEnv !== 'test') {
|
|
1008
|
+
clearStaleDaemonLock();
|
|
993
1009
|
}
|
|
994
1010
|
|
|
995
1011
|
// ─── Cloudflare Tunnel Auto-Start ───
|