total-recall-brain 3.20.1 → 3.21.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.
@@ -60,6 +60,8 @@ const COMMANDS = {
60
60
  recall: 'recall.mjs',
61
61
  search: 'recall.mjs',
62
62
  research: 'research.mjs',
63
+ proposals: 'proposals.mjs',
64
+ proposal: 'proposals.mjs',
63
65
  task: 'task.mjs',
64
66
  share: 'share.mjs',
65
67
  'generate-pat': 'generate-pat.mjs',
@@ -102,6 +104,7 @@ function printHelp() {
102
104
  recall Read-path hybrid search
103
105
  compile Rebuild instruction surfaces
104
106
  dream Memory consolidation cycle
107
+ proposals <cmd> Review / apply optimizer proposals (list|apply|revert)
105
108
  task <cmd> Enqueue daemon work (open envelope)
106
109
  daemon <cmd> Background worker (start|stop|status)
107
110
  skill <cmd> Skills registry, deploy, multi-repo sync
@@ -16,6 +16,37 @@ export async function fetchUsageStats(): Promise<UsageData> {
16
16
  return res.json()
17
17
  }
18
18
 
19
+ /** One provider's reported spend, or the reason we could not read it. */
20
+ export interface ProviderUsageEntry {
21
+ label: string
22
+ /** `ok` carries numbers; every other state carries a `reason` instead. */
23
+ state: 'ok' | 'no_key' | 'needs_admin_key' | 'unsupported' | 'error'
24
+ reason?: string
25
+ secret_name?: string
26
+ total_cost?: number
27
+ currency?: string
28
+ balance?: number
29
+ /** True when the figure is lifetime-to-date rather than the requested window. */
30
+ lifetime?: boolean
31
+ }
32
+
33
+ export interface ProviderUsage {
34
+ fetched_at: string
35
+ window_days: number
36
+ from_cache?: boolean
37
+ providers: Record<string, ProviderUsageEntry>
38
+ }
39
+
40
+ /**
41
+ * Spend as the providers report it, rather than estimated from local token counts.
42
+ * Served from an hourly cache unless `refresh` is set.
43
+ */
44
+ export async function fetchProviderUsage(refresh = false): Promise<ProviderUsage> {
45
+ const res = await apiFetch(`${API_BASE}/api/usage/providers${refresh ? '?refresh=1' : ''}`)
46
+ if (!res.ok) throw new Error(`Provider usage API error: ${res.status}`)
47
+ return res.json()
48
+ }
49
+
19
50
  export async function fetchLogs(type: 'server' | 'daemon'): Promise<{ content: string }> {
20
51
  const res = await apiFetch(`${API_BASE}/api/logs/${type}`)
21
52
  if (!res.ok) throw new Error(`Logs API error: ${res.status}`)
@@ -1,5 +1,6 @@
1
1
  import { useState, useEffect, useMemo } from 'react';
2
- import { fetchUsageStats, fetchConfigJson } from '../api';
2
+ import { fetchUsageStats, fetchConfigJson, fetchProviderUsage } from '../api';
3
+ import type { ProviderUsage } from '../api';
3
4
  import type { ConfigJson, UsageData } from '../types';
4
5
  import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
5
6
 
@@ -8,6 +9,9 @@ export default function UsagePage() {
8
9
  const [config, setConfig] = useState<ConfigJson | null>(null);
9
10
  const [loading, setLoading] = useState(true);
10
11
  const [error, setError] = useState<string | null>(null);
12
+ const [providerUsage, setProviderUsage] = useState<ProviderUsage | null>(null);
13
+ const [providerLoading, setProviderLoading] = useState(false);
14
+ const [providerError, setProviderError] = useState<string | null>(null);
11
15
 
12
16
  const fetchUsageAndConfig = async () => {
13
17
  try {
@@ -24,6 +28,18 @@ export default function UsagePage() {
24
28
  }
25
29
  };
26
30
 
31
+ const loadProviderUsage = async (refresh = false) => {
32
+ setProviderLoading(true);
33
+ try {
34
+ setProviderUsage(await fetchProviderUsage(refresh));
35
+ setProviderError(null);
36
+ } catch (err: unknown) {
37
+ setProviderError((err as Error).message || 'Failed to read provider billing data.');
38
+ } finally {
39
+ setProviderLoading(false);
40
+ }
41
+ };
42
+
27
43
  useEffect(() => {
28
44
  // eslint-disable-next-line react-hooks/set-state-in-effect -- legitimate statistics sync on mount
29
45
  void fetchUsageAndConfig();
@@ -31,6 +47,13 @@ export default function UsagePage() {
31
47
  return () => clearInterval(interval);
32
48
  }, []);
33
49
 
50
+ useEffect(() => {
51
+ // Deliberately not on the 5s ledger interval — this hits live provider
52
+ // billing APIs. The server caches hourly; refreshing is a manual action.
53
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount
54
+ void loadProviderUsage();
55
+ }, []);
56
+
34
57
 
35
58
 
36
59
  const dailyCap = config?.budget?.budget?.daily_cap_usd ?? 5.0;
@@ -611,6 +634,69 @@ export default function UsagePage() {
611
634
  </div>
612
635
  </div>
613
636
 
637
+ {/* ── Provider-Reported Spend ──
638
+ Everything above is estimated locally: observed tokens × a hardcoded
639
+ price table. This panel is what the providers themselves bill. */}
640
+ <div className="card" style={{ padding: 24, marginTop: 24, background: 'rgba(18, 18, 26, 0.6)', backdropFilter: 'blur(8px)', border: '1px solid var(--border)' }}>
641
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', borderBottom: '1px solid var(--border)', paddingBottom: 10, marginBottom: 16 }}>
642
+ <h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: 0.5, margin: 0 }}>
643
+ Reported by Provider
644
+ </h3>
645
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
646
+ {providerUsage?.fetched_at && (
647
+ <span style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
648
+ {new Date(providerUsage.fetched_at).toLocaleString()}
649
+ {providerUsage.from_cache ? ' (cached)' : ''}
650
+ </span>
651
+ )}
652
+ <button
653
+ type="button"
654
+ onClick={() => { void loadProviderUsage(true); }}
655
+ disabled={providerLoading}
656
+ style={{ fontSize: 11, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border)', background: 'transparent', color: 'var(--text-secondary)', cursor: providerLoading ? 'wait' : 'pointer' }}
657
+ >
658
+ {providerLoading ? 'Refreshing…' : 'Refresh'}
659
+ </button>
660
+ </div>
661
+ </div>
662
+
663
+ {providerError && (
664
+ <div style={{ fontSize: 12, color: 'var(--error)', marginBottom: 12 }}>{providerError}</div>
665
+ )}
666
+
667
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16 }}>
668
+ {Object.entries(providerUsage?.providers || {}).map(([id, p]) => (
669
+ <div key={id} style={{
670
+ border: '1px solid var(--border)',
671
+ borderLeft: `3px solid ${p.state === 'ok' ? 'var(--success)' : p.state === 'error' ? 'var(--error)' : 'var(--warning)'}`,
672
+ borderRadius: 8,
673
+ padding: 16,
674
+ }}>
675
+ <div style={{ fontSize: 13, fontWeight: 700, color: '#fff', marginBottom: 8 }}>{p.label}</div>
676
+ {p.state === 'ok' ? (
677
+ <>
678
+ <div style={{ fontSize: 22, fontWeight: 700, color: '#fff' }}>
679
+ ${(p.total_cost ?? 0).toFixed(2)}
680
+ </div>
681
+ <div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4 }}>
682
+ {/* Never label a lifetime figure as a windowed one. */}
683
+ {p.lifetime ? 'lifetime to date' : `last ${providerUsage?.window_days ?? 30} days`}
684
+ {typeof p.balance === 'number' ? ` · $${p.balance.toFixed(2)} remaining` : ''}
685
+ </div>
686
+ </>
687
+ ) : (
688
+ <div style={{ fontSize: 11, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
689
+ {p.reason}
690
+ </div>
691
+ )}
692
+ </div>
693
+ ))}
694
+ {!providerUsage && !providerError && (
695
+ <div style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>Loading provider billing data…</div>
696
+ )}
697
+ </div>
698
+ </div>
699
+
614
700
  </div>
615
701
  );
616
702
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.20.1",
3
+ "version": "3.21.0",
4
4
  "description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -0,0 +1,216 @@
1
+ import path from 'node:path';
2
+ import { resolveBrainDir, parseLayerFlag } from './agent-dir.mjs';
3
+ import {
4
+ listProposals,
5
+ getProposal,
6
+ applyProposal,
7
+ setProposalStatus,
8
+ revertProposal,
9
+ hasHandler,
10
+ AUTO_APPLICABLE_TOPICS,
11
+ } from '../core/proposal-applier.mjs';
12
+ import { findStaleNodes } from '../core/optimizer.mjs';
13
+
14
+ function printHelp() {
15
+ console.log(`
16
+ total-recall proposals — Review and apply optimizer proposals
17
+
18
+ Usage: total-recall proposals <subcommand> [options]
19
+
20
+ Subcommands:
21
+ list Show open proposals (default)
22
+ show <id> Full detail for one proposal
23
+ apply <id> Perform the proposed work
24
+ reject <id> [reason] Refuse a proposal permanently
25
+ revert <id> Undo an applied proposal from its snapshot
26
+ supersede --topic <t> Bulk-close every open proposal of a retired topic
27
+ stale Nodes due for re-verification (read-only query)
28
+
29
+ Options:
30
+ --status <s> Filter: draft | accepted | applied | rejected | superseded | all
31
+ --topic <t> Filter by proposal topic
32
+ --dry-run With apply: show the change without writing
33
+ --global Use the global brain
34
+ --project Use the project brain
35
+ --help, -h Show this help
36
+
37
+ ${AUTO_APPLICABLE_TOPICS.size > 0
38
+ ? ` Applied automatically during the dream cycle: ${[...AUTO_APPLICABLE_TOPICS].join(', ')}.
39
+ Everything else waits here for review.`
40
+ : ` Nothing is applied automatically in this release — every proposal waits for
41
+ you to run \`apply\`. Applying is non-destructive (duplicates are superseded,
42
+ never deleted) and \`revert\` restores the exact prior bytes.`}
43
+
44
+ Examples:
45
+ npx total-recall proposals
46
+ npx total-recall proposals show prop_a1b2c3d4e5f6
47
+ npx total-recall proposals apply prop_a1b2c3d4e5f6 --dry-run
48
+ npx total-recall proposals reject prop_a1b2c3d4e5f6 "targets are not duplicates"
49
+ npx total-recall proposals revert prop_a1b2c3d4e5f6
50
+ `);
51
+ }
52
+
53
+ const STATUS_ICON = {
54
+ draft: '○',
55
+ accepted: '◆',
56
+ applied: '✓',
57
+ rejected: '✗',
58
+ superseded: '~',
59
+ };
60
+
61
+ function flagValue(args, name) {
62
+ const i = args.indexOf(name);
63
+ return i !== -1 ? args[i + 1] : null;
64
+ }
65
+
66
+ export default async function proposals(args) {
67
+ if (args.includes('--help') || args.includes('-h')) {
68
+ printHelp();
69
+ return;
70
+ }
71
+
72
+ const { layer, remainingArgs } = parseLayerFlag(args);
73
+ const brainDir = resolveBrainDir(layer);
74
+ const vaultDir = path.join(brainDir, 'memory-vault');
75
+
76
+ const sub = remainingArgs[0] && !remainingArgs[0].startsWith('--') ? remainingArgs[0] : 'list';
77
+ const id = remainingArgs[1] && !remainingArgs[1].startsWith('--') ? remainingArgs[1] : null;
78
+
79
+ switch (sub) {
80
+ case 'list': {
81
+ const statusFlag = flagValue(remainingArgs, '--status');
82
+ // Default view is the actionable queue. Terminal proposals are history, not
83
+ // work — `--status all` shows them when you actually want them.
84
+ const status = statusFlag === 'all' ? undefined : (statusFlag || ['draft', 'accepted']);
85
+ const items = listProposals(vaultDir, {
86
+ status,
87
+ topic: flagValue(remainingArgs, '--topic') || undefined,
88
+ });
89
+
90
+ if (items.length === 0) {
91
+ console.log('\n No proposals match.\n');
92
+ return;
93
+ }
94
+
95
+ console.log(`\n ${items.length} proposal(s)\n`);
96
+ for (const p of items) {
97
+ const auto = AUTO_APPLICABLE_TOPICS.has(p.topic) && hasHandler(p.topic) ? ' [auto]' : '';
98
+ console.log(` ${STATUS_ICON[p.status] || '?'} ${p.proposal_id} ${p.topic}${auto}`);
99
+ console.log(` ${p.summary || p.title}`);
100
+ if (p.review_reason) console.log(` ↳ ${p.review_reason}`);
101
+ if (p.rejection_reason) console.log(` ↳ ${p.rejection_reason}`);
102
+ }
103
+ console.log('\n Apply one: npx total-recall proposals apply <id>\n');
104
+ return;
105
+ }
106
+
107
+ case 'show': {
108
+ if (!id) { console.error(' ❌ Usage: proposals show <id>'); process.exitCode = 1; return; }
109
+ const p = getProposal(vaultDir, id);
110
+ if (!p) { console.error(` ❌ Proposal not found: ${id}`); process.exitCode = 1; return; }
111
+ console.log(`\n ${p.proposal_id} [${p.status}] ${p.topic}\n`);
112
+ console.log(` Summary: ${p.summary || p.title}`);
113
+ console.log(` Target: ${p.target_path || '(none)'}`);
114
+ console.log(` Proposed: ${p.proposed_at} by ${p.proposed_by}`);
115
+ if (p.reviewed_at) console.log(` Reviewed: ${p.reviewed_at} by ${p.reviewed_by}`);
116
+ if (p.applied_at) console.log(` Applied: ${p.applied_at} by ${p.applied_by}`);
117
+ if (p.review_reason) console.log(` Note: ${p.review_reason}`);
118
+ if (p.rejection_reason) console.log(` Rejected: ${p.rejection_reason}`);
119
+ console.log(`\n ${p.rationale || p.description || ''}\n`);
120
+ return;
121
+ }
122
+
123
+ case 'apply': {
124
+ if (!id) { console.error(' ❌ Usage: proposals apply <id>'); process.exitCode = 1; return; }
125
+ const dryRun = remainingArgs.includes('--dry-run');
126
+ const result = await applyProposal(vaultDir, id, { dryRun, actor: 'cli' });
127
+ if (!result.ok) {
128
+ console.error(` ❌ Not applied: ${result.reason}`);
129
+ process.exitCode = 1;
130
+ return;
131
+ }
132
+ if (dryRun) {
133
+ console.log(`\n Dry run — would keep ${result.canonical}, supersede ${result.merged.join(', ')}\n`);
134
+ return;
135
+ }
136
+ console.log(`\n ✓ Applied ${id}`);
137
+ console.log(` Kept: ${result.canonical}`);
138
+ console.log(` Merged: ${result.merged.join(', ')}`);
139
+ console.log(`\n Undo: npx total-recall proposals revert ${id}\n`);
140
+ return;
141
+ }
142
+
143
+ case 'reject': {
144
+ if (!id) { console.error(' ❌ Usage: proposals reject <id> [reason]'); process.exitCode = 1; return; }
145
+ const reason = remainingArgs.slice(2).filter(a => !a.startsWith('--')).join(' ') || 'Rejected by user.';
146
+ setProposalStatus(vaultDir, id, 'rejected', { rejection_reason: reason, reviewed_by: 'user' });
147
+ console.log(`\n ✗ Rejected ${id}: ${reason}\n`);
148
+ return;
149
+ }
150
+
151
+ case 'supersede': {
152
+ // Bulk-resolve a whole topic. Needed when a generator is retired: its
153
+ // already-filed tickets are neither wrong nor refused, they just describe
154
+ // work that now flows somewhere else. Without this they sit in `list`
155
+ // forever, and the terminal-only pruner correctly refuses to touch them.
156
+ const topic = flagValue(remainingArgs, '--topic');
157
+ if (!topic) {
158
+ console.error(' ❌ Usage: proposals supersede --topic <topic> [--reason "..."]');
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+ const reason = flagValue(remainingArgs, '--reason') || `Topic "${topic}" is no longer generated.`;
163
+ const open = listProposals(vaultDir, { status: ['draft', 'accepted'], topic });
164
+ if (open.length === 0) {
165
+ console.log(`\n No open proposals under topic "${topic}".\n`);
166
+ return;
167
+ }
168
+ if (!remainingArgs.includes('--yes')) {
169
+ console.log(`\n Would supersede ${open.length} open "${topic}" proposal(s).`);
170
+ console.log(` Reason: ${reason}`);
171
+ console.log('\n Re-run with --yes to apply.\n');
172
+ return;
173
+ }
174
+ for (const p of open) {
175
+ setProposalStatus(vaultDir, p.proposal_id, 'superseded', { superseded_reason: reason });
176
+ }
177
+ console.log(`\n ~ Superseded ${open.length} "${topic}" proposal(s).\n`);
178
+ return;
179
+ }
180
+
181
+ case 'revert': {
182
+ if (!id) { console.error(' ❌ Usage: proposals revert <id>'); process.exitCode = 1; return; }
183
+ try {
184
+ const { reverted, deleted } = revertProposal(vaultDir, id);
185
+ console.log(`\n ✓ Reverted ${id} — ${reverted} file(s) restored, ${deleted} removed.\n`);
186
+ } catch (err) {
187
+ console.error(` ❌ ${err.message}`);
188
+ process.exitCode = 1;
189
+ }
190
+ return;
191
+ }
192
+
193
+ case 'stale': {
194
+ // Answers the question the old stale-knowledge-refresh generator used to
195
+ // answer by writing one file per node, every cycle, forever.
196
+ const stale = findStaleNodes(vaultDir);
197
+ if (stale.length === 0) {
198
+ console.log('\n No stale high-importance nodes.\n');
199
+ return;
200
+ }
201
+ console.log(`\n ${stale.length} node(s) due for re-verification\n`);
202
+ for (const n of stale.slice(0, 40)) {
203
+ const days = Math.floor((Date.now() - new Date(n.last_accessed).getTime()) / 86400000);
204
+ console.log(` ${String(days).padStart(4)}d [i${n.importance}] ${n.slug}`);
205
+ }
206
+ if (stale.length > 40) console.log(`\n … and ${stale.length - 40} more.`);
207
+ console.log('\n The dream cycle queues these for research automatically.\n');
208
+ return;
209
+ }
210
+
211
+ default:
212
+ console.error(` ❌ Unknown subcommand: ${sub}`);
213
+ printHelp();
214
+ process.exitCode = 1;
215
+ }
216
+ }
@@ -53,9 +53,22 @@ export async function runRebuild(options = {}) {
53
53
  }
54
54
 
55
55
  // Perform full rebuild
56
+ // Discard the cheap, fully-derivable indexes — but NOT embeddings.db.
57
+ //
58
+ // `fs.rmSync(derivedDir)` used to take the whole directory, which threw away
59
+ // every vector. Embeddings are not free to re-derive: each one is a provider
60
+ // call. Worse, the rebuild that followed was fire-and-forget, so the process
61
+ // exited before replacing them and the index stayed empty — that is how this
62
+ // brain ended up with 520 nodes and 0 embeddings while reporting success.
63
+ //
64
+ // The index is content-hash incremental and prunes slugs no longer in the
65
+ // vault, so keeping it across a rebuild is correct as well as cheap.
56
66
  console.log('🗑️ Discarding existing derived indexes...');
57
67
  if (fs.existsSync(derivedDir)) {
58
- fs.rmSync(derivedDir, { recursive: true, force: true });
68
+ for (const entry of fs.readdirSync(derivedDir)) {
69
+ if (entry.startsWith('embeddings.db')) continue; // also skips -wal / -shm
70
+ fs.rmSync(path.join(derivedDir, entry), { recursive: true, force: true });
71
+ }
59
72
  }
60
73
 
61
74
  console.log('🏗️ Recompiling surface from canonical vault...');
@@ -64,7 +77,19 @@ export async function runRebuild(options = {}) {
64
77
  console.log(`✅ Processed ${stats.nodesProcessed} canonical memory nodes.`);
65
78
  console.log(`✅ Injected memory into ${stats.skillsInjected} skill files.`);
66
79
  console.log(`✅ Rebuilt graph-index.jsonl, memory-layers.jsonl, and skill-routes.jsonl.`);
67
-
80
+
81
+ // Report vector coverage explicitly. "Post-build verification passed: 0 drift"
82
+ // checks the jsonl indexes only — it passed happily on a brain with 520 nodes
83
+ // and 0 embeddings, i.e. keyword-only recall that returns plausible-looking
84
+ // results from the wrong places.
85
+ if (stats.semanticUnavailable) {
86
+ console.log(`⚠️ Vector search is OFF — embeddings unavailable${stats.semanticError ? `: ${stats.semanticError}` : ''}.`);
87
+ console.log(` Recall will fall back to keyword matching.`);
88
+ } else {
89
+ console.log(`✅ Embeddings: ${stats.semanticIndexed} built, ${stats.semanticSkipped} unchanged${stats.semanticFailed ? `, ${stats.semanticFailed} FAILED` : ''}.`);
90
+ }
91
+
92
+
68
93
  // Verify
69
94
  const drift = detectIndexDrift(vaultDir, derivedDir);
70
95
  if (!drift.drifted) {
@@ -16,6 +16,7 @@ import fs from 'fs';
16
16
  import { createScheduler, updateTaskStatus, persistTaskToDisk } from './scheduler.mjs';
17
17
  import { scanAndIngest } from './session-watcher.mjs';
18
18
  import { syncAllRepos } from './repo-sync.mjs';
19
+ import { refreshProviderUsage } from './usage-fetcher.mjs';
19
20
  import { logger } from './logger.mjs';
20
21
  import { updateQueueItem } from './research-queue.mjs';
21
22
  import { agentDir, brainDir } from './config.mjs';
@@ -365,6 +366,18 @@ async function main() {
365
366
  } catch (err) {
366
367
  logger.info({ subsystem: 'daemon-loop', message: `Periodic repo-sync failed: ${err.message}` });
367
368
  }
369
+
370
+ // Provider usage/billing refresh. Self-throttles to once an hour
371
+ // internally, so calling it every cycle is safe.
372
+ try {
373
+ const usage = await refreshProviderUsage(BRAIN_DIR);
374
+ if (!usage.from_cache) {
375
+ const ok = Object.values(usage.providers).filter(p => p.state === 'ok').length;
376
+ logger.info({ subsystem: 'daemon-loop', message: `Provider usage refreshed (${ok} provider(s) reporting)` });
377
+ }
378
+ } catch (err) {
379
+ logger.info({ subsystem: 'daemon-loop', message: `Provider usage refresh failed: ${err.message}` });
380
+ }
368
381
  }
369
382
 
370
383
  const scheduler = createScheduler({
@@ -79,6 +79,46 @@ function writeDailyNote(vaultDir, summaryLines) {
79
79
  import { detectConflicts, quarantineConflict } from './steering.mjs';
80
80
  import { compileSurface } from './surface.mjs';
81
81
 
82
+ /**
83
+ * Delete only proposals that have reached a terminal state.
84
+ *
85
+ * The old code ran proposals through the same blind 3-day age prune as log
86
+ * files, so every un-actioned proposal was destroyed before anyone could act on
87
+ * it — which is a large part of why the feature looked harmless for so long: the
88
+ * queue emptied itself. An open proposal is pending work and is never pruned by
89
+ * age, and neither is a `rejected` tombstone; only `applied` and `superseded`
90
+ * tickets expire.
91
+ */
92
+ export function pruneResolvedProposals(proposalsDir, maxAgeMs, now = Date.now()) {
93
+ if (!fs.existsSync(proposalsDir)) return 0;
94
+ // `rejected` is deliberately NOT pruned by age: it is a tombstone that stops
95
+ // the generator re-filing the same rejected proposal every cycle. Deleting it
96
+ // reopens that leak on a 3-day timer. Only genuinely finished work expires.
97
+ const resolved = new Set(['applied', 'superseded']);
98
+ let removed = 0;
99
+ let files;
100
+ try {
101
+ files = fs.readdirSync(proposalsDir).filter(f => f.endsWith('.md'));
102
+ } catch {
103
+ return 0;
104
+ }
105
+ for (const file of files) {
106
+ const full = path.join(proposalsDir, file);
107
+ try {
108
+ if (now - fs.statSync(full).mtimeMs <= maxAgeMs) continue;
109
+ const { data } = matter(fs.readFileSync(full, 'utf8'));
110
+ // Unparseable or status-less files are left alone: deleting a proposal we
111
+ // cannot read is exactly the silent data loss this function exists to stop.
112
+ if (!resolved.has(data.status)) continue;
113
+ fs.unlinkSync(full);
114
+ removed++;
115
+ } catch (err) {
116
+ logger.debug('dream: skipping proposal during prune', { file, err: err.message });
117
+ }
118
+ }
119
+ return removed;
120
+ }
121
+
82
122
  /**
83
123
  * Phase 5: Automatic Storage & Memory Pruning.
84
124
  * Keeps the VFS local directories clean of old logs, draft files,
@@ -135,7 +175,7 @@ export function autoPruneStorage(brainDir, vaultDir, conflictsDir) {
135
175
  };
136
176
 
137
177
  pruneDir(logsDir, logMaxAgeMs);
138
- pruneDir(proposalsDir, logMaxAgeMs);
178
+ pruneResolvedProposals(proposalsDir, logMaxAgeMs, now);
139
179
  pruneDir(inboxDir, draftMaxAgeMs); // Keeps the local dev inbox fully clean of automatic research noise
140
180
  if (conflictsDir) pruneDir(conflictsDir, draftMaxAgeMs);
141
181
 
@@ -212,13 +252,23 @@ export function autoPruneStorage(brainDir, vaultDir, conflictsDir) {
212
252
  }
213
253
 
214
254
  import {
215
- generateMemoryCleanupProposals,
255
+ generateMemoryCleanupProposals,
216
256
  generateStaleKnowledgeRefreshProposals,
217
- evaluateProposalGate
257
+ evaluateProposalGate,
258
+ dedupeProposals,
259
+ refreshStaleKnowledge
218
260
  } from './optimizer.mjs';
261
+ import { applyAcceptedProposals } from './proposal-applier.mjs';
219
262
 
220
263
  const DREAM_PROMOTION_THRESHOLD = 0.7;
221
264
 
265
+ // Master switch for the stale-knowledge-refresh *ticket generator* (see PHASE 4).
266
+ // Disabled 2026-08-01 and superseded: staleness is now handled by
267
+ // refreshStaleKnowledge(), which enqueues research the daemon can actually
268
+ // perform instead of filing one .md per stale node every cycle (16,401 unread
269
+ // tickets at its peak). There is no reason to turn this back on.
270
+ const ENABLE_STALE_KNOWLEDGE_REFRESH = false;
271
+
222
272
  /**
223
273
  * Phase 1: Light Sleep. Scan for modifications.
224
274
  */
@@ -474,14 +524,36 @@ export async function runDreamCycle({
474
524
  let proposalCount = 0;
475
525
  try {
476
526
  const cleanupProposals = await generateMemoryCleanupProposals(vaultDir);
477
- const staleProposals = await generateStaleKnowledgeRefreshProposals(vaultDir);
478
- const allProposals = [...cleanupProposals, ...staleProposals];
527
+
528
+ // Stale-knowledge-refresh generation is DISABLED. It filed one ticket per
529
+ // high-importance node untouched for 30 days — ~600 of them — and nothing
530
+ // in this system ever reads or applies a proposal, so every one was write-
531
+ // only noise. Flip to true to re-enable; the dedupe gate below keeps it
532
+ // bounded at one open ticket per target if you do.
533
+ const staleProposals = ENABLE_STALE_KNOWLEDGE_REFRESH
534
+ ? await generateStaleKnowledgeRefreshProposals(vaultDir)
535
+ : [];
536
+
537
+ // The generators are pure functions of vault state, so every cycle produces
538
+ // the SAME proposals as the last one — a node stale on Monday is still stale
539
+ // on Tuesday. Without this gate each cycle re-filed all of them under fresh
540
+ // random slugs; the global vault accumulated 16,401 proposals for 594 real
541
+ // targets (~28 copies each, ~5k/day) until proposals were 95% of the vault.
542
+ const { kept: allProposals, suppressed } = dedupeProposals(
543
+ [...cleanupProposals, ...staleProposals],
544
+ vaultDir,
545
+ );
546
+ if (suppressed > 0) {
547
+ logger.info('dream', `Suppressed ${suppressed} duplicate proposals (already open on disk).`);
548
+ }
479
549
 
480
550
  if (allProposals.length > 0) {
481
551
  logger.info('dream', `Generated ${allProposals.length} optimization proposals.`);
482
552
  for (const p of allProposals) {
483
- const passed = await evaluateProposalGate(p, null);
484
- logger.info('dream', `Proposal [${p.category}]: ${p.summary} -> ${passed ? 'ACCEPTED' : 'REJECTED'}`);
553
+ // Pass vaultDir so the gate can verify the proposal against live state
554
+ // rather than grading the optimizer's own rationale string.
555
+ const passed = await evaluateProposalGate(p, null, vaultDir);
556
+ logger.info('dream', `Proposal [${p.category}]: ${p.summary} -> ${p.status.toUpperCase()}`);
485
557
  // Contract write under vault root → proposals/<slug>.md
486
558
  await writeNode(p, vaultDir);
487
559
  if (passed) proposalCount++;
@@ -489,10 +561,29 @@ export async function runDreamCycle({
489
561
  } else {
490
562
  logger.info('dream', 'No new proposals generated.');
491
563
  }
564
+
565
+ // Consume what the gate accepted. Without this the whole phase is write-only:
566
+ // proposals accumulated for months and the 3-day pruner deleted every one.
567
+ const applied = await applyAcceptedProposals(vaultDir, { actor: 'dream' });
568
+ if (applied.applied || applied.superseded || applied.failed) {
569
+ logger.info('dream', `Applied ${applied.applied} proposals (${applied.superseded} superseded, ${applied.failed} failed).`);
570
+ }
492
571
  } catch (err) {
493
572
  logger.error('dream', `Optimizer failed: ${err.message}`);
494
573
  }
495
574
 
575
+ // Staleness is handled here rather than as proposals: the research daemon can
576
+ // actually re-verify a stale node and commit the result, whereas a proposal
577
+ // could only ask someone to. Rate-limited so the backlog drains over cycles.
578
+ try {
579
+ const { enqueued, stale } = await refreshStaleKnowledge(vaultDir);
580
+ if (stale > 0) {
581
+ logger.info('dream', `Staleness sweep: ${stale} stale nodes, ${enqueued.length} queued for research this cycle.`);
582
+ }
583
+ } catch (err) {
584
+ logger.error('dream', `Staleness sweep failed: ${err.message}`);
585
+ }
586
+
496
587
  // Write daily note summary (native SSSS node; Obsidian Daily Notes reads it directly)
497
588
  try {
498
589
  const existingNodes = getNodes(vaultDir);