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.
Files changed (65) hide show
  1. package/bin/total-recall.mjs +31 -1
  2. package/docs/ARCHITECTURE.md +76 -9
  3. package/frontend/dist/assets/index-U7o-qU99.js +5742 -0
  4. package/frontend/dist/index.html +1 -1
  5. package/frontend/src/App.tsx +39 -1
  6. package/frontend/src/api/index.ts +1 -0
  7. package/frontend/src/api/plugins.ts +167 -0
  8. package/frontend/src/pages/ChatPage.tsx +44 -17
  9. package/frontend/src/pages/IntegrationsPage.tsx +7 -0
  10. package/frontend/src/pages/PluginsPage.spec.tsx +17 -0
  11. package/frontend/src/pages/PluginsPage.tsx +1331 -0
  12. package/package.json +1 -1
  13. package/scaffold/.agent/plugins/.gitkeep +0 -0
  14. package/scaffold/.agent/plugins/code-quality/README.md +16 -0
  15. package/scaffold/.agent/plugins/code-quality/cli.mjs +15 -0
  16. package/scaffold/.agent/plugins/code-quality/plugin.json +34 -0
  17. package/scaffold/.agent/plugins/git-sentinel/README.md +16 -0
  18. package/scaffold/.agent/plugins/git-sentinel/cli.mjs +86 -0
  19. package/scaffold/.agent/plugins/git-sentinel/plugin.json +34 -0
  20. package/scaffold/.agent/plugins/meta-harness/README.md +16 -0
  21. package/scaffold/.agent/plugins/meta-harness/cli.mjs +34 -0
  22. package/scaffold/.agent/plugins/meta-harness/plugin.json +34 -0
  23. package/scaffold/.agent/plugins/system-monitor/README.md +16 -0
  24. package/scaffold/.agent/plugins/system-monitor/cli.mjs +79 -0
  25. package/scaffold/.agent/plugins/system-monitor/generator.mjs +20 -0
  26. package/scaffold/.agent/plugins/system-monitor/plugin.json +38 -0
  27. package/scaffold/.agent/skills/meta-harness/SKILL.md +113 -0
  28. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +23 -0
  29. package/src/cli/agent.mjs +173 -0
  30. package/src/cli/connect.mjs +89 -4
  31. package/src/cli/deploy-ui.mjs +6 -0
  32. package/src/cli/harness.mjs +135 -0
  33. package/src/cli/ingest-openwiki.mjs +71 -2
  34. package/src/cli/init.mjs +1 -0
  35. package/src/cli/lint.mjs +47 -3
  36. package/src/cli/mesh.mjs +286 -0
  37. package/src/cli/plugin/create.mjs +186 -0
  38. package/src/cli/plugin/index.mjs +139 -0
  39. package/src/cli/plugin/install.mjs +141 -0
  40. package/src/cli/plugin/list.mjs +83 -0
  41. package/src/cli/plugin/remove.mjs +51 -0
  42. package/src/cli/plugin/search.mjs +37 -0
  43. package/src/cli/skill-projection.mjs +14 -0
  44. package/src/cli/wizard.html +9 -0
  45. package/src/core/agent-manager.mjs +290 -0
  46. package/src/core/cache-prune.mjs +341 -0
  47. package/src/core/context-cache.mjs +130 -0
  48. package/src/core/daemon-loop.mjs +47 -1
  49. package/src/core/evolving-context.mjs +171 -0
  50. package/src/core/mesh-activity.mjs +140 -0
  51. package/src/core/mesh.mjs +174 -1
  52. package/src/core/meta-harness.mjs +251 -0
  53. package/src/core/plugin-context.mjs +206 -0
  54. package/src/core/plugin-loader.mjs +210 -0
  55. package/src/core/runtime.mjs +65 -13
  56. package/src/core/surface.mjs +32 -10
  57. package/src/server/api.mjs +18 -2
  58. package/src/server/index.mjs +8 -2
  59. package/src/server/rest.mjs +4 -0
  60. package/src/server/routes/integrations.mjs +5 -1
  61. package/src/server/routes/mesh-presence.mjs +143 -0
  62. package/src/server/routes/models.mjs +42 -30
  63. package/src/server/routes/plugins.mjs +517 -0
  64. package/src/server/tools.mjs +266 -0
  65. 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
+ }
@@ -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();