total-recall-brain 3.26.0 → 3.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/total-recall.mjs +31 -1
- package/docs/ARCHITECTURE.md +76 -9
- package/frontend/dist/assets/index-U7o-qU99.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/src/App.tsx +39 -1
- package/frontend/src/api/index.ts +1 -0
- package/frontend/src/api/plugins.ts +167 -0
- package/frontend/src/pages/ChatPage.tsx +44 -17
- package/frontend/src/pages/IntegrationsPage.tsx +7 -0
- package/frontend/src/pages/PluginsPage.spec.tsx +17 -0
- package/frontend/src/pages/PluginsPage.tsx +1331 -0
- package/package.json +1 -1
- package/scaffold/.agent/plugins/.gitkeep +0 -0
- package/scaffold/.agent/plugins/code-quality/README.md +16 -0
- package/scaffold/.agent/plugins/code-quality/cli.mjs +15 -0
- package/scaffold/.agent/plugins/code-quality/plugin.json +34 -0
- package/scaffold/.agent/plugins/git-sentinel/README.md +16 -0
- package/scaffold/.agent/plugins/git-sentinel/cli.mjs +86 -0
- package/scaffold/.agent/plugins/git-sentinel/plugin.json +34 -0
- package/scaffold/.agent/plugins/meta-harness/README.md +16 -0
- package/scaffold/.agent/plugins/meta-harness/cli.mjs +34 -0
- package/scaffold/.agent/plugins/meta-harness/plugin.json +34 -0
- package/scaffold/.agent/plugins/system-monitor/README.md +16 -0
- package/scaffold/.agent/plugins/system-monitor/cli.mjs +79 -0
- package/scaffold/.agent/plugins/system-monitor/generator.mjs +20 -0
- package/scaffold/.agent/plugins/system-monitor/plugin.json +38 -0
- package/scaffold/.agent/skills/meta-harness/SKILL.md +113 -0
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +23 -0
- package/src/cli/agent.mjs +173 -0
- package/src/cli/connect.mjs +89 -4
- package/src/cli/deploy-ui.mjs +6 -0
- package/src/cli/harness.mjs +135 -0
- package/src/cli/ingest-openwiki.mjs +71 -2
- package/src/cli/init.mjs +1 -0
- package/src/cli/lint.mjs +47 -3
- package/src/cli/mesh.mjs +286 -0
- package/src/cli/plugin/create.mjs +186 -0
- package/src/cli/plugin/index.mjs +139 -0
- package/src/cli/plugin/install.mjs +141 -0
- package/src/cli/plugin/list.mjs +83 -0
- package/src/cli/plugin/remove.mjs +51 -0
- package/src/cli/plugin/search.mjs +37 -0
- package/src/cli/skill-projection.mjs +14 -0
- package/src/cli/wizard.html +9 -0
- package/src/core/agent-manager.mjs +290 -0
- package/src/core/cache-prune.mjs +341 -0
- package/src/core/context-cache.mjs +130 -0
- package/src/core/daemon-loop.mjs +47 -1
- package/src/core/evolving-context.mjs +171 -0
- package/src/core/mesh-activity.mjs +140 -0
- package/src/core/mesh.mjs +174 -1
- package/src/core/meta-harness.mjs +251 -0
- package/src/core/plugin-context.mjs +206 -0
- package/src/core/plugin-loader.mjs +210 -0
- package/src/core/runtime.mjs +65 -13
- package/src/core/surface.mjs +32 -10
- package/src/server/api.mjs +18 -2
- package/src/server/index.mjs +8 -2
- package/src/server/rest.mjs +4 -0
- package/src/server/routes/integrations.mjs +5 -1
- package/src/server/routes/mesh-presence.mjs +143 -0
- package/src/server/routes/models.mjs +42 -30
- package/src/server/routes/plugins.mjs +517 -0
- package/src/server/tools.mjs +266 -0
- package/frontend/dist/assets/index-Bl3I11B0.js +0 -5742
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cache-prune — bound the caches a long-running brain accumulates.
|
|
3
|
+
*
|
|
4
|
+
* Nothing pruned anything. A brain that had been up for eight days measured
|
|
5
|
+
* 3.2 GB, and half of that was one file:
|
|
6
|
+
*
|
|
7
|
+
* logs/daemon.log 1.5 GB single file, no rotation, no cap
|
|
8
|
+
* logs/system-*.jsonl 27 MB rotates daily by filename, never deleted
|
|
9
|
+
* sessions/ 200 MB 4,080 files going back three months
|
|
10
|
+
* .snapshots/ 98 MB never capped
|
|
11
|
+
*
|
|
12
|
+
* The vault itself was 346 MB. The brain was mostly exhaust.
|
|
13
|
+
*
|
|
14
|
+
* Two rules shape everything here:
|
|
15
|
+
*
|
|
16
|
+
* 1. Only caches. Anything that cannot be regenerated is off limits, and the
|
|
17
|
+
* protected list is checked by resolved path so a policy typo cannot walk
|
|
18
|
+
* into the vault. `memory-derived/embeddings.db` is deliberately protected
|
|
19
|
+
* too: it is rebuildable, but it is in constant use and re-embedding the
|
|
20
|
+
* vault is expensive. Unused is the word that matters, not rebuildable.
|
|
21
|
+
*
|
|
22
|
+
* 2. A file another process holds open is truncated, never unlinked. The daemon
|
|
23
|
+
* keeps `daemon.log` open in append mode; deleting it orphans that
|
|
24
|
+
* descriptor and logging silently stops until the next restart, which is a
|
|
25
|
+
* worse failure than the disk usage being fixed.
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import crypto from 'node:crypto';
|
|
30
|
+
|
|
31
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Directories that must never be pruned, relative to the brain root.
|
|
35
|
+
* Checked by resolved path, not by string prefix.
|
|
36
|
+
*/
|
|
37
|
+
export const PROTECTED = [
|
|
38
|
+
'memory-vault', // the source of truth
|
|
39
|
+
'memory-derived', // embeddings index — rebuildable but always in use
|
|
40
|
+
'memory-inbox', // pending promotions, not yet durable
|
|
41
|
+
'config', // includes secrets.enc
|
|
42
|
+
'browser-profile', // live credentials; never touch
|
|
43
|
+
'skills',
|
|
44
|
+
'skills-registry',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {object} Policy
|
|
49
|
+
* @property {string} id
|
|
50
|
+
* @property {string} dir relative to brainDir
|
|
51
|
+
* @property {RegExp} [match] which files the policy applies to
|
|
52
|
+
* @property {'age'|'count'|'size'} mode
|
|
53
|
+
* @property {number} limit days | groups to keep | max bytes
|
|
54
|
+
* @property {number} [keepBytes] size mode: bytes of tail to preserve
|
|
55
|
+
* @property {boolean} [group] count mode: group files by name-without-extension
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A session file is exhaust only once its contents are in the vault.
|
|
60
|
+
*
|
|
61
|
+
* Ingestion is tracked per *entry*, not per file: `memory-derived/content-hashes.jsonl`
|
|
62
|
+
* records a sha256 of each entry's content as it is absorbed. So a file is safe
|
|
63
|
+
* to delete only when every entry in it is already there. Age alone is not a
|
|
64
|
+
* proof of ingestion — a session written while the daemon was stopped can be
|
|
65
|
+
* arbitrarily old and never have been read.
|
|
66
|
+
*
|
|
67
|
+
* Anything unreadable or unparseable is treated as NOT ingested, so corruption
|
|
68
|
+
* costs disk space rather than memory.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} brainDir
|
|
71
|
+
* @returns {(file: {abs: string}) => boolean}
|
|
72
|
+
*/
|
|
73
|
+
export function sessionIngestedGuard(brainDir) {
|
|
74
|
+
let seen = null;
|
|
75
|
+
const load = () => {
|
|
76
|
+
if (seen) return seen;
|
|
77
|
+
seen = new Set();
|
|
78
|
+
const idx = path.join(brainDir, 'memory-derived', 'content-hashes.jsonl');
|
|
79
|
+
try {
|
|
80
|
+
for (const line of fs.readFileSync(idx, 'utf8').split('\n')) {
|
|
81
|
+
if (!line.trim()) continue;
|
|
82
|
+
try { seen.add(JSON.parse(line).sha256); } catch { /* corrupt line */ }
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
/* no index yet — nothing has been ingested, so nothing is safe to drop */
|
|
86
|
+
}
|
|
87
|
+
return seen;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return (file) => {
|
|
91
|
+
const hashes = load();
|
|
92
|
+
if (hashes.size === 0) return false;
|
|
93
|
+
let text;
|
|
94
|
+
try {
|
|
95
|
+
text = fs.readFileSync(file.abs, 'utf8');
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const lines = text.split('\n').filter((l) => l.trim());
|
|
100
|
+
if (lines.length === 0) return true; // empty file carries nothing
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
let entry;
|
|
103
|
+
try { entry = JSON.parse(line); } catch { return false; }
|
|
104
|
+
const h = crypto.createHash('sha256').update(String(entry.content || '')).digest('hex');
|
|
105
|
+
if (!hashes.has(h)) return false;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @type {Policy[]} */
|
|
112
|
+
export const DEFAULT_POLICIES = [
|
|
113
|
+
{
|
|
114
|
+
id: 'system-logs',
|
|
115
|
+
dir: 'logs',
|
|
116
|
+
match: /^system-\d{4}-\d{2}-\d{2}\.jsonl$/,
|
|
117
|
+
mode: 'age',
|
|
118
|
+
limit: 14,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
// One unrotated file that grew ~190 MB/day. Truncated, never unlinked.
|
|
122
|
+
id: 'daemon-log',
|
|
123
|
+
dir: 'logs',
|
|
124
|
+
match: /^daemon\.log$/,
|
|
125
|
+
mode: 'size',
|
|
126
|
+
limit: 25 * 1024 * 1024,
|
|
127
|
+
keepBytes: 5 * 1024 * 1024,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
// Age selects candidates; the guard decides.
|
|
131
|
+
//
|
|
132
|
+
// NOTE: this policy is deliberately inert on brains with no ingestion
|
|
133
|
+
// evidence. `sessions/` holds the OUTPUT of ingesting external transcripts,
|
|
134
|
+
// and `content-hashes.jsonl` records external-source dedup rather than
|
|
135
|
+
// "this session reached the vault" — so it is a conservative proxy, not a
|
|
136
|
+
// proof. Where the index is absent the guard withholds everything, and the
|
|
137
|
+
// daemon logs the retained count so an inert policy is visible rather than
|
|
138
|
+
// looking like a cache that was already small.
|
|
139
|
+
id: 'sessions',
|
|
140
|
+
dir: 'sessions',
|
|
141
|
+
match: /\.jsonl$/,
|
|
142
|
+
mode: 'age',
|
|
143
|
+
limit: 30,
|
|
144
|
+
guard: sessionIngestedGuard,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
// Snapshots come in pairs (.tar.gz + .json); keeping "10 files" would keep
|
|
148
|
+
// five snapshots and orphan half of a sixth.
|
|
149
|
+
id: 'snapshots',
|
|
150
|
+
dir: '.snapshots',
|
|
151
|
+
mode: 'count',
|
|
152
|
+
limit: 10,
|
|
153
|
+
group: true,
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
/** Is this path inside a protected directory? */
|
|
158
|
+
export function isProtected(brainDir, target) {
|
|
159
|
+
const root = path.resolve(brainDir);
|
|
160
|
+
const abs = path.resolve(target);
|
|
161
|
+
// Outside the brain entirely — refuse rather than guess.
|
|
162
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) return true;
|
|
163
|
+
const rel = path.relative(root, abs);
|
|
164
|
+
const first = rel.split(path.sep)[0];
|
|
165
|
+
return PROTECTED.includes(first);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Strip one trailing extension, and `.tar.gz` as a unit. */
|
|
169
|
+
function stem(name) {
|
|
170
|
+
return name.replace(/\.tar\.gz$/, '').replace(/\.[^.]+$/, '');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function listFiles(dir, match) {
|
|
174
|
+
let entries;
|
|
175
|
+
try {
|
|
176
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
177
|
+
} catch {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const e of entries) {
|
|
182
|
+
if (!e.isFile()) continue;
|
|
183
|
+
if (match && !match.test(e.name)) continue;
|
|
184
|
+
const abs = path.join(dir, e.name);
|
|
185
|
+
try {
|
|
186
|
+
const st = fs.statSync(abs);
|
|
187
|
+
out.push({ name: e.name, abs, mtime: st.mtimeMs, size: st.size });
|
|
188
|
+
} catch {
|
|
189
|
+
/* vanished between readdir and stat */
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Keep only the tail of an oversized file, in place.
|
|
197
|
+
*
|
|
198
|
+
* Writing to the same path truncates it, and an appender's O_APPEND descriptor
|
|
199
|
+
* simply continues at the new end. Lines written between the read and the write
|
|
200
|
+
* are lost; for a log that is an acceptable trade for not orphaning the fd.
|
|
201
|
+
*/
|
|
202
|
+
function truncateKeepingTail(file, keepBytes) {
|
|
203
|
+
const fd = fs.openSync(file, 'r');
|
|
204
|
+
try {
|
|
205
|
+
const { size } = fs.fstatSync(fd);
|
|
206
|
+
const start = Math.max(0, size - keepBytes);
|
|
207
|
+
const buf = Buffer.alloc(Math.min(keepBytes, size));
|
|
208
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
209
|
+
// Drop a partial first line so the file stays parseable line-by-line.
|
|
210
|
+
const nl = buf.indexOf(0x0a);
|
|
211
|
+
const tail = nl >= 0 && start > 0 ? buf.subarray(nl + 1) : buf;
|
|
212
|
+
fs.writeFileSync(file, tail);
|
|
213
|
+
return size - tail.length;
|
|
214
|
+
} finally {
|
|
215
|
+
fs.closeSync(fd);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Apply every policy.
|
|
221
|
+
*
|
|
222
|
+
* @param {{brainDir: string, policies?: Policy[], dryRun?: boolean, now?: number}} opts
|
|
223
|
+
* @returns {{freed_bytes: number, removed: number, dry_run: boolean, results: object[]}}
|
|
224
|
+
*/
|
|
225
|
+
export function pruneCaches({ brainDir, policies = DEFAULT_POLICIES, dryRun = false, now = Date.now() } = {}) {
|
|
226
|
+
const results = [];
|
|
227
|
+
let freed = 0;
|
|
228
|
+
let removed = 0;
|
|
229
|
+
|
|
230
|
+
for (const p of policies) {
|
|
231
|
+
const dir = path.join(brainDir, p.dir);
|
|
232
|
+
const res = { id: p.id, dir: p.dir, freed_bytes: 0, removed: 0, retained: 0, skipped: null };
|
|
233
|
+
|
|
234
|
+
if (isProtected(brainDir, dir)) {
|
|
235
|
+
// A policy pointing at protected data is a bug, and it must be loud
|
|
236
|
+
// rather than silently doing nothing.
|
|
237
|
+
res.skipped = `refused: ${p.dir} is protected`;
|
|
238
|
+
results.push(res);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const files = listFiles(dir, p.match);
|
|
243
|
+
|
|
244
|
+
if (p.mode === 'size') {
|
|
245
|
+
for (const f of files) {
|
|
246
|
+
if (f.size <= p.limit) continue;
|
|
247
|
+
if (dryRun) {
|
|
248
|
+
res.freed_bytes += f.size - (p.keepBytes || 0);
|
|
249
|
+
} else {
|
|
250
|
+
try {
|
|
251
|
+
res.freed_bytes += truncateKeepingTail(f.abs, p.keepBytes || p.limit);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
res.skipped = `truncate failed: ${err.message}`;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} else if (p.mode === 'age') {
|
|
258
|
+
const cutoff = now - p.limit * DAY_MS;
|
|
259
|
+
const guard = typeof p.guard === 'function' ? p.guard(brainDir) : null;
|
|
260
|
+
for (const f of files) {
|
|
261
|
+
if (f.mtime >= cutoff) continue;
|
|
262
|
+
if (guard && !guard(f)) { res.retained = (res.retained || 0) + 1; continue; }
|
|
263
|
+
if (!dryRun) {
|
|
264
|
+
try {
|
|
265
|
+
fs.unlinkSync(f.abs);
|
|
266
|
+
} catch {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
res.freed_bytes += f.size;
|
|
271
|
+
res.removed += 1;
|
|
272
|
+
}
|
|
273
|
+
} else if (p.mode === 'count') {
|
|
274
|
+
const groups = new Map();
|
|
275
|
+
for (const f of files) {
|
|
276
|
+
const key = p.group ? stem(f.name) : f.name;
|
|
277
|
+
const g = groups.get(key) || { mtime: 0, files: [] };
|
|
278
|
+
g.mtime = Math.max(g.mtime, f.mtime);
|
|
279
|
+
g.files.push(f);
|
|
280
|
+
groups.set(key, g);
|
|
281
|
+
}
|
|
282
|
+
const ordered = [...groups.values()].sort((a, b) => b.mtime - a.mtime);
|
|
283
|
+
for (const g of ordered.slice(p.limit)) {
|
|
284
|
+
for (const f of g.files) {
|
|
285
|
+
if (!dryRun) {
|
|
286
|
+
try {
|
|
287
|
+
fs.unlinkSync(f.abs);
|
|
288
|
+
} catch {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
res.freed_bytes += f.size;
|
|
293
|
+
res.removed += 1;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
freed += res.freed_bytes;
|
|
299
|
+
removed += res.removed;
|
|
300
|
+
results.push(res);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { freed_bytes: freed, removed, dry_run: !!dryRun, results };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function formatBytes(n) {
|
|
307
|
+
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
|
308
|
+
const u = ['B', 'KB', 'MB', 'GB'];
|
|
309
|
+
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
|
310
|
+
return `${(n / 1024 ** i).toFixed(i ? 1 : 0)} ${u[i]}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Throttled entry point for callers on a hot loop.
|
|
315
|
+
*
|
|
316
|
+
* The daemon has no natural "once a day" tick, so the interval is tracked in a
|
|
317
|
+
* marker file rather than in memory — a process that restarts every few minutes
|
|
318
|
+
* would otherwise prune on every boot. Failing to read or write the marker is
|
|
319
|
+
* never fatal: the worst case is pruning more often than intended, which is
|
|
320
|
+
* cheap when there is nothing to prune.
|
|
321
|
+
*
|
|
322
|
+
* @param {{brainDir: string, minIntervalMs?: number, now?: number}} opts
|
|
323
|
+
* @returns {null|object} null when skipped, otherwise the prune report
|
|
324
|
+
*/
|
|
325
|
+
export function maybePruneCaches({ brainDir, minIntervalMs = 6 * 60 * 60 * 1000, now = Date.now(), ...rest } = {}) {
|
|
326
|
+
const marker = path.join(brainDir, 'scheduler', 'last-cache-prune.json');
|
|
327
|
+
try {
|
|
328
|
+
const last = JSON.parse(fs.readFileSync(marker, 'utf8'))?.at || 0;
|
|
329
|
+
if (now - last < minIntervalMs) return null;
|
|
330
|
+
} catch {
|
|
331
|
+
/* no marker yet — first run */
|
|
332
|
+
}
|
|
333
|
+
const report = pruneCaches({ brainDir, now, ...rest });
|
|
334
|
+
try {
|
|
335
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
336
|
+
fs.writeFileSync(marker, JSON.stringify({ at: now, freed_bytes: report.freed_bytes }));
|
|
337
|
+
} catch {
|
|
338
|
+
/* marker is an optimisation, not a requirement */
|
|
339
|
+
}
|
|
340
|
+
return report;
|
|
341
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Context Cache & Section Management
|
|
7
|
+
*
|
|
8
|
+
* Provides fine-grained section caching for evolving context blocks.
|
|
9
|
+
* Avoids full graph re-synthesis when only an atomic benchmark or project node changes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function loadSectionCache(derivedDir) {
|
|
13
|
+
if (!derivedDir) return {};
|
|
14
|
+
const cachePath = path.join(derivedDir, 'section-cache.json');
|
|
15
|
+
if (!fs.existsSync(cachePath)) return {};
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
18
|
+
} catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function saveSectionCache(derivedDir, cache) {
|
|
24
|
+
if (!derivedDir || !cache) return;
|
|
25
|
+
const cachePath = path.join(derivedDir, 'section-cache.json');
|
|
26
|
+
try {
|
|
27
|
+
if (!fs.existsSync(derivedDir)) {
|
|
28
|
+
fs.mkdirSync(derivedDir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf8');
|
|
31
|
+
} catch (err) {
|
|
32
|
+
// Non-fatal cache write failure
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compute a deterministic hash for a group of nodes or inputs.
|
|
38
|
+
*/
|
|
39
|
+
export function computeSectionHash(nodes = [], extraInput = '') {
|
|
40
|
+
const hash = crypto.createHash('sha256');
|
|
41
|
+
for (const n of nodes) {
|
|
42
|
+
hash.update(n.slug || '');
|
|
43
|
+
hash.update(String(n.timestamp || ''));
|
|
44
|
+
hash.update(String(n.importance || ''));
|
|
45
|
+
hash.update(String(n.confidence || ''));
|
|
46
|
+
hash.update(JSON.stringify(n.source || {}));
|
|
47
|
+
hash.update(n.title || '');
|
|
48
|
+
hash.update(n.body || n.content || '');
|
|
49
|
+
if (Array.isArray(n.enables)) hash.update(n.enables.join(','));
|
|
50
|
+
if (Array.isArray(n.prerequisites)) hash.update(n.prerequisites.join(','));
|
|
51
|
+
}
|
|
52
|
+
if (extraInput) {
|
|
53
|
+
hash.update(String(extraInput));
|
|
54
|
+
}
|
|
55
|
+
return hash.digest('hex');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Retrieve a section from cache if inputs are unchanged, otherwise compute and cache.
|
|
60
|
+
*/
|
|
61
|
+
export async function getCachedSection({
|
|
62
|
+
derivedDir,
|
|
63
|
+
sectionKey,
|
|
64
|
+
nodes = [],
|
|
65
|
+
extraInput = '',
|
|
66
|
+
generatorFn
|
|
67
|
+
}) {
|
|
68
|
+
const cache = loadSectionCache(derivedDir);
|
|
69
|
+
const inputHash = computeSectionHash(nodes, extraInput);
|
|
70
|
+
|
|
71
|
+
if (cache[sectionKey] && cache[sectionKey].hash === inputHash) {
|
|
72
|
+
return {
|
|
73
|
+
content: cache[sectionKey].content,
|
|
74
|
+
cached: true,
|
|
75
|
+
hash: inputHash
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Dirty or uncached: compute section
|
|
80
|
+
const content = await generatorFn();
|
|
81
|
+
cache[sectionKey] = {
|
|
82
|
+
hash: inputHash,
|
|
83
|
+
content,
|
|
84
|
+
updatedAt: new Date().toISOString()
|
|
85
|
+
};
|
|
86
|
+
saveSectionCache(derivedDir, cache);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
content,
|
|
90
|
+
cached: false,
|
|
91
|
+
hash: inputHash
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Manage context token budget across sections.
|
|
97
|
+
* Allocates tokens based on section priority:
|
|
98
|
+
* 1. User Projects & Grounding (Critical, cannot be truncated)
|
|
99
|
+
* 2. Active Benchmarks & World Records
|
|
100
|
+
* 3. Verified Capability Breakthroughs
|
|
101
|
+
* 4. Deep Evolving Context Block / Background
|
|
102
|
+
*/
|
|
103
|
+
export function manageContextBudget(sections = [], options = {}) {
|
|
104
|
+
const maxChars = options.maxChars || 80000; // ~20,000 tokens default for surface injection
|
|
105
|
+
let currentTotal = 0;
|
|
106
|
+
const budgeted = [];
|
|
107
|
+
|
|
108
|
+
// Sort sections by priority if priority is provided
|
|
109
|
+
const sorted = [...sections].sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
|
110
|
+
|
|
111
|
+
for (const sec of sorted) {
|
|
112
|
+
if (!sec.content) continue;
|
|
113
|
+
const secLen = sec.content.length;
|
|
114
|
+
|
|
115
|
+
if (currentTotal + secLen <= maxChars) {
|
|
116
|
+
budgeted.push(sec.content);
|
|
117
|
+
currentTotal += secLen;
|
|
118
|
+
} else {
|
|
119
|
+
// Remaining budget
|
|
120
|
+
const remaining = maxChars - currentTotal;
|
|
121
|
+
if (remaining > 500 && sec.allowTruncation !== false) {
|
|
122
|
+
budgeted.push(sec.content.slice(0, remaining) + '\n\n... (section truncated to preserve context budget)');
|
|
123
|
+
currentTotal += remaining;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return budgeted.join('\n\n---\n\n');
|
|
130
|
+
}
|
package/src/core/daemon-loop.mjs
CHANGED
|
@@ -337,6 +337,38 @@ async function main() {
|
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
+
// Bound the caches. Internally throttled to once every six hours via a
|
|
341
|
+
// marker file, so calling it on the heartbeat costs a stat. Nothing
|
|
342
|
+
// pruned anything before this: one brain reached 3.2 GB, half of it a
|
|
343
|
+
// single unrotated daemon.log.
|
|
344
|
+
if (taskCount > 0 && taskCount % (60000 / TASK_SLEEP_MS) === 0) {
|
|
345
|
+
try {
|
|
346
|
+
const { maybePruneCaches, formatBytes } = await import('./cache-prune.mjs');
|
|
347
|
+
const pruned = maybePruneCaches({ brainDir: BRAIN_DIR });
|
|
348
|
+
if (pruned && pruned.freed_bytes > 0) {
|
|
349
|
+
logger.info(
|
|
350
|
+
'daemon-loop',
|
|
351
|
+
`Pruned caches: freed ${formatBytes(pruned.freed_bytes)} across ${pruned.removed} file(s)`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
for (const r of pruned?.results || []) {
|
|
355
|
+
if (r.skipped) logger.warn('daemon-loop', `Cache policy ${r.id} skipped — ${r.skipped}`);
|
|
356
|
+
// Retention is not the same as nothing-to-do. A policy that keeps
|
|
357
|
+
// everything it looked at should say so, or a cache that never
|
|
358
|
+
// shrinks looks like a cache that was already small.
|
|
359
|
+
if (r.retained > 0) {
|
|
360
|
+
logger.info(
|
|
361
|
+
'daemon-loop',
|
|
362
|
+
`Cache policy ${r.id} retained ${r.retained} file(s) — guard withheld them as unsafe to delete`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
} catch (err) {
|
|
367
|
+
// Housekeeping must never take the daemon down.
|
|
368
|
+
logger.warn('daemon-loop', `Cache prune failed: ${err.message}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
340
372
|
// Periodically patch own mesh node (heartbeat)
|
|
341
373
|
if (taskCount > 0 && taskCount % (60000 / TASK_SLEEP_MS) === 0) {
|
|
342
374
|
try {
|
|
@@ -351,8 +383,22 @@ async function main() {
|
|
|
351
383
|
message: `Mesh re-enrolled via ${enrollment.method || 'unknown'}`,
|
|
352
384
|
});
|
|
353
385
|
}
|
|
354
|
-
const { patchOwnMeshNode } = await import('./mesh.mjs');
|
|
386
|
+
const { patchOwnMeshNode, ensureMeshNodeEntities } = await import('./mesh.mjs');
|
|
355
387
|
await patchOwnMeshNode();
|
|
388
|
+
// Self-patching describes only this machine, so peers that will never
|
|
389
|
+
// run a brain — a phone, an always-on host without one — stay
|
|
390
|
+
// undescribed forever. The leader fills those in; offline peers are
|
|
391
|
+
// reported rather than created, since a long-dead registration should
|
|
392
|
+
// not be re-legitimised by housekeeping.
|
|
393
|
+
if (await isLeader()) {
|
|
394
|
+
const ents = await ensureMeshNodeEntities();
|
|
395
|
+
if (ents.created.length) {
|
|
396
|
+
logger.info({
|
|
397
|
+
subsystem: 'daemon-loop',
|
|
398
|
+
message: `Created mesh entities for ${ents.created.join(', ')}`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
356
402
|
// renewLease is a no-op shim (re-checks isLeader); kept for API stability.
|
|
357
403
|
if (await isLeader()) {
|
|
358
404
|
await renewLease();
|