total-recall-brain 3.40.7 → 3.41.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-memory.mjs +0 -0
- package/bin/total-recall.mjs +9 -40
- package/docs/reference/cli-reference.md +65 -1
- package/frontend/dist/assets/{index-AGUes-VG.css → index-BG9NkVUy.css} +1 -1
- package/frontend/dist/assets/{index-Bt5iVPZj.js → index-BtuiZqwP.js} +20 -7
- package/frontend/dist/index.html +2 -2
- package/frontend/src/components/plugins/PluginPanel.tsx +31 -5
- package/frontend/src/components/plugins/card/CardEngine.tsx +15 -23
- package/frontend/src/components/plugins/card/CardScenes.tsx +52 -23
- package/frontend/src/components/plugins/card/card.css +2 -5
- package/frontend/src/components/plugins/card/contract.ts +13 -19
- package/frontend/src/components/plugins/card/format.ts +8 -1
- package/frontend/src/components/plugins/card/widgets.tsx +4 -35
- package/frontend/src/components/plugins/plugin-frame.ts +30 -3
- package/frontend/src/pages/PluginsPage.tsx +3 -2
- package/package.json +1 -1
- package/scaffold/.agent/skills/plugin/SKILL.md +110 -0
- package/scaffold/.agent/skills/plugin/evals/README.md +53 -0
- package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +42 -0
- package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +39 -0
- package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +32 -0
- package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +46 -0
- package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +55 -0
- package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +92 -0
- package/scaffold/.agent/skills/plugin/evals/run.mjs +118 -0
- package/scaffold/.agent/skills/plugin/prompts/01-function.md +29 -0
- package/scaffold/.agent/skills/plugin/prompts/02-surfaces.md +43 -0
- package/scaffold/.agent/skills/plugin/prompts/03-scaffold.md +31 -0
- package/scaffold/.agent/skills/plugin/prompts/04-cli.md +49 -0
- package/scaffold/.agent/skills/plugin/prompts/05-credentials.md +41 -0
- package/scaffold/.agent/skills/plugin/prompts/06-card.md +46 -0
- package/scaffold/.agent/skills/plugin/prompts/07-scaffold.md +52 -0
- package/scaffold/.agent/skills/plugin/prompts/08-verify.md +43 -0
- package/scaffold/.agent/skills/plugin/references/anti-patterns.md +123 -0
- package/scaffold/.agent/skills/plugin/references/card.md +90 -0
- package/scaffold/.agent/skills/plugin/references/scaffold.md +120 -0
- package/scaffold/.agent/skills/plugin/references/surfaces.md +206 -0
- package/scaffold/.agent/skills/plugin/scripts/record.mjs +360 -0
- package/scaffold/.agent/skills/plugin/subagents/card-designer.md +60 -0
- package/scaffold/.agent/skills/plugin/subagents/plugin-reviewer.md +55 -0
- package/scaffold/.agent/skills/plugin/subagents/scaffold-extractor.md +71 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +71 -1441
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +7 -5
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +3 -44
- package/scaffold/.agent/skills/total-recall/plugins/auth/scripts/agent-login-pty.py +185 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +20 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/host-secrets-store.mjs +15 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +6 -4
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +1 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -24
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +0 -21
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +0 -32
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +11 -0
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +5 -2
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +1 -28
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/DESIGN.md +95 -13
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +553 -340
- package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +0 -36
- package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +383 -400
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +0 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +0 -28
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +0 -32
- package/scaffold/.agent/skills/total-recall/references/auth.md +3 -1
- package/scaffold/.agent/skills/total-recall/references/memory.md +4 -0
- package/scaffold/.agent/skills/total-recall/references/openwiki.md +2 -0
- package/scaffold/.agent/skills/total-recall/references/operations.md +12 -0
- package/src/cli/brain.mjs +4 -2
- package/src/cli/context-stats.mjs +104 -0
- package/src/cli/context.mjs +8 -0
- package/src/cli/daemon.mjs +10 -1
- package/src/cli/export.mjs +3 -2
- package/src/cli/ingest-okf.mjs +12 -15
- package/src/cli/init.mjs +0 -85
- package/src/cli/lint.mjs +3 -2
- package/src/cli/plugin/copies.mjs +68 -0
- package/src/cli/plugin/create.mjs +22 -24
- package/src/cli/plugin/index.mjs +49 -3
- package/src/cli/plugin/search.mjs +19 -2
- package/src/cli/rebuild.mjs +2 -0
- package/src/cli/remember.mjs +23 -0
- package/src/cli/status.mjs +10 -3
- package/src/cli/update.mjs +3 -2
- package/src/core/action-predictor.mjs +161 -0
- package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +20 -3
- package/src/core/app-deploy/source.mjs +16 -9
- package/src/core/automatic-brain-updates.mjs +6 -3
- package/src/core/brain-registry.mjs +19 -2
- package/src/core/brain-state.mjs +23 -0
- package/src/core/cli-commands.mjs +1 -0
- package/src/core/cli-maintenance.mjs +1 -1
- package/src/core/cli-semantic-index.mjs +1 -1
- package/src/core/command-surface.mjs +3 -3
- package/src/core/composed-command-resolution.mjs +21 -0
- package/src/core/config.mjs +84 -0
- package/src/core/confined-memory.mjs +93 -0
- package/src/core/context-telemetry.mjs +214 -0
- package/src/core/daemon-control.mjs +14 -43
- package/src/core/fast-recall.mjs +1 -1
- package/src/core/memory-brain.mjs +2 -0
- package/src/core/plugin-activation.mjs +38 -6
- package/src/core/plugin-bundle.mjs +54 -0
- package/src/core/plugin-catalog.mjs +1 -1
- package/src/core/plugin-context.mjs +1 -1
- package/src/core/plugin-contracts/card.mjs +69 -37
- package/src/core/plugin-loader.mjs +5 -6
- package/src/core/plugin-runner.mjs +13 -6
- package/src/core/plugin-runtime.mjs +5 -4
- package/src/core/plugin-setup.mjs +5 -3
- package/src/core/plugin-state-store.mjs +12 -1
- package/src/core/plugin-state.mjs +46 -5
- package/src/core/plugin-store.mjs +78 -3
- package/src/core/plugin-tasks.mjs +3 -1
- package/src/core/plugin-updates.mjs +117 -0
- package/src/core/plugin-vendor.mjs +10 -4
- package/src/core/project-brain.mjs +14 -41
- package/src/core/project-id.mjs +4 -1
- package/src/core/required-plugins.mjs +151 -13
- package/src/core/skills-registry.mjs +27 -3
- package/src/core/ssss-kernel-bridge.mjs +3 -1
- package/src/core/startup-health.mjs +66 -16
- package/src/core/surface.mjs +51 -44
- package/src/core/testing/plugin-fixture.mjs +69 -0
- package/src/core/vault-files.mjs +13 -0
- package/src/core/vault.mjs +4 -30
- package/src/server/index.mjs +13 -1
- package/src/server/invoke-decision-child.mjs +49 -0
- package/src/server/invoke-execution.mjs +87 -0
- package/src/server/invoke-policy.mjs +64 -0
- package/src/server/invoke-read-child.mjs +26 -0
- package/src/server/invoke-targets.mjs +49 -0
- package/src/server/invoke-write-child.mjs +19 -0
- package/src/server/routes/invoke.mjs +314 -0
- package/src/server/routes/plugins.mjs +1 -1
- package/scaffold/.agent/skills/plugins/SKILL.md +0 -57
- package/scaffold/.agent/skills/plugins/references/card-design.md +0 -55
- package/scaffold/.agent/skills/plugins/references/distribution.md +0 -23
- package/scaffold/.agent/skills/plugins/references/manifest-contract.md +0 -90
- package/scaffold/.agent/skills/plugins/references/optimized/index.md +0 -9
- package/scaffold/.agent/skills/plugins/references/optimized/manifest.json +0 -35
- package/scaffold/.agent/skills/plugins/references/optimized/original.md +0 -58
- package/scaffold/.agent/skills/plugins/references/optimized/section-01.md +0 -5
- package/scaffold/.agent/skills/plugins/references/optimized/section-02.md +0 -25
- package/scaffold/.agent/skills/plugins/references/optimized/section-03.md +0 -12
- package/scaffold/.agent/skills/plugins/references/optimized/section-04.md +0 -7
- package/scaffold/.agent/skills/plugins/references/optimized/section-05.md +0 -5
- package/scaffold/.agent/skills/total-recall/openwiki/README.md +0 -10
- package/scaffold/.agent/skills/total-recall/openwiki/architecture.md +0 -5
- package/scaffold/.agent/skills/total-recall/openwiki/ide.md +0 -10
- package/scaffold/.agent/skills/total-recall/openwiki/memory.md +0 -11
- package/scaffold/.agent/skills/total-recall/openwiki/secrets.md +0 -7
- package/scaffold/.agent/skills/total-recall/openwiki/skills.md +0 -7
- package/scaffold/openwiki/README.md +0 -10
- package/scaffold/openwiki/architecture.md +0 -5
- package/scaffold/openwiki/ide.md +0 -10
- package/scaffold/openwiki/memory.md +0 -11
- package/scaffold/openwiki/secrets.md +0 -7
- package/scaffold/openwiki/skills.md +0 -7
- package/src/core/okf-adapter.mjs +0 -559
- package/templates/openwiki/README.md +0 -10
- package/templates/openwiki/architecture.md +0 -5
- package/templates/openwiki/ide.md +0 -10
- package/templates/openwiki/memory.md +0 -11
- package/templates/openwiki/secrets.md +0 -7
- package/templates/openwiki/skills.md +0 -7
|
@@ -10,6 +10,7 @@ import { readProcessCommand, shouldHonorPidLock } from './pid-lock.mjs';
|
|
|
10
10
|
import { updateGlobalBrainPackages } from './global-brain-updates.mjs';
|
|
11
11
|
import { updateMeshBrainPackages } from './mesh-brain-updates.mjs';
|
|
12
12
|
import { updateSourceBrainPackages } from './source-brain-updates.mjs';
|
|
13
|
+
import { updateInstalledPlugins } from './plugin-updates.mjs';
|
|
13
14
|
|
|
14
15
|
export function automaticUpdateStatus(brainDir) {
|
|
15
16
|
let last;
|
|
@@ -34,7 +35,7 @@ export function startAutomaticBrainUpdates({brainDir,intervalMs=60*60*1000,run=r
|
|
|
34
35
|
return {tick,stop(){stopped=true;clearInterval(timer);}};
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
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,now=Date.now}={}) {
|
|
38
|
+
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,now=Date.now}={}) {
|
|
38
39
|
if(!force && !isPackageAutoUpdateEnabled())return {skipped:true,reason:'disabled',results:[]};
|
|
39
40
|
const file=path.join(brainDir,'memory-derived/automatic-brain-updates.json');
|
|
40
41
|
let saved;try{saved=JSON.parse(fs.readFileSync(file,'utf8'));}catch{}
|
|
@@ -56,10 +57,12 @@ export async function runAutomaticBrainUpdates({brainDir,fleet=false,force=false
|
|
|
56
57
|
const summary=await update({brainDir,dryRun:false,ensureDefaults:true});
|
|
57
58
|
const global=summary.versions?await globalUpdate({versions:summary.versions}):{failed:1,results:[],error:'Cannot maintain global packages without verified published versions'};
|
|
58
59
|
const sources=summary.versions?await sourceUpdate({brainDir,versions:summary.versions}):{failed:1,results:[]};
|
|
60
|
+
// The shared plugin folder follows the source each plugin was installed from (git, mesh peer, directory).
|
|
61
|
+
const plugins=await pluginUpdate({roots:[path.resolve(brainDir,'../../..')]});
|
|
59
62
|
const mesh=fleet && summary.versions?await meshUpdate({versions:summary.versions,version:summary.versions['total-recall-brain']}):{failed:0,results:[],scope:'local-only'};
|
|
60
|
-
const changed=summary.updated>0 || global.results.some(row=>row.status==='updated') || sources.results.some(row=>row.status==='updated') || JSON.stringify(saved?.versions)!==JSON.stringify(summary.versions);
|
|
63
|
+
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);
|
|
61
64
|
const surfaces=await compile({brainDir,force:changed});
|
|
62
|
-
report={...summary,global,sources,mesh,surfaces,failed:(summary.failed || 0)+global.failed+sources.failed+mesh.failed+(surfaces.ok?0:surfaces.results.filter(row=>row.status==='failed').length),checkedAt:now()};
|
|
65
|
+
report={...summary,global,sources,plugins,mesh,surfaces,failed:(summary.failed || 0)+global.failed+sources.failed+plugins.failed+mesh.failed+(surfaces.ok?0:surfaces.results.filter(row=>row.status==='failed').length),checkedAt:now()};
|
|
63
66
|
}catch(error){report={failed:1,error:error.message,checkedAt:now(),results:[]};}
|
|
64
67
|
const temporary=`${file}.${process.pid}.tmp`;fs.writeFileSync(temporary,JSON.stringify(report,null,2));fs.renameSync(temporary,file);
|
|
65
68
|
return report;
|
|
@@ -40,9 +40,26 @@ function readJson(file, fallback) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Authorization consumers must distinguish missing metadata from corruption. */
|
|
44
|
+
function readStrictJson(file, fallback) {
|
|
45
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
46
|
+
catch (err) { if (err.code === 'ENOENT') return fallback; throw new Error('Brain registry metadata is unavailable'); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readRegisteredProjects(globalBrainDir = globalBrainDirFor()) {
|
|
50
|
+
const rows = readStrictJson(registryPath(globalBrainDir), []);
|
|
51
|
+
if (!Array.isArray(rows) || rows.some(row => !row || typeof row !== 'object' || Array.isArray(row))) {
|
|
52
|
+
throw new Error('Invalid project registry');
|
|
53
|
+
}
|
|
54
|
+
return rows;
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
/** `{ [resolvedBrainDir]: false }` for every brain switched off. */
|
|
44
|
-
export function readBrainToggles(globalBrainDir = globalBrainDirFor()) {
|
|
45
|
-
const data = readJson(togglesPath(globalBrainDir), {});
|
|
58
|
+
export function readBrainToggles(globalBrainDir = globalBrainDirFor(), { strict = false } = {}) {
|
|
59
|
+
const data = strict ? readStrictJson(togglesPath(globalBrainDir), {}) : readJson(togglesPath(globalBrainDir), {});
|
|
60
|
+
if (strict && (!data || typeof data !== 'object' || Array.isArray(data) || Object.values(data).some(v => typeof v !== 'boolean'))) {
|
|
61
|
+
throw new Error('Invalid brain toggles');
|
|
62
|
+
}
|
|
46
63
|
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
47
64
|
}
|
|
48
65
|
|
package/src/core/brain-state.mjs
CHANGED
|
@@ -43,9 +43,32 @@ export const BRAIN_STATE = load();
|
|
|
43
43
|
* @param {string} relPath e.g. "references/api-reference.md"
|
|
44
44
|
* @returns {boolean}
|
|
45
45
|
*/
|
|
46
|
+
/**
|
|
47
|
+
* Bundled plugin SOURCE inside the shipped scaffold is a template, not state.
|
|
48
|
+
*
|
|
49
|
+
* `plugins/` is state because an installed plugin directory is one brain's own
|
|
50
|
+
* collection. But the scaffold ships bundled plugin source at
|
|
51
|
+
* `scaffold/.agent/skills/total-recall/plugins/<id>/`, and `.gitignore` states
|
|
52
|
+
* plainly that it must stay in git. Matching the `plugins` segment anywhere in
|
|
53
|
+
* the path therefore flagged the template itself and failed the release gate.
|
|
54
|
+
* The exemption is anchored to the scaffold's own brain path, so an installed
|
|
55
|
+
* or brain-local `plugins/` directory is still state.
|
|
56
|
+
*/
|
|
57
|
+
function isScaffoldBundledPluginSource(parts) {
|
|
58
|
+
const at = parts.lastIndexOf('scaffold');
|
|
59
|
+
if (at === -1) return false;
|
|
60
|
+
const rest = parts.slice(at + 1);
|
|
61
|
+
return rest[0] === '.agent'
|
|
62
|
+
&& rest[1] === 'skills'
|
|
63
|
+
&& rest[2] === 'total-recall'
|
|
64
|
+
&& rest[3] === 'plugins'
|
|
65
|
+
&& rest.length > 4;
|
|
66
|
+
}
|
|
67
|
+
|
|
46
68
|
export function isBrainState(relPath) {
|
|
47
69
|
if (!relPath) return false;
|
|
48
70
|
const parts = String(relPath).split(/[\\/]+/).filter(Boolean);
|
|
71
|
+
if (isScaffoldBundledPluginSource(parts)) return false;
|
|
49
72
|
if (parts.some((seg) => BRAIN_STATE.dirs.has(seg))) return true;
|
|
50
73
|
const base = parts[parts.length - 1];
|
|
51
74
|
if (!base) return false;
|
|
@@ -29,7 +29,7 @@ export async function maintainCliSurfaces({brainDir,brains,force=false,run=runBo
|
|
|
29
29
|
const current=fingerprint(root);
|
|
30
30
|
if(!force && previous?.fingerprint===current){results.push({root,status:'current'});continue;}
|
|
31
31
|
const result=await run(process.execPath,[path.join(PACKAGE_ROOT,'bin/total-recall.mjs'),'plugin','refresh',...(brain.kind==='global'?['--global']:[])],{cwd:root,timeoutMs:600000,maxBytes:262144});
|
|
32
|
-
if(result.code!==0){results.push({root,status:'failed',error:result.reason || result.output?.slice(-1000) ||
|
|
32
|
+
if(result.code!==0){results.push({root,status:'failed',error:result.reason || (result.stderr || result.output)?.trim().slice(-1000) || `CLI compilation failed (exit ${result.code})`});continue;}
|
|
33
33
|
fs.mkdirSync(path.dirname(file),{recursive:true});
|
|
34
34
|
const temp=`${file}.${process.pid}.tmp`;fs.writeFileSync(temp,JSON.stringify({fingerprint:current,compiledAt:new Date().toISOString()}));fs.renameSync(temp,file);
|
|
35
35
|
results.push({root,status:'refreshed'});
|
|
@@ -30,7 +30,7 @@ const CORE_METADATA = {
|
|
|
30
30
|
effect: 'read'
|
|
31
31
|
},
|
|
32
32
|
init: {
|
|
33
|
-
description: 'Bootstrap global or project brain
|
|
33
|
+
description: 'Bootstrap a portable global or project memory brain.',
|
|
34
34
|
usage: 'total-recall init [--project]',
|
|
35
35
|
effect: 'write'
|
|
36
36
|
},
|
|
@@ -16,10 +16,10 @@ const NAME = /^[a-z][a-z0-9-]{0,63}$/;
|
|
|
16
16
|
const MAX_DESCRIPTION = 180;
|
|
17
17
|
|
|
18
18
|
/** Static scope metadata shared by catalogs and instruction projections. */
|
|
19
|
-
export function commandScopeDirectories(projectRoot, agentDir) {
|
|
20
|
-
const dirs = [{ scope: 'project', dir: path.join(
|
|
19
|
+
export function commandScopeDirectories(projectRoot, agentDir, { projectAgentDir = path.join(projectRoot, '.agent') } = {}) {
|
|
20
|
+
const dirs = projectAgentDir ? [{ scope: 'project', dir: path.join(projectAgentDir, 'commands') }] : [];
|
|
21
21
|
let projectId;
|
|
22
|
-
try { projectId = JSON.parse(fs.readFileSync(path.join(
|
|
22
|
+
try { if (projectAgentDir) projectId = JSON.parse(fs.readFileSync(path.join(projectAgentDir, 'skills/total-recall/config/brain.json'), 'utf8')).project_id; } catch {}
|
|
23
23
|
const groups = path.join(agentDir, 'skills/total-recall/groups');
|
|
24
24
|
if (typeof projectId === 'string' && projectId) {
|
|
25
25
|
let files = [];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { globalAgentDir, resolveBrainLayer } from './config.mjs';
|
|
4
|
+
import { commandScopeDirectories } from './command-surface.mjs';
|
|
5
|
+
|
|
6
|
+
/** Execution and invoke discovery use the same selected brain as the CLI. */
|
|
7
|
+
export function composedCommandDirectories(target = process.cwd()) {
|
|
8
|
+
let projectAgentDir = null;
|
|
9
|
+
try { projectAgentDir = resolveBrainLayer('project', undefined, target).agentDir; } catch {}
|
|
10
|
+
return commandScopeDirectories(target, globalAgentDir, { projectAgentDir });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Resolve statically; importing a candidate is an execution boundary. */
|
|
14
|
+
export function resolveComposedCommand(name, dirs = composedCommandDirectories()) {
|
|
15
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(name)) return null;
|
|
16
|
+
for (const { dir } of dirs) {
|
|
17
|
+
const file = path.join(dir, `${name}.mjs`);
|
|
18
|
+
try { if (fs.statSync(file).isFile()) return file; } catch {}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
package/src/core/config.mjs
CHANGED
|
@@ -257,8 +257,27 @@ export function detectProjectBrain(startDir = process.cwd()) {
|
|
|
257
257
|
|
|
258
258
|
let dir = startDir;
|
|
259
259
|
const homeDir = os.homedir();
|
|
260
|
+
// Resolved live rather than from the module constant above: the agent dir can
|
|
261
|
+
// be overridden per process, and the guard must follow the folder actually in
|
|
262
|
+
// use.
|
|
263
|
+
const agentDirLive = process.env.AGENT_DIR || process.env._TR_TEST_AGENT_DIR || path.join(homeDir, '.agent');
|
|
264
|
+
const pluginsRoot = path.join(agentDirLive, 'skills', 'total-recall', 'plugins');
|
|
265
|
+
// A shared plugin is either a directory inside the shared folder or a symlink
|
|
266
|
+
// to its owner checkout. Working inside one — directly or through the link —
|
|
267
|
+
// must never be treated as working in a project, or the plugin provisions a
|
|
268
|
+
// complete nested brain into itself, one level deeper on every run. The owner
|
|
269
|
+
// checkout's TOP LEVEL is the one exception: that is the plugin's repository,
|
|
270
|
+
// registered and edited in its own right.
|
|
271
|
+
const targets = pluginTargets(pluginsRoot);
|
|
272
|
+
const reachedThroughPlugin = isInsideSharedFolder(startDir, pluginsRoot, targets);
|
|
260
273
|
while (dir !== path.dirname(dir)) {
|
|
261
274
|
if (dir === homeDir) break; // don't detect global as project
|
|
275
|
+
if (dir === pluginsRoot || dir.startsWith(pluginsRoot + path.sep)) break;
|
|
276
|
+
// A plugin's own checkout stops the walk unless it is a registered project.
|
|
277
|
+
// The shared folder links every plugin to its owner repository, and those
|
|
278
|
+
// repositories are registered and worked in directly, so reaching one is
|
|
279
|
+
// only "a project" when the registry says it is.
|
|
280
|
+
if (reachedThroughPlugin && targets.has(fs.existsSync(dir) ? safeRealpath(dir) : dir) && !isRegisteredProject(dir)) break;
|
|
262
281
|
// Both `.agent` (canonical) and `.agents` (the upstream harness convention)
|
|
263
282
|
// are supported. This function used to check only `.agent` while its former
|
|
264
283
|
// sibling in src/cli/agent-dir.mjs checked both, so the two could disagree
|
|
@@ -283,6 +302,71 @@ export function detectProjectBrain(startDir = process.cwd()) {
|
|
|
283
302
|
return null;
|
|
284
303
|
}
|
|
285
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Whether a starting path belongs to a shared plugin rather than to a repository
|
|
307
|
+
* in its own right.
|
|
308
|
+
*
|
|
309
|
+
* True when the caller's path lies beneath the shared folder literally, or when
|
|
310
|
+
* it resolves into one of the folder's link targets — including the target root,
|
|
311
|
+
* because a plugin that resolved its own directory asks from exactly there. The
|
|
312
|
+
* registry is what separates that plugin's own repository from a plugin install;
|
|
313
|
+
* this function only reports where the caller came from.
|
|
314
|
+
*/
|
|
315
|
+
function isInsideSharedFolder(startDir, pluginsRoot, targets) {
|
|
316
|
+
const candidates = [startDir, safeRealpath(startDir)].filter(Boolean);
|
|
317
|
+
for (const candidate of candidates) {
|
|
318
|
+
if (candidate === pluginsRoot || candidate.startsWith(pluginsRoot + path.sep)) return true;
|
|
319
|
+
for (const target of targets) {
|
|
320
|
+
if (candidate === target || candidate.startsWith(target + path.sep)) return true;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Whether this directory is a project the operator registered, read from the
|
|
328
|
+
* brain registry. A registered repository is a real project even when it is also
|
|
329
|
+
* installed as a shared plugin; anything else that is only reachable as a plugin
|
|
330
|
+
* is a plugin. Unreadable registry metadata is treated as "not registered",
|
|
331
|
+
* which keeps provisioning safe.
|
|
332
|
+
*/
|
|
333
|
+
function isRegisteredProject(dir) {
|
|
334
|
+
try {
|
|
335
|
+
const registry = path.join(
|
|
336
|
+
process.env.AGENT_DIR || process.env._TR_TEST_AGENT_DIR || path.join(os.homedir(), '.agent'),
|
|
337
|
+
'skills', 'total-recall', 'config', 'project-registry.json'
|
|
338
|
+
);
|
|
339
|
+
const rows = JSON.parse(fs.readFileSync(registry, 'utf8'));
|
|
340
|
+
if (!Array.isArray(rows)) return false;
|
|
341
|
+
const wanted = fs.existsSync(dir) ? safeRealpath(dir) : path.resolve(dir);
|
|
342
|
+
return rows.some((row) => {
|
|
343
|
+
const candidate = row?.path || row?.root;
|
|
344
|
+
if (typeof candidate !== 'string' || !candidate) return false;
|
|
345
|
+
return (fs.existsSync(candidate) ? safeRealpath(candidate) : path.resolve(candidate)) === wanted;
|
|
346
|
+
});
|
|
347
|
+
} catch { return false; }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function safeRealpath(target) {
|
|
351
|
+
try { return fs.realpathSync(target); } catch { return null; }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The real directories behind the shared plugin folder. Each entry is normally a
|
|
356
|
+
* symlink to the plugin's owner checkout, which is where a nested brain would
|
|
357
|
+
* actually be written once the link is resolved.
|
|
358
|
+
*/
|
|
359
|
+
function pluginTargets(pluginsRoot) {
|
|
360
|
+
const targets = new Set();
|
|
361
|
+
let entries = [];
|
|
362
|
+
try { entries = fs.readdirSync(pluginsRoot, { withFileTypes: true }); } catch { return targets; }
|
|
363
|
+
for (const entry of entries) {
|
|
364
|
+
if (!entry.isSymbolicLink()) continue;
|
|
365
|
+
try { targets.add(fs.realpathSync(path.join(pluginsRoot, entry.name))); } catch { /* broken link */ }
|
|
366
|
+
}
|
|
367
|
+
return targets;
|
|
368
|
+
}
|
|
369
|
+
|
|
286
370
|
/**
|
|
287
371
|
* Get both brain layers for the current context.
|
|
288
372
|
*
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Trusted memory-owner adapter. No CLI, configuration, auth or plugin imports. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import matter from './frontmatter.mjs';
|
|
6
|
+
import { processOperationAsync } from './operation-validator.mjs';
|
|
7
|
+
import { buildLocalSearchIndex } from './fast-recall.mjs';
|
|
8
|
+
|
|
9
|
+
export function validateConfinedFact(params) {
|
|
10
|
+
if (!params || typeof params !== 'object' || Array.isArray(params)) throw new Error('Invalid fact');
|
|
11
|
+
const allowed = new Set(['category', 'title', 'content', 'tags', 'target', 'timeoutMs']);
|
|
12
|
+
if (Object.keys(params).some(k => !allowed.has(k))) throw new Error('Unknown fact field');
|
|
13
|
+
if (params.category !== undefined && params.category !== 'fact') throw new Error('Only facts are supported');
|
|
14
|
+
if (params.target !== undefined && params.target !== 'server') throw new Error('Invalid target');
|
|
15
|
+
if (typeof params.content !== 'string' || !params.content.trim() || Buffer.byteLength(params.content) > 12000) throw new Error('Invalid content');
|
|
16
|
+
if (params.title !== undefined && (typeof params.title !== 'string' || !params.title.trim() || params.title.length > 200)) throw new Error('Invalid title');
|
|
17
|
+
if (params.tags !== undefined && (!Array.isArray(params.tags) || params.tags.length > 16 ||
|
|
18
|
+
params.tags.some(t => typeof t !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(t) || ['universal', 'absolute'].includes(t)))) throw new Error('Invalid tags');
|
|
19
|
+
if (params.timeoutMs !== undefined && (typeof params.timeoutMs !== 'number' || !Number.isFinite(params.timeoutMs) || params.timeoutMs <= 0)) throw new Error('Invalid timeout');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Resolve an operator-selected brain once; reject links below that boundary. */
|
|
23
|
+
function checkTree(dir) {
|
|
24
|
+
if (!fs.existsSync(dir)) {
|
|
25
|
+
// existsSync follows links, so dangling links must still be refused.
|
|
26
|
+
try { if (fs.lstatSync(dir).isSymbolicLink()) throw new Error('Unsafe path'); }
|
|
27
|
+
catch (err) { if (err.code !== 'ENOENT') throw err; }
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const stat = fs.lstatSync(dir);
|
|
31
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() && !stat.isFile()) throw new Error('Unsafe path');
|
|
32
|
+
if (stat.isDirectory()) for (const name of fs.readdirSync(dir)) checkTree(path.join(dir, name));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readMemoryNodes(vault) {
|
|
36
|
+
const nodes = [];
|
|
37
|
+
function walk(dir) {
|
|
38
|
+
if (!fs.existsSync(dir)) return;
|
|
39
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
40
|
+
if (entry.name.startsWith('.') || entry.name === 'proposals') continue;
|
|
41
|
+
const file = path.join(dir, entry.name);
|
|
42
|
+
if (entry.isSymbolicLink()) throw new Error('Unsafe vault path');
|
|
43
|
+
if (entry.isDirectory()) walk(file);
|
|
44
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
45
|
+
const { data, content } = matter(fs.readFileSync(file, 'utf8'));
|
|
46
|
+
if (data.type === 'memory') nodes.push({ ...data, body: content, _filePath: file });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
walk(vault);
|
|
51
|
+
return nodes;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Create-only SSSS fact; rebuilding the local projection never runs generators. */
|
|
55
|
+
export async function createConfinedFact(brain, params, caller = 'local-operator') {
|
|
56
|
+
validateConfinedFact(params);
|
|
57
|
+
if (typeof caller !== 'string' || !caller || caller.length > 200) throw new Error('Invalid caller');
|
|
58
|
+
const base = fs.realpathSync(brain);
|
|
59
|
+
const vault = path.join(base, 'memory-vault'), derived = path.join(base, 'memory-derived');
|
|
60
|
+
checkTree(vault); checkTree(derived);
|
|
61
|
+
const slug = `fact-${crypto.randomUUID()}`, relative = `facts/${slug}.md`;
|
|
62
|
+
const title = params.title || params.content.trim().split('\n')[0].slice(0, 160);
|
|
63
|
+
const now = new Date().toISOString();
|
|
64
|
+
const data = { type: 'memory', slug, category: 'facts', title, description: title, timestamp: now,
|
|
65
|
+
schema_version: 2, status: 'active', confidence: 1, importance: 3, modality: 'descriptive',
|
|
66
|
+
created: now, updated: now, last_accessed: now, subject: 'agent', predicate: 'remembers_fact', object: slug,
|
|
67
|
+
sentiment_polarity: 'descriptive', sentiment_target: 'memory', tags: [...new Set(['invoke', ...(params.tags || [])])],
|
|
68
|
+
source: { type: 'confined-memory', session_id: 'confined-memory', agent: caller, evidence_count: 1 },
|
|
69
|
+
x_temporal_context: now };
|
|
70
|
+
// No overwrite grant and no caller-selected path, metadata, role or source.
|
|
71
|
+
const principal = { id: caller, kind: 'agent', workspaceIds: ['confined-memory'],
|
|
72
|
+
capabilities: ['memory:create', 'ssss:memory:create'],
|
|
73
|
+
authentication: { provider: 'total-recall-confined-memory', assurance: 'verified' } };
|
|
74
|
+
const result = { category: 'fact', path: `memory-vault/${relative}`, slug, committed: false, indexed: false };
|
|
75
|
+
try {
|
|
76
|
+
const written = await processOperationAsync({ type: 'operation', idempotency_key: crypto.randomUUID(),
|
|
77
|
+
path: relative, workspace_id: 'confined-memory', content: matter.stringify(params.content, data),
|
|
78
|
+
actor: { role: 'confined-memory' } }, vault, { agentRole: 'confined-memory', principal });
|
|
79
|
+
if (!written.success) throw new Error('Canonical write failed');
|
|
80
|
+
result.committed = true;
|
|
81
|
+
result.operation_id = written.operation_id;
|
|
82
|
+
buildLocalSearchIndex(readMemoryNodes(vault), { vaultDir: vault, derivedDir: derived });
|
|
83
|
+
result.indexed = true;
|
|
84
|
+
return result;
|
|
85
|
+
} catch (cause) {
|
|
86
|
+
// Kernel rolls back failed canonical event commits. A later host-audit or
|
|
87
|
+
// index error may leave the fact committed; never advertise safe blind retry.
|
|
88
|
+
result.committed = fs.existsSync(path.join(vault, relative));
|
|
89
|
+
const err = new Error(result.committed ? 'Fact committed; local indexing incomplete' : 'Fact creation failed', { cause });
|
|
90
|
+
err.result = result;
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-telemetry — measurement for the one number that governs every session.
|
|
3
|
+
*
|
|
4
|
+
* `compileContext` already computed everything needed to judge the context
|
|
5
|
+
* policy (total tokens, required tokens, overflow, which rules were included,
|
|
6
|
+
* which were dropped) and then threw it away. Without a record, the budget
|
|
7
|
+
* question could only ever be argued from a point-in-time anecdote — TR-003
|
|
8
|
+
* recorded "12 of 13 capsules overflow" once and nobody could later say whether
|
|
9
|
+
* a change helped, or whether the gate still failed at all.
|
|
10
|
+
*
|
|
11
|
+
* This records one line per capsule so the policy is decided from a trend
|
|
12
|
+
* rather than a memory.
|
|
13
|
+
*
|
|
14
|
+
* Bounded by construction. An append-only event log in this project once
|
|
15
|
+
* reached 306 MB, GitHub silently rejected every backup for five weeks, and the
|
|
16
|
+
* cause was invisible until someone looked. So this file carries its own size
|
|
17
|
+
* cap and retention, enforced on write, and it records counts and identifiers
|
|
18
|
+
* ONLY: never rule text, never capsule content, never a credential. Identifiers
|
|
19
|
+
* are slugs that already appear in the vault.
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
/** Append-only ledger for capsule measurements, inside the brain's derived space. */
|
|
25
|
+
export const LEDGER_NAME = 'context-ledger.jsonl';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fraction of the cap retained when trimming (TR_CONTEXT_LEDGER_RETAIN, default
|
|
29
|
+
* 0.5). Trimming to the cap on every crossing would re-trim constantly; leaving
|
|
30
|
+
* headroom makes the trim rare and keeps a real window of history.
|
|
31
|
+
*/
|
|
32
|
+
const RETAIN_FRACTION = (() => {
|
|
33
|
+
const n = Number(process.env.TR_CONTEXT_LEDGER_RETAIN ?? 0.5);
|
|
34
|
+
return Number.isFinite(n) && n > 0 && n <= 1 ? n : 0.5;
|
|
35
|
+
})();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Max bytes the ledger may reach (TR_CONTEXT_LEDGER_MAX_BYTES, default 8 MiB,
|
|
39
|
+
* 0 disables). Small on purpose: this is telemetry, not memory, and it must
|
|
40
|
+
* never be able to threaten a backup or a disk the way default.jsonl did.
|
|
41
|
+
*/
|
|
42
|
+
const maxBytes = () => {
|
|
43
|
+
const n = Number(process.env.TR_CONTEXT_LEDGER_MAX_BYTES ?? 8 * 1024 * 1024);
|
|
44
|
+
return Number.isFinite(n) && n >= 0 ? n : 8 * 1024 * 1024;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Whether telemetry is recorded at all (TR_CONTEXT_TELEMETRY=off disables). */
|
|
48
|
+
export const telemetryEnabled = () => String(process.env.TR_CONTEXT_TELEMETRY || '').toLowerCase() !== 'off';
|
|
49
|
+
|
|
50
|
+
/** Ledger path for a brain's derived directory. */
|
|
51
|
+
export function ledgerPath(derivedDir) {
|
|
52
|
+
return derivedDir ? path.join(derivedDir, LEDGER_NAME) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Keep the ledger bounded while preserving a usable trend.
|
|
57
|
+
*
|
|
58
|
+
* Two failure modes, both observed rather than imagined:
|
|
59
|
+
* 1. An append-only log in this project reached 306 MB and silently broke
|
|
60
|
+
* every backup for five weeks. So growth must be bounded by construction.
|
|
61
|
+
* 2. The first attempt here renamed the live file over the previous
|
|
62
|
+
* generation, so 40 writes under a small cap left 4 records — the trend
|
|
63
|
+
* the ledger exists for was destroyed by its own housekeeping.
|
|
64
|
+
*
|
|
65
|
+
* The ledger therefore trims to a retained fraction of the cap rather than
|
|
66
|
+
* collapsing: the newest half survives, the rest is dropped. Trimming runs only
|
|
67
|
+
* once the cap is crossed (not on every write), so the common path stays a
|
|
68
|
+
* single append.
|
|
69
|
+
*/
|
|
70
|
+
function enforceBound(file, cap, fsImpl = fs) {
|
|
71
|
+
if (cap <= 0) return;
|
|
72
|
+
let size;
|
|
73
|
+
try { size = fsImpl.statSync(file).size; } catch { return; }
|
|
74
|
+
if (size <= cap) return;
|
|
75
|
+
try {
|
|
76
|
+
const lines = fsImpl.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
77
|
+
const keep = [];
|
|
78
|
+
let bytes = 0;
|
|
79
|
+
const retainBytes = Math.floor(cap * RETAIN_FRACTION);
|
|
80
|
+
// Walk backwards so the most recent measurements are the ones retained.
|
|
81
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
82
|
+
const next = Buffer.byteLength(lines[i]) + 1;
|
|
83
|
+
if (bytes + next > retainBytes && keep.length) break;
|
|
84
|
+
bytes += next;
|
|
85
|
+
keep.unshift(lines[i]);
|
|
86
|
+
}
|
|
87
|
+
fsImpl.writeFileSync(file, keep.length ? `${keep.join('\n')}\n` : '');
|
|
88
|
+
} catch { /* a concurrent writer or an unreadable file: leave it alone */ }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Record one capsule measurement.
|
|
93
|
+
*
|
|
94
|
+
* Returns the record written, or null when telemetry is off or the write
|
|
95
|
+
* failed. Never throws: measurement must never be able to break a session.
|
|
96
|
+
*/
|
|
97
|
+
export function recordCapsule(stats, { derivedDir, action = [], query, consumer = 'cli', now = Date.now(), fsImpl = fs } = {}) {
|
|
98
|
+
if (!telemetryEnabled() || !stats || !derivedDir) return null;
|
|
99
|
+
try {
|
|
100
|
+
const file = ledgerPath(derivedDir);
|
|
101
|
+
fsImpl.mkdirSync(path.dirname(file), { recursive: true });
|
|
102
|
+
enforceBound(file, maxBytes(), fsImpl);
|
|
103
|
+
const contributions = Array.isArray(stats.contributions) ? stats.contributions : [];
|
|
104
|
+
const record = {
|
|
105
|
+
at: new Date(now).toISOString(),
|
|
106
|
+
consumer,
|
|
107
|
+
// Actions and the query are what make a measurement interpretable; the
|
|
108
|
+
// query is truncated because it is free text, not an identifier.
|
|
109
|
+
actions: Array.isArray(action) ? action : [action].filter(Boolean),
|
|
110
|
+
query_chars: typeof query === 'string' ? query.length : 0,
|
|
111
|
+
total_tokens: stats.total_tokens ?? null,
|
|
112
|
+
// `budget` is added by the caller's capsuleResponse wrapper; absent here.
|
|
113
|
+
required_tokens: stats.required_tokens ?? null,
|
|
114
|
+
overflow_tokens: stats.overflow_tokens ?? null,
|
|
115
|
+
ready: typeof stats.overflow_tokens === 'number' ? stats.overflow_tokens === 0 : null,
|
|
116
|
+
required_count: Array.isArray(stats.required_ids) ? stats.required_ids.length : contributions.filter(c => c.required).length,
|
|
117
|
+
excluded_count: Array.isArray(stats.excluded_ids) ? stats.excluded_ids.length : 0,
|
|
118
|
+
// Identifier lists only, and only the ones that decided the budget.
|
|
119
|
+
required_ids: Array.isArray(stats.required_ids) ? stats.required_ids : [],
|
|
120
|
+
excluded_ids: Array.isArray(stats.excluded_ids) ? stats.excluded_ids.slice(0, 40) : [],
|
|
121
|
+
version: stats.version || null,
|
|
122
|
+
};
|
|
123
|
+
// A torn final line — an interrupted write, a full disk — would otherwise
|
|
124
|
+
// have the next record appended onto it, corrupting both. Repair the tail
|
|
125
|
+
// before appending so one bad write costs one measurement, not two.
|
|
126
|
+
repairTornTail(file, fsImpl);
|
|
127
|
+
fsImpl.appendFileSync(file, JSON.stringify(record) + '\n');
|
|
128
|
+
return record;
|
|
129
|
+
} catch { return null; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Give a torn final line its own terminator.
|
|
134
|
+
*
|
|
135
|
+
* An append-only ledger assumes the file ends at a record boundary. When it
|
|
136
|
+
* does not — the process died mid-write — the next append lands on that line
|
|
137
|
+
* and both records become unparseable. Dropping the damaged tail keeps the loss
|
|
138
|
+
* at exactly the one measurement that failed.
|
|
139
|
+
*/
|
|
140
|
+
function repairTornTail(file, fsImpl = fs) {
|
|
141
|
+
let text;
|
|
142
|
+
try { text = fsImpl.readFileSync(file, 'utf8'); } catch { return; }
|
|
143
|
+
if (!text || text.endsWith('\n')) return;
|
|
144
|
+
const lastBreak = text.lastIndexOf('\n');
|
|
145
|
+
const tail = lastBreak === -1 ? text : text.slice(lastBreak + 1);
|
|
146
|
+
try { JSON.parse(tail); return; } catch { /* the tail is torn */ }
|
|
147
|
+
try { fsImpl.writeFileSync(file, lastBreak === -1 ? '' : text.slice(0, lastBreak + 1)); } catch { /* leave it alone */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Read ledger records, oldest first.
|
|
152
|
+
*
|
|
153
|
+
* Skips a malformed line individually. A torn write must cost one measurement,
|
|
154
|
+
* never the whole ledger — an earlier version discarded every valid record that
|
|
155
|
+
* followed the torn one.
|
|
156
|
+
*/
|
|
157
|
+
export function readLedger(derivedDir, { limit = 0, fsImpl = fs } = {}) {
|
|
158
|
+
const file = ledgerPath(derivedDir);
|
|
159
|
+
if (!file) return [];
|
|
160
|
+
const records = [];
|
|
161
|
+
for (const candidate of [file]) {
|
|
162
|
+
let text;
|
|
163
|
+
try { text = fsImpl.readFileSync(candidate, 'utf8'); } catch { continue; }
|
|
164
|
+
for (const line of text.split('\n')) {
|
|
165
|
+
if (!line.trim()) continue;
|
|
166
|
+
try { records.push(JSON.parse(line)); } catch { /* skip this line only */ }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return limit > 0 ? records.slice(-limit) : records;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Summarise a ledger into the numbers a design decision needs.
|
|
174
|
+
*
|
|
175
|
+
* Reports per action, because "the capsule is too big" is not actionable while
|
|
176
|
+
* "read overflows and edit does not" is. `top_required` answers which rules
|
|
177
|
+
* carry the cost, which is the input to action-scoping a rule.
|
|
178
|
+
*/
|
|
179
|
+
export function summariseLedger(records = []) {
|
|
180
|
+
const byAction = new Map();
|
|
181
|
+
const ruleTokens = new Map();
|
|
182
|
+
const ruleAppearances = new Map();
|
|
183
|
+
for (const r of records) {
|
|
184
|
+
const actions = r.actions?.length ? r.actions : ['(none)'];
|
|
185
|
+
for (const action of actions) {
|
|
186
|
+
const entry = byAction.get(action) || { action, samples: 0, overflow: 0, required_max: 0, required_sum: 0, total_max: 0, excluded_total: 0 };
|
|
187
|
+
entry.samples++;
|
|
188
|
+
if (r.overflow_tokens > 0) entry.overflow++;
|
|
189
|
+
entry.required_sum += r.required_tokens || 0;
|
|
190
|
+
entry.required_max = Math.max(entry.required_max, r.required_tokens || 0);
|
|
191
|
+
entry.total_max = Math.max(entry.total_max, r.total_tokens || 0);
|
|
192
|
+
entry.excluded_total += r.excluded_count || 0;
|
|
193
|
+
byAction.set(action, entry);
|
|
194
|
+
}
|
|
195
|
+
for (const id of r.required_ids || []) {
|
|
196
|
+
ruleAppearances.set(id, (ruleAppearances.get(id) || 0) + 1);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const actions = [...byAction.values()]
|
|
200
|
+
.map(e => ({ ...e, required_avg: e.samples ? Math.round(e.required_sum / e.samples) : 0, overflow_rate: e.samples ? +(e.overflow / e.samples).toFixed(3) : 0 }))
|
|
201
|
+
.sort((a, b) => b.overflow_rate - a.overflow_rate || b.required_max - a.required_max);
|
|
202
|
+
const mostRequired = [...ruleAppearances.entries()]
|
|
203
|
+
.map(([id, appearances]) => ({ id, appearances }))
|
|
204
|
+
.sort((a, b) => b.appearances - a.appearances)
|
|
205
|
+
.slice(0, 25);
|
|
206
|
+
return {
|
|
207
|
+
samples: records.length,
|
|
208
|
+
first_at: records.length ? records[0].at : null,
|
|
209
|
+
last_at: records.length ? records[records.length - 1].at : null,
|
|
210
|
+
actions,
|
|
211
|
+
most_required: mostRequired,
|
|
212
|
+
rule_tokens: [...ruleTokens.entries()].map(([id, tokens]) => ({ id, tokens })),
|
|
213
|
+
};
|
|
214
|
+
}
|