total-recall-brain 3.47.1 → 3.47.3
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 +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/included-usage.mjs +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +7 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/model-selection.mjs +6 -2
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +25 -6
- package/src/cli/update.mjs +20 -1
- package/src/core/automatic-brain-updates.mjs +8 -4
- package/src/core/brain-updates.mjs +25 -6
- package/src/core/release-defaults-handoff.mjs +89 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "total-recall-brain",
|
|
3
|
-
"version": "3.47.
|
|
3
|
+
"version": "3.47.3",
|
|
4
4
|
"description": "The Vibe Coding Brain & Autonomous Software Factory: memory, rules, secrets, and 33 visual plugins automatically managed across all your devices.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tr-plugin-meta-harness",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "tr-plugin-meta-harness",
|
|
9
|
-
"version": "0.4.
|
|
9
|
+
"version": "0.4.3",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@gregiteen/ssss-cli": "0.14.5"
|
|
12
12
|
},
|
|
13
13
|
"devDependencies": {
|
|
14
|
-
"total-recall-brain": "3.
|
|
14
|
+
"total-recall-brain": "3.47.1"
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"node_modules/@alcalzone/ansi-tokenize": {
|
|
@@ -5546,9 +5546,9 @@
|
|
|
5546
5546
|
}
|
|
5547
5547
|
},
|
|
5548
5548
|
"node_modules/total-recall-brain": {
|
|
5549
|
-
"version": "3.
|
|
5550
|
-
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.
|
|
5551
|
-
"integrity": "sha512-
|
|
5549
|
+
"version": "3.47.1",
|
|
5550
|
+
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.47.1.tgz",
|
|
5551
|
+
"integrity": "sha512-9yDByVZMNxNE10hjPf0vEmvBLGGUpD8sqAN7svuZpJEb2TSTPA1eSSwaZ5H4QScJQTT3DcV8MeQjuyLy8urnEw==",
|
|
5552
5552
|
"dev": true,
|
|
5553
5553
|
"license": "MIT",
|
|
5554
5554
|
"workspaces": [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "meta-harness",
|
|
3
3
|
"name": "Metaharness",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.3",
|
|
5
5
|
"description": "Discover, configure, document, track, and run coding agent harnesses through Total Recall across mesh nodes.",
|
|
6
6
|
"gate": {
|
|
7
7
|
"tasks": ["Run work in another agent harness", "Choose which harness takes a task", "Check harness versions, sign-in and quota", "Read a harness's official documentation"],
|
|
@@ -79,7 +79,7 @@ export async function dispatchWithReceipt(request, { dispatchFn, node = request.
|
|
|
79
79
|
// exhausted until its reset and picks another included harness instead of choosing it again.
|
|
80
80
|
if (result.failureKind === 'quota') {
|
|
81
81
|
try { await record({ harness: request.agent, role, task: String(request.contextTask || request.prompt || '').slice(0, 200), success: false,
|
|
82
|
-
failureKind: 'quota', error: result.error, resetsAt: result.resetsAt || null }); } catch { /* bookkeeping must not hide the refusal */ }
|
|
82
|
+
failureKind: 'quota', billingMode, error: result.error, resetsAt: result.resetsAt || null }); } catch { /* bookkeeping must not hide the refusal */ }
|
|
83
83
|
}
|
|
84
84
|
return { ...result, launchPrevented, durationMs };
|
|
85
85
|
}
|
|
@@ -7,7 +7,7 @@ import { createInterface } from 'node:readline';
|
|
|
7
7
|
import { pathToFileURL } from 'node:url';
|
|
8
8
|
import { configDir } from './paths.mjs';
|
|
9
9
|
import { agentEnvironment, CODEX_PLAN_ARGS, findBinary, OPENROUTER_AGENTS, readClaudeMaxOrganization } from './worker.mjs';
|
|
10
|
-
import { activeQuotaWalls, readReceipts } from './learner.mjs';
|
|
10
|
+
import { activeQuotaWalls, includedBillingReceipts, readReceipts } from './learner.mjs';
|
|
11
11
|
|
|
12
12
|
export const INCLUDED_AGENTS = ['claude', 'codex'];
|
|
13
13
|
const VERSION = 1;
|
|
@@ -235,7 +235,7 @@ export async function includedAdmission(agent, cwd, policy, { readKey = authKey,
|
|
|
235
235
|
if (agent === 'claude' && !policy.claudePlan && !claudeProofCurrent(policy)) {
|
|
236
236
|
return { ok: false, failureKind: 'billing-policy', error: 'Verify the native Claude Max login with Usage credits off, or bind a verified included grant.' };
|
|
237
237
|
}
|
|
238
|
-
const wall = activeQuotaWalls((await receipts())
|
|
238
|
+
const wall = activeQuotaWalls(includedBillingReceipts(await receipts()))[agent];
|
|
239
239
|
if (wall) return { ok: false, failureKind: 'quota', error: 'This harness is waiting for included usage.', resetsAt: wall.resetReported ? wall.until : null };
|
|
240
240
|
if (agent === 'codex') return codex(cwd);
|
|
241
241
|
if (policy.claudePlan) return { ok: true, claudePlan: policy.claudePlan.organization };
|
|
@@ -60,6 +60,13 @@ export async function recordRunReceipt(receipt = {}) {
|
|
|
60
60
|
return entry;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Receipts that count under the included usage policy: included runs, plus quota refusals recorded before
|
|
65
|
+
* refusals carried a billingMode. Those legacy walls expire at their reset, so nothing stale lingers.
|
|
66
|
+
*/
|
|
67
|
+
export const includedBillingReceipts = (entries = []) =>
|
|
68
|
+
entries.filter(run => run.billingMode === 'included' || (!run.billingMode && run.failureKind === 'quota'));
|
|
69
|
+
|
|
63
70
|
/** A harness is walled from its last quota failure until the reported reset, or until a later success proves it clear. */
|
|
64
71
|
export function activeQuotaWalls(entries = [], now = Date.now(), unknownResetMs = 60 * 60 * 1000) {
|
|
65
72
|
const latest = new Map();
|
|
@@ -20,7 +20,11 @@ export function queryAiModels(harness, model, { bin = process.env.TOTAL_RECALL_B
|
|
|
20
20
|
const line = output.trim().split('\n').filter(Boolean).at(-1);
|
|
21
21
|
try {
|
|
22
22
|
const answer = JSON.parse(line);
|
|
23
|
-
|
|
23
|
+
if (!answer || typeof answer.ok !== 'boolean') return null;
|
|
24
|
+
// An error reply (an older ai-models without `validate` answers invalid_arguments) is the command
|
|
25
|
+
// failing, not a verdict on the model; reading it as one would refuse every model.
|
|
26
|
+
if (answer.error != null || answer.code != null) return null;
|
|
27
|
+
return answer;
|
|
24
28
|
} catch { return null; }
|
|
25
29
|
}
|
|
26
30
|
|
|
@@ -32,7 +36,7 @@ export function createModelValidator({ aiModels = queryAiModels, catalog = getMo
|
|
|
32
36
|
if (refusal) return { ok: false, harness, model, source: 'policy', reason: refusal };
|
|
33
37
|
if (ownerAnswers) {
|
|
34
38
|
const answer = aiModels(harness, model);
|
|
35
|
-
if (answer) return { ok: answer.ok, harness, model, source: 'ai-models', ...(answer.ok ? {} : { reason: answer.reason ||
|
|
39
|
+
if (answer) return { ok: answer.ok, harness, model, source: 'ai-models', ...(answer.ok ? {} : { reason: answer.reason || `AI Models does not list ${model} for ${harness}` }) };
|
|
36
40
|
ownerAnswers = false;
|
|
37
41
|
}
|
|
38
42
|
// Another node's catalog is not this one's: its destination validates against its own.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The harnesses that can actually take work right now, and why the others cannot. */
|
|
2
2
|
import { ADAPTER_CATALOG } from '../capability/index.mjs';
|
|
3
3
|
import { inspectAgents, getActiveAdapters } from './worker.mjs';
|
|
4
|
-
import { readReceipts, activeQuotaWalls, activeCredentialBlocks } from './learner.mjs';
|
|
4
|
+
import { readReceipts, activeQuotaWalls, activeCredentialBlocks, includedBillingReceipts } from './learner.mjs';
|
|
5
5
|
import { measuredWalls } from './telemetry.mjs';
|
|
6
6
|
import { claudeProofCurrent, INCLUDED_AGENTS, readIncludedPolicy } from './included-usage.mjs';
|
|
7
7
|
import os from 'node:os';
|
|
@@ -29,7 +29,7 @@ export async function resolvePool({ availableHarnesses, inspect = inspectAgents,
|
|
|
29
29
|
if (!policy.enabled && Array.isArray(availableHarnesses) && availableHarnesses.length) return { ready: availableHarnesses.map(id => describe(id)), excluded: [], walls: {} };
|
|
30
30
|
const claudePlan = policy.enabled ? policy.claudePlan?.organization : undefined;
|
|
31
31
|
const [{ agents = [] }, entries] = await Promise.all([inspect({ env: process.env, claudePlan }), receipts()]);
|
|
32
|
-
const billingEntries = policy.enabled ? entries
|
|
32
|
+
const billingEntries = policy.enabled ? includedBillingReceipts(entries) : entries;
|
|
33
33
|
const walls = { ...activeQuotaWalls(billingEntries), ...(!policy.enabled ? measuredWalls() : {}) };
|
|
34
34
|
const blocks = activeCredentialBlocks(billingEntries);
|
|
35
35
|
const ready = [], excluded = [];
|
|
@@ -18,10 +18,10 @@ import { classifyTaskComplexity, selectOptimalHarness, computeEmpiricalScorecard
|
|
|
18
18
|
import { inspectAgents } from './worker.mjs';
|
|
19
19
|
import { inspectNode } from './executor.mjs';
|
|
20
20
|
import { getModelCatalog, joinHarnessModels } from './model-catalog.mjs';
|
|
21
|
-
import { readReceipts, activeQuotaWalls, activeCredentialBlocks } from './learner.mjs';
|
|
21
|
+
import { readReceipts, activeQuotaWalls, activeCredentialBlocks, includedBillingReceipts, recordRunReceipt } from './learner.mjs';
|
|
22
22
|
import { resolvePool, exclusionReason, isLocalExecutionNode } from './pool.mjs';
|
|
23
23
|
import { readCodexRateLimits, measuredWalls } from './telemetry.mjs';
|
|
24
|
-
import { claudeProofCurrent, INCLUDED_AGENTS, readIncludedPolicy } from './included-usage.mjs';
|
|
24
|
+
import { claudeProofCurrent, INCLUDED_AGENTS, readIncludedPolicy, readCodexAllowance } from './included-usage.mjs';
|
|
25
25
|
import { derivedDir } from './paths.mjs';
|
|
26
26
|
import { createModelValidator, filterPoolByModel } from './model-selection.mjs';
|
|
27
27
|
|
|
@@ -169,7 +169,7 @@ export async function inspectQuotas(options = {}) {
|
|
|
169
169
|
const inspected = remote ? await (options.inspectNodeFn || inspectNode)(options.node) :
|
|
170
170
|
await (options.inspect || inspectAgents)({ env: process.env, cwd: options.cwd, claudePlan });
|
|
171
171
|
const codexLimits = remote ? null : readCodexRateLimits();
|
|
172
|
-
const receipts = remote ? [] : (
|
|
172
|
+
const receipts = remote ? [] : (policy.enabled ? includedBillingReceipts : runs => runs)(await (options.receipts || readReceipts)());
|
|
173
173
|
const walls = remote ? {} : { ...activeQuotaWalls(receipts), ...(!policy.enabled ? measuredWalls(codexLimits) : {}) };
|
|
174
174
|
const metered = METERED_HARNESSES;
|
|
175
175
|
|
|
@@ -286,7 +286,26 @@ export async function routeTask(taskDescription, options = {}) {
|
|
|
286
286
|
...(remote || lean.fleet ? {} : { fleet: complexity.tier === 'local' ? cachedFleet : () => ({ ok: false, error: 'Not read: this route places no local work', nodes: [] }) }),
|
|
287
287
|
});
|
|
288
288
|
const quotas = quotaReport.quotas;
|
|
289
|
-
const
|
|
289
|
+
const rank = ready => selectOptimalHarness(taskDescription, ready.map(h => h.id), quotas, { ...options, tierOverride: complexity.tier, empiricalScorecard: computeEmpiricalScorecard(receipts) });
|
|
290
|
+
let excluded = pool.excluded;
|
|
291
|
+
let routing = rank(pool.ready);
|
|
292
|
+
// Quota walls come from launches this machine saw refused. With none recorded here, a route kept
|
|
293
|
+
// choosing a Codex that was out of quota, and the selected-harness write gate then blocked every
|
|
294
|
+
// other harness. Before choosing Codex, ask it for its allowance (no turn), and record a quota
|
|
295
|
+
// answer as the wall a refused launch records (A-124), so later routes skip it without asking.
|
|
296
|
+
if (policy.enabled && !remote && routing.selected === 'codex') {
|
|
297
|
+
const allowance = await (options.codexAllowance || readCodexAllowance)(options.cwd || process.cwd());
|
|
298
|
+
if (allowance?.ok === false && allowance.failureKind === 'quota') {
|
|
299
|
+
const resetsAt = allowance.resetsAt || null;
|
|
300
|
+
try {
|
|
301
|
+
await (options.recordReceipt || recordRunReceipt)({ harness: 'codex', role: 'Route', task: String(taskDescription).slice(0, 200),
|
|
302
|
+
success: false, failureKind: 'quota', billingMode: 'included', error: allowance.error, resetsAt });
|
|
303
|
+
} catch { /* the route still skips Codex; only the next route asks again */ }
|
|
304
|
+
excluded = [...excluded, { harness: 'codex', reason: resetsAt ? `out of quota until ${resetsAt}` : 'out of quota', failureKind: 'quota', resetsAt }];
|
|
305
|
+
quotas.codex = { ...quotas.codex, status: 'exhausted', wallMessage: allowance.error, ...(resetsAt ? { resetsAt } : {}) };
|
|
306
|
+
routing = rank(pool.ready.filter(h => h.id !== 'codex'));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
290
309
|
const selectedQuota = quotas[routing.selected] || {};
|
|
291
310
|
const warnings = [];
|
|
292
311
|
if (policy.enabled && options.jev) warnings.push('Included usage policy uses the local task classifier.');
|
|
@@ -298,8 +317,8 @@ export async function routeTask(taskDescription, options = {}) {
|
|
|
298
317
|
if (selectedModel?.source === 'unverified') warnings.push(selectedModel.note);
|
|
299
318
|
return {
|
|
300
319
|
ok: Boolean(routing.selected), task: taskDescription, classifier, complexity,
|
|
301
|
-
selected: routing.selected, tier: complexity.tier, model: options.model ?? null, ...(modelSelection ? { modelSelection } : {}), candidates: routing.candidates, excluded
|
|
302
|
-
...(policy.enabled && !routing.selected ? { deferred:
|
|
320
|
+
selected: routing.selected, tier: complexity.tier, model: options.model ?? null, ...(modelSelection ? { modelSelection } : {}), candidates: routing.candidates, excluded,
|
|
321
|
+
...(policy.enabled && !routing.selected ? { deferred: excluded.find(row => row.failureKind === 'quota') || null } : {}),
|
|
303
322
|
placement: complexity.tier === 'local' ? placeLocalWork(quotaReport.fleet) : null,
|
|
304
323
|
scoring: 'Adapter quality and cost scores are configured priors, adjusted by pass rates measured on this machine.',
|
|
305
324
|
quotaSnapshot: { selectedHarnessStatus: selectedQuota.status || null, selectedHarnessUsage: selectedQuota.usagePercent ?? null, freeMemGB: quotas.system.freeMemGB ?? null },
|
package/src/cli/update.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from '../core/package-auto-update.mjs';
|
|
18
18
|
import { brainDir } from '../core/config.mjs';
|
|
19
19
|
import { installAutoPull } from '../core/autopull-install.mjs';
|
|
20
|
-
import { updateAllBrains } from '../core/brain-updates.mjs';
|
|
20
|
+
import { updateAllBrains, ensureBrainDefaults } from '../core/brain-updates.mjs';
|
|
21
21
|
import { installHarnessHooks } from '../core/harness-hooks.mjs';
|
|
22
22
|
import { automaticUpdateStatus, runAutomaticBrainUpdates } from '../core/automatic-brain-updates.mjs';
|
|
23
23
|
import { runningServerStatus } from '../core/running-server.mjs';
|
|
@@ -38,6 +38,7 @@ export function parseArgs(args) {
|
|
|
38
38
|
automatic: false,
|
|
39
39
|
status: false,
|
|
40
40
|
localOnly: false,
|
|
41
|
+
defaults: false,
|
|
41
42
|
};
|
|
42
43
|
for (let i = 0; i < args.length; i++) {
|
|
43
44
|
const a = args[i];
|
|
@@ -55,6 +56,7 @@ export function parseArgs(args) {
|
|
|
55
56
|
else if (a === '--automatic') opts.automatic = true;
|
|
56
57
|
else if (a === '--status') opts.status = true;
|
|
57
58
|
else if (a === '--local-only') opts.localOnly = true;
|
|
59
|
+
else if (a === '--defaults') opts.defaults = true;
|
|
58
60
|
}
|
|
59
61
|
return opts;
|
|
60
62
|
}
|
|
@@ -85,6 +87,12 @@ function printHelp() {
|
|
|
85
87
|
installed plugin from its recorded source and refresh surfaces. Where a
|
|
86
88
|
per-user service (LaunchAgent, systemd user unit) runs the server from
|
|
87
89
|
the global install, also restart a stale server and verify /health.
|
|
90
|
+
When this run installed a release newer than the running code, that release
|
|
91
|
+
refreshes the defaults with its own code (update --defaults) in the same pass.
|
|
92
|
+
total-recall update --defaults [--repo <path>] [--json]
|
|
93
|
+
Refresh the shared default plugins from this release's bundled copies (a
|
|
94
|
+
linked owner checkout is left alone) and provision every registered brain.
|
|
95
|
+
Exit 1 when any brain fails.
|
|
88
96
|
total-recall update --status [--json]
|
|
89
97
|
Read the actual last automatic maintenance report without starting work.
|
|
90
98
|
Also reports what the server on this host runs against the release.
|
|
@@ -121,6 +129,17 @@ export default async function update(args = []) {
|
|
|
121
129
|
console.log(JSON.stringify(status,null,2));
|
|
122
130
|
return status;
|
|
123
131
|
}
|
|
132
|
+
if (opts.defaults) {
|
|
133
|
+
const report = ensureBrainDefaults({ brainDir, roots: opts.repos });
|
|
134
|
+
if (opts.json) console.log(JSON.stringify(report, null, 2));
|
|
135
|
+
else {
|
|
136
|
+
console.log(`\n Default plugins: ${report.results.length - report.failed} brain(s) ready, ${report.failed} failed`);
|
|
137
|
+
for (const row of report.results) console.log(` ${row.status === 'ready' ? '✓' : '✗'} ${row.root}${row.error ? `: ${row.error}` : ''}`);
|
|
138
|
+
console.log('');
|
|
139
|
+
}
|
|
140
|
+
if (report.failed) process.exitCode = 1;
|
|
141
|
+
return report;
|
|
142
|
+
}
|
|
124
143
|
if(opts.automatic){
|
|
125
144
|
const report=await runAutomaticBrainUpdates({brainDir,force:opts.force,fleet:!opts.localOnly});
|
|
126
145
|
console.log(JSON.stringify(report,null,2));
|
|
@@ -14,6 +14,7 @@ import { updateSourceBrainPackages } from './source-brain-updates.mjs';
|
|
|
14
14
|
import { updateInstalledPlugins } from './plugin-updates.mjs';
|
|
15
15
|
import { runningServerStatus } from './running-server.mjs';
|
|
16
16
|
import { replaceStaleServer } from './server-restart.mjs';
|
|
17
|
+
import { handOffDefaultsToRelease } from './release-defaults-handoff.mjs';
|
|
17
18
|
|
|
18
19
|
export function automaticUpdateStatus(brainDir) {
|
|
19
20
|
let last;
|
|
@@ -38,7 +39,7 @@ export function startAutomaticBrainUpdates({brainDir,intervalMs=60*60*1000,run=r
|
|
|
38
39
|
return {tick,stop(){stopped=true;clearInterval(timer);}};
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
export async function runAutomaticBrainUpdates({brainDir,fleet=false,force=false,skipThrottle=false,intervalMs=60*60*1000,update=updateAllBrains,compile=maintainCliSurfaces,globalUpdate=updateGlobalBrainPackages,meshUpdate=updateMeshBrainPackages,sourceUpdate=updateSourceBrainPackages,pluginUpdate=updateInstalledPlugins,skillMaintenance=maintainPortableSkills,serverCheck=runningServerStatus,replaceServer=replaceStaleServer,now=Date.now}={}) {
|
|
42
|
+
export async function runAutomaticBrainUpdates({brainDir,fleet=false,force=false,skipThrottle=false,intervalMs=60*60*1000,update=updateAllBrains,compile=maintainCliSurfaces,globalUpdate=updateGlobalBrainPackages,meshUpdate=updateMeshBrainPackages,sourceUpdate=updateSourceBrainPackages,pluginUpdate=updateInstalledPlugins,defaultsHandoff=handOffDefaultsToRelease,skillMaintenance=maintainPortableSkills,serverCheck=runningServerStatus,replaceServer=replaceStaleServer,now=Date.now}={}) {
|
|
42
43
|
if(!force && !isPackageAutoUpdateEnabled())return {skipped:true,reason:'disabled',results:[]};
|
|
43
44
|
const file=path.join(brainDir,'memory-derived/automatic-brain-updates.json');
|
|
44
45
|
let saved;try{saved=JSON.parse(fs.readFileSync(file,'utf8'));}catch{}
|
|
@@ -62,10 +63,13 @@ export async function runAutomaticBrainUpdates({brainDir,fleet=false,force=false
|
|
|
62
63
|
const summary=await update({brainDir,dryRun:false,ensureDefaults:true});
|
|
63
64
|
const global=summary.versions?await globalUpdate({versions:summary.versions}):{failed:1,results:[],error:'Cannot maintain global packages without verified published versions'};
|
|
64
65
|
const sources=summary.versions?await sourceUpdate({brainDir,versions:summary.versions}):{failed:1,results:[]};
|
|
66
|
+
// The defaults above came from the running code, which is the release being replaced. A newer
|
|
67
|
+
// release installed just now refreshes them with its own code and bundled copies, this pass.
|
|
68
|
+
const defaults=await defaultsHandoff({global,sources});
|
|
65
69
|
// The shared plugin folder follows the source each plugin was installed from (git, mesh peer, directory).
|
|
66
70
|
const plugins=await pluginUpdate({roots:[path.resolve(brainDir,'../../..')]});
|
|
67
71
|
const mesh=fleet && summary.versions?await meshUpdate({versions:summary.versions,version:summary.versions['total-recall-brain']}):{failed:0,results:[],scope:'local-only'};
|
|
68
|
-
const changed=summary.updated>0 || plugins.updated>0 || global.results.some(row=>row.status==='updated') || sources.results.some(row=>row.status==='updated') || JSON.stringify(saved?.versions)!==JSON.stringify(summary.versions);
|
|
72
|
+
const changed=summary.updated>0 || plugins.updated>0 || defaults.status==='refreshed' || global.results.some(row=>row.status==='updated') || sources.results.some(row=>row.status==='updated') || JSON.stringify(saved?.versions)!==JSON.stringify(summary.versions);
|
|
69
73
|
const skills=await skillMaintenance({brainDir});
|
|
70
74
|
const surfaces=await compile({brainDir,force:changed || skills.updated > 0});
|
|
71
75
|
// Last, so a replaced server starts on everything this run moved. Where maintenance cannot
|
|
@@ -79,8 +83,8 @@ export async function runAutomaticBrainUpdates({brainDir,fleet=false,force=false
|
|
|
79
83
|
// local_failed is this node's own maintenance. Peers judge a node by it, not by
|
|
80
84
|
// `failed`, which also counts the node's view of its peers: judging by that made
|
|
81
85
|
// every fleet pass mark every peer failed forever, each because the others had.
|
|
82
|
-
const local_failed=(summary.failed || 0)+global.failed+sources.failed+plugins.failed+(skills.failed || 0)+(surfaces.ok?0:surfaces.results.filter(row=>row.status==='failed').length)+serverFailed;
|
|
83
|
-
report={...summary,global,sources,plugins,mesh,server,skills,surfaces,local_failed,failed:local_failed+mesh.failed,checkedAt:now()};
|
|
86
|
+
const local_failed=(summary.failed || 0)+global.failed+sources.failed+(defaults.status==='failed'?1:0)+plugins.failed+(skills.failed || 0)+(surfaces.ok?0:surfaces.results.filter(row=>row.status==='failed').length)+serverFailed;
|
|
87
|
+
report={...summary,global,sources,defaults,plugins,mesh,server,skills,surfaces,local_failed,failed:local_failed+mesh.failed,checkedAt:now()};
|
|
84
88
|
}catch(error){report={failed:1,error:error.message,checkedAt:now(),results:[]};}
|
|
85
89
|
const temporary=`${file}.${process.pid}.tmp`;fs.writeFileSync(temporary,JSON.stringify(report,null,2));fs.renameSync(temporary,file);
|
|
86
90
|
return report;
|
|
@@ -217,18 +217,37 @@ export function installEnv(env = process.env) {
|
|
|
217
217
|
return kept;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/** The brains maintenance provisions: each update root that has a brain, plus the selected brain. */
|
|
221
|
+
function maintainedBrains(roots, brainDir) {
|
|
222
|
+
const brains = roots.map(root => ({ root, brain: path.join(root, '.agent', 'skills', 'total-recall') }))
|
|
223
|
+
.filter(({ brain }) => fs.existsSync(brain));
|
|
224
|
+
if (brainDir) brains.push({ root: brainDir, brain: brainDir });
|
|
225
|
+
return brains;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Refresh the shared default plugins from this package's bundled copies and provision every
|
|
230
|
+
* registered brain, reporting each brain's outcome instead of throwing. `update --defaults` runs
|
|
231
|
+
* this, so a release maintenance has just installed can do it with its own code and its own
|
|
232
|
+
* bundled copies (release-defaults-handoff.mjs).
|
|
233
|
+
*/
|
|
234
|
+
export function ensureBrainDefaults(opts = {}, { ensure = ensureRequiredPlugins } = {}) {
|
|
235
|
+
const roots = listUpdateRoots(opts).map(row => row.root);
|
|
236
|
+
const results = maintainedBrains(roots, opts.brainDir).map(({ root, brain }) => {
|
|
237
|
+
try { ensure(brain); return { root, status: 'ready' }; }
|
|
238
|
+
catch (error) { return { root, status: 'failed', error: error.message }; }
|
|
239
|
+
});
|
|
240
|
+
return { failed: results.filter(row => row.status === 'failed').length, results };
|
|
241
|
+
}
|
|
242
|
+
|
|
220
243
|
export async function updateAllBrains(opts = {}) {
|
|
221
244
|
const dryRun = opts.dryRun !== false;
|
|
222
245
|
const installMissing = opts.installMissing === true;
|
|
223
246
|
const exec = opts.exec || spawnSync;
|
|
224
247
|
const roots = listUpdateRoots(opts).map(row => row.root);
|
|
225
248
|
const errors = [];
|
|
226
|
-
const brains = roots
|
|
227
|
-
|
|
228
|
-
if (opts.brainDir) {
|
|
229
|
-
brains.push({ root: opts.brainDir, brain: opts.brainDir });
|
|
230
|
-
roots.push(opts.brainDir);
|
|
231
|
-
}
|
|
249
|
+
const brains = maintainedBrains(roots, opts.brainDir);
|
|
250
|
+
if (opts.brainDir) roots.push(opts.brainDir);
|
|
232
251
|
for (const { root, brain } of brains) {
|
|
233
252
|
if (!dryRun && opts.ensureDefaults === true) {
|
|
234
253
|
try { ensureRequiredPlugins(brain); }
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic maintenance refreshes the default plugins before it installs a new release: the code
|
|
3
|
+
* that runs it is the old release, and so are its bundled copies. A default is replaced only by a
|
|
4
|
+
* higher bundled version, so that pass changed nothing and a release's defaults arrived one pass
|
|
5
|
+
* (about an hour) later. After the global and source updates, the newest installed release of this
|
|
6
|
+
* package refreshes the defaults itself, with its own code and its own bundled copies.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { PACKAGE_ROOT } from './plugin-loader.mjs';
|
|
11
|
+
import { comparePackageVersions } from './package-version.mjs';
|
|
12
|
+
import { findLinkedSourceCheckout } from './global-brain-updates.mjs';
|
|
13
|
+
import { runBounded } from './startup-health.mjs';
|
|
14
|
+
|
|
15
|
+
const PACKAGE = 'total-recall-brain';
|
|
16
|
+
|
|
17
|
+
function readVersion(dir) {
|
|
18
|
+
try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version || null; } catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The release this process runs, read once at import. A source update can fast-forward this
|
|
23
|
+
* checkout during the run, so reading the file again later would report the new release.
|
|
24
|
+
*/
|
|
25
|
+
export const RUNNING_VERSION = readVersion(PACKAGE_ROOT);
|
|
26
|
+
|
|
27
|
+
/** Installed releases this run may have moved: the global install and source checkouts it updated. */
|
|
28
|
+
export function installedReleases({ global, sources } = {}) {
|
|
29
|
+
const dirs = new Set();
|
|
30
|
+
const add = dir => { try { dirs.add(fs.realpathSync(dir)); } catch {} };
|
|
31
|
+
if (global?.root) {
|
|
32
|
+
const direct = path.join(global.root, PACKAGE);
|
|
33
|
+
const dir = fs.existsSync(direct) ? direct : findLinkedSourceCheckout(global.root, PACKAGE);
|
|
34
|
+
if (dir) add(dir);
|
|
35
|
+
}
|
|
36
|
+
for (const row of sources?.results || []) {
|
|
37
|
+
if (row?.package === PACKAGE && row.status === 'updated' && row.root) add(row.root);
|
|
38
|
+
}
|
|
39
|
+
return [...dirs]
|
|
40
|
+
.map(dir => ({ dir, version: readVersion(dir) }))
|
|
41
|
+
.filter(release => release.version && fs.existsSync(path.join(release.dir, 'bin', 'total-recall.mjs')));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The JSON report `update --defaults --json` prints, even after other output on stdout. */
|
|
45
|
+
export function parseDefaultsReport(text) {
|
|
46
|
+
const trimmed = String(text || '').trim();
|
|
47
|
+
try { return JSON.parse(trimmed); } catch {}
|
|
48
|
+
const start = trimmed.lastIndexOf('\n{\n');
|
|
49
|
+
if (start >= 0) { try { return JSON.parse(trimmed.slice(start + 1)); } catch {} }
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* When an installed release is newer than the running code, run its `update --defaults` and report
|
|
55
|
+
* what it did. Never throws: the outcome is a row in the maintenance report.
|
|
56
|
+
* @returns {Promise<{status:'current'|'refreshed'|'failed', running?:string|null, installed?:string, version?:string, from?:string|null, results?:object[], error?:string}>}
|
|
57
|
+
*/
|
|
58
|
+
export async function handOffDefaultsToRelease({ global, sources, cwd = process.cwd(), running = RUNNING_VERSION,
|
|
59
|
+
execPath = process.execPath, run = runBounded, timeoutMs = 10 * 60 * 1000 } = {}) {
|
|
60
|
+
let newest = null;
|
|
61
|
+
try {
|
|
62
|
+
for (const release of installedReleases({ global, sources })) {
|
|
63
|
+
if (!newest || comparePackageVersions(release.version, newest.version) === 1) newest = release;
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return { status: 'failed', running, error: `Installed releases could not be read: ${error.message}` };
|
|
67
|
+
}
|
|
68
|
+
if (!newest || comparePackageVersions(newest.version, running) !== 1) {
|
|
69
|
+
return { status: 'current', running, ...(newest ? { installed: newest.version } : {}) };
|
|
70
|
+
}
|
|
71
|
+
const handoff = { version: newest.version, from: running };
|
|
72
|
+
let result;
|
|
73
|
+
try {
|
|
74
|
+
result = await run(execPath, [path.join(newest.dir, 'bin', 'total-recall.mjs'), 'update', '--defaults', '--json'],
|
|
75
|
+
{ cwd, timeoutMs, maxBytes: 1024 * 1024 });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return { status: 'failed', ...handoff, error: `update --defaults could not run: ${error.message}` };
|
|
78
|
+
}
|
|
79
|
+
const report = parseDefaultsReport(result?.output);
|
|
80
|
+
if (result?.code === 0 && report && report.failed === 0) {
|
|
81
|
+
return { status: 'refreshed', ...handoff, results: report.results || [] };
|
|
82
|
+
}
|
|
83
|
+
const failures = (report?.results || []).filter(row => row.status === 'failed').map(row => `${row.root}: ${row.error}`);
|
|
84
|
+
const error = result?.reason ? `update --defaults stopped: ${result.reason}`
|
|
85
|
+
: failures.length ? failures.join('; ')
|
|
86
|
+
: !report ? `update --defaults printed no report (exit ${result?.code ?? 'unknown'}): ${String(result?.stderr || result?.output || '').trim().slice(-1000)}`
|
|
87
|
+
: `update --defaults exited ${result?.code}`;
|
|
88
|
+
return { status: 'failed', ...handoff, error, ...(report?.results ? { results: report.results } : {}) };
|
|
89
|
+
}
|