metame-cli 1.4.18 → 1.4.20

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.
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * memory-gc.js — Nightly Fact Garbage Collection
5
+ *
6
+ * Archives stale, low-frequency facts from memory.db by marking them
7
+ * with conflict_status = 'ARCHIVED' (soft delete, fully auditable).
8
+ *
9
+ * GC criteria (ALL must be true):
10
+ * 1. last_searched_at older than 30 days (i.e. datetime < now-30d), OR NULL and created_at also older than 30 days
11
+ * 2. search_count < 3
12
+ * 3. superseded_by IS NULL (already-superseded facts excluded)
13
+ * 4. conflict_status IS NULL OR conflict_status = 'OK' (skip CONFLICT/ARCHIVED)
14
+ * 5. relation NOT IN protected set (user_pref, workflow_rule, arch_convention never archived)
15
+ *
16
+ * Protected relations are permanently excluded — they are high-value guardrails
17
+ * that must survive regardless of search frequency.
18
+ *
19
+ * Designed to run nightly at 02:00 via daemon.yaml scheduler.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const os = require('os');
27
+
28
+ const HOME = os.homedir();
29
+ const METAME_DIR = path.join(HOME, '.metame');
30
+ const DB_PATH = path.join(METAME_DIR, 'memory.db');
31
+ const LOCK_FILE = path.join(METAME_DIR, 'memory-gc.lock');
32
+ const GC_LOG_FILE = path.join(METAME_DIR, 'memory_gc_log.jsonl');
33
+
34
+ // Relations that are permanently protected from archival
35
+ const PROTECTED_RELATIONS = ['user_pref', 'workflow_rule', 'arch_convention', 'config_fact'];
36
+
37
+ // GC threshold: facts older than this many days are candidates
38
+ const STALE_DAYS = 30;
39
+ // GC threshold: facts with fewer searches than this are candidates
40
+ const MIN_SEARCH_COUNT = 3;
41
+ // Lock timeout: if a lock is older than this, it's stale and safe to break
42
+ const LOCK_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
43
+
44
+ /**
45
+ * Acquire atomic lock using O_EXCL — prevents concurrent GC runs.
46
+ * Returns the lock fd, or throws if lock is held by a live process.
47
+ */
48
+ function acquireLock() {
49
+ try {
50
+ const fd = fs.openSync(LOCK_FILE, 'wx');
51
+ fs.writeSync(fd, process.pid.toString());
52
+ fs.closeSync(fd);
53
+ return true;
54
+ } catch (e) {
55
+ if (e.code === 'EEXIST') {
56
+ // Check if the lock is stale (crashed process left it behind)
57
+ try {
58
+ const lockAge = Date.now() - fs.statSync(LOCK_FILE).mtimeMs;
59
+ if (lockAge < LOCK_TIMEOUT_MS) {
60
+ console.log('[MEMORY-GC] Already running (lock held), skipping.');
61
+ return false;
62
+ }
63
+ // Stale lock — remove and re-acquire
64
+ fs.unlinkSync(LOCK_FILE);
65
+ const fd = fs.openSync(LOCK_FILE, 'wx');
66
+ fs.writeSync(fd, process.pid.toString());
67
+ fs.closeSync(fd);
68
+ return true;
69
+ } catch {
70
+ console.log('[MEMORY-GC] Could not acquire lock, skipping.');
71
+ return false;
72
+ }
73
+ }
74
+ throw e;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Release the atomic lock.
80
+ */
81
+ function releaseLock() {
82
+ try { fs.unlinkSync(LOCK_FILE); } catch { /* non-fatal */ }
83
+ }
84
+
85
+ /**
86
+ * Append a GC run record to the audit log.
87
+ */
88
+ function writeGcLog(record) {
89
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...record }) + '\n';
90
+ try {
91
+ fs.mkdirSync(path.dirname(GC_LOG_FILE), { recursive: true });
92
+ fs.appendFileSync(GC_LOG_FILE, line, 'utf8');
93
+ } catch (e) {
94
+ console.log(`[MEMORY-GC] Warning: could not write GC log: ${e.message}`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Get the on-disk size of the database file in bytes.
100
+ */
101
+ function getDbSizeBytes() {
102
+ try { return fs.statSync(DB_PATH).size; } catch { return 0; }
103
+ }
104
+
105
+ /**
106
+ * Main GC run.
107
+ */
108
+ function run() {
109
+ console.log('[MEMORY-GC] Starting GC run...');
110
+
111
+ if (!fs.existsSync(DB_PATH)) {
112
+ console.log('[MEMORY-GC] memory.db not found, nothing to GC.');
113
+ return;
114
+ }
115
+
116
+ if (!acquireLock()) {
117
+ return;
118
+ }
119
+
120
+ let db;
121
+ try {
122
+ const { DatabaseSync } = require('node:sqlite');
123
+ db = new DatabaseSync(DB_PATH);
124
+
125
+ db.exec('PRAGMA journal_mode = WAL');
126
+ db.exec('PRAGMA busy_timeout = 5000');
127
+
128
+ const dbSizeBefore = getDbSizeBytes();
129
+
130
+ // ── Ensure ARCHIVED status column accepts the new value ──
131
+ // conflict_status was created with NOT NULL DEFAULT 'OK'; ARCHIVED is a new valid state.
132
+ // No schema change needed — we just write the string value directly.
133
+
134
+ const protectedPlaceholders = PROTECTED_RELATIONS.map(() => '?').join(', ');
135
+
136
+ // ── DRY RUN: count candidates and protected exclusions ──
137
+ console.log(`[MEMORY-GC] Scanning facts older than ${STALE_DAYS} days with search_count < ${MIN_SEARCH_COUNT}...`);
138
+
139
+ const countCandidatesStmt = db.prepare(`
140
+ SELECT COUNT(*) AS cnt
141
+ FROM facts
142
+ WHERE (
143
+ (last_searched_at IS NOT NULL AND last_searched_at < datetime('now', '-${STALE_DAYS} days'))
144
+ OR
145
+ (last_searched_at IS NULL AND created_at < datetime('now', '-${STALE_DAYS} days'))
146
+ )
147
+ AND search_count < ${MIN_SEARCH_COUNT}
148
+ AND superseded_by IS NULL
149
+ AND (conflict_status IS NULL OR conflict_status = 'OK')
150
+ AND relation NOT IN (${protectedPlaceholders})
151
+ AND source_type != 'manual'
152
+ `);
153
+ const candidateCount = countCandidatesStmt.get(...PROTECTED_RELATIONS).cnt;
154
+
155
+ // Count how many facts would be skipped due to the protected-relation guard
156
+ const countProtectedStmt = db.prepare(`
157
+ SELECT COUNT(*) AS cnt
158
+ FROM facts
159
+ WHERE (
160
+ (last_searched_at IS NOT NULL AND last_searched_at < datetime('now', '-${STALE_DAYS} days'))
161
+ OR
162
+ (last_searched_at IS NULL AND created_at < datetime('now', '-${STALE_DAYS} days'))
163
+ )
164
+ AND search_count < ${MIN_SEARCH_COUNT}
165
+ AND superseded_by IS NULL
166
+ AND (conflict_status IS NULL OR conflict_status = 'OK')
167
+ AND (relation IN (${protectedPlaceholders}) OR source_type = 'manual')
168
+ `);
169
+ const protectedCount = countProtectedStmt.get(...PROTECTED_RELATIONS).cnt;
170
+
171
+ console.log(`[MEMORY-GC] Found ${candidateCount} candidates (excluded ${protectedCount} protected facts)`);
172
+
173
+ let archivedCount = 0;
174
+
175
+ db.exec('BEGIN IMMEDIATE');
176
+ try {
177
+ if (candidateCount > 0) {
178
+ // ── EXECUTE: archive the candidates ──
179
+ const updateStmt = db.prepare(`
180
+ UPDATE facts
181
+ SET conflict_status = 'ARCHIVED',
182
+ updated_at = datetime('now')
183
+ WHERE (
184
+ (last_searched_at IS NOT NULL AND last_searched_at < datetime('now', '-${STALE_DAYS} days'))
185
+ OR
186
+ (last_searched_at IS NULL AND created_at < datetime('now', '-${STALE_DAYS} days'))
187
+ )
188
+ AND search_count < ${MIN_SEARCH_COUNT}
189
+ AND superseded_by IS NULL
190
+ AND (conflict_status IS NULL OR conflict_status = 'OK')
191
+ AND relation NOT IN (${protectedPlaceholders})
192
+ AND source_type != 'manual'
193
+ `);
194
+
195
+ const result = updateStmt.run(...PROTECTED_RELATIONS);
196
+ archivedCount = result.changes;
197
+
198
+ console.log(`[MEMORY-GC] Archived ${archivedCount} facts → conflict_status = 'ARCHIVED'`);
199
+ } else {
200
+ console.log('[MEMORY-GC] No candidates to archive.');
201
+ }
202
+ db.exec('COMMIT');
203
+ } catch (e) {
204
+ try { db.exec('ROLLBACK'); } catch {}
205
+ throw e;
206
+ }
207
+
208
+ // Run VACUUM to reclaim space (only if we archived something) — outside transaction
209
+ if (archivedCount > 0) {
210
+ try {
211
+ db.exec('VACUUM');
212
+ } catch { /* non-fatal — WAL mode makes VACUUM occasionally slow */ }
213
+ }
214
+
215
+ const dbSizeAfter = getDbSizeBytes();
216
+
217
+ // ── Write audit log ──
218
+ writeGcLog({
219
+ archived: archivedCount,
220
+ skipped_protected: protectedCount,
221
+ candidates_found: candidateCount,
222
+ stale_days_threshold: STALE_DAYS,
223
+ min_search_count_threshold: MIN_SEARCH_COUNT,
224
+ db_size_before: dbSizeBefore,
225
+ db_size_after: dbSizeAfter,
226
+ });
227
+
228
+ console.log(`[MEMORY-GC] GC complete. Log: ${GC_LOG_FILE}`);
229
+
230
+ } catch (e) {
231
+ console.error(`[MEMORY-GC] Fatal error: ${e.message}`);
232
+ process.exitCode = 1;
233
+ } finally {
234
+ try { if (db) db.close(); } catch { /* non-fatal */ }
235
+ releaseLock();
236
+ }
237
+ }
238
+
239
+ run();
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * memory-index.js — Auto-generate INDEX.md for ~/.metame/memory/
5
+ *
6
+ * Lists all .md files under ~/.metame/memory/ recursively and writes
7
+ * a structured INDEX.md at the root of that directory.
8
+ * Serves as an L1 pointer document for context injection.
9
+ *
10
+ * Designed to run nightly at 01:30 via daemon.yaml scheduler.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const os = require('os');
18
+
19
+ const MEMORY_DIR = path.join(os.homedir(), '.metame', 'memory');
20
+ const INDEX_FILE = path.join(MEMORY_DIR, 'INDEX.md');
21
+
22
+ /**
23
+ * Recursively list all .md files under dir, excluding INDEX.md itself.
24
+ * Returns relative paths (relative to MEMORY_DIR), sorted alphabetically.
25
+ *
26
+ * @param {string} dir - absolute directory to scan
27
+ * @param {string} base - relative prefix to prepend (used in recursion)
28
+ * @returns {string[]} sorted relative paths
29
+ */
30
+ function listFiles(dir, base = '') {
31
+ let results = [];
32
+ let entries;
33
+ try {
34
+ entries = fs.readdirSync(dir, { withFileTypes: true });
35
+ } catch {
36
+ return results;
37
+ }
38
+
39
+ for (const entry of entries) {
40
+ const relPath = base ? `${base}/${entry.name}` : entry.name;
41
+ if (entry.isDirectory()) {
42
+ results = results.concat(listFiles(path.join(dir, entry.name), relPath));
43
+ } else if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'INDEX.md') {
44
+ results.push(relPath);
45
+ }
46
+ }
47
+
48
+ return results.sort();
49
+ }
50
+
51
+ /**
52
+ * Group files by their top-level subdirectory (or root).
53
+ * @param {string[]} files - relative paths
54
+ * @returns {Map<string, string[]>}
55
+ */
56
+ function groupByDir(files) {
57
+ const groups = new Map();
58
+ for (const f of files) {
59
+ const parts = f.split('/');
60
+ const dir = parts.length > 1 ? parts[0] : '(root)';
61
+ if (!groups.has(dir)) groups.set(dir, []);
62
+ groups.get(dir).push(f);
63
+ }
64
+ return groups;
65
+ }
66
+
67
+ /**
68
+ * Main: scan memory dir and write INDEX.md.
69
+ */
70
+ function run() {
71
+ fs.mkdirSync(MEMORY_DIR, { recursive: true });
72
+
73
+ const files = listFiles(MEMORY_DIR);
74
+ const groups = groupByDir(files);
75
+
76
+ const lines = [
77
+ '# Memory Index',
78
+ '',
79
+ `_Updated: ${new Date().toISOString()}_`,
80
+ `_Total files: ${files.length}_`,
81
+ '',
82
+ ];
83
+
84
+ if (files.length === 0) {
85
+ lines.push('_(no memory files yet)_');
86
+ } else {
87
+ for (const [dir, dirFiles] of groups) {
88
+ lines.push(`## ${dir}`);
89
+ lines.push('');
90
+ for (const f of dirFiles) {
91
+ lines.push(`- [${path.basename(f, '.md')}](./${f})`);
92
+ }
93
+ lines.push('');
94
+ }
95
+ }
96
+
97
+ fs.writeFileSync(INDEX_FILE, lines.join('\n') + '\n', 'utf8');
98
+ console.log(`[MEMORY-INDEX] Updated INDEX.md (${files.length} file(s)) → ${INDEX_FILE}`);
99
+ }
100
+
101
+ if (require.main === module) {
102
+ run();
103
+ }
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * memory-nightly-reflect.js — Nightly Hot-Fact Distillation
5
+ *
6
+ * Reads "hot zone" facts from memory.db (search_count >= 3, last 7 days),
7
+ * calls Haiku to distill high-level patterns, and writes results to:
8
+ * - ~/.metame/memory/decisions/YYYY-MM-DD-nightly-reflect.md (strategic/architectural)
9
+ * - ~/.metame/memory/lessons/YYYY-MM-DD-nightly-reflect.md (operational SOPs)
10
+ *
11
+ * Designed to run nightly at 01:00 via daemon.yaml scheduler (require_idle).
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+
20
+ const HOME = os.homedir();
21
+ const METAME_DIR = path.join(HOME, '.metame');
22
+ const DB_PATH = path.join(METAME_DIR, 'memory.db');
23
+ const LOCK_FILE = path.join(METAME_DIR, 'memory-nightly-reflect.lock');
24
+ const REFLECT_LOG_FILE = path.join(METAME_DIR, 'memory_reflect_log.jsonl');
25
+
26
+ const MEMORY_DIR = path.join(HOME, '.metame', 'memory');
27
+ const DECISIONS_DIR = path.join(MEMORY_DIR, 'decisions');
28
+ const LESSONS_DIR = path.join(MEMORY_DIR, 'lessons');
29
+
30
+ // Hot zone thresholds
31
+ const MIN_SEARCH_COUNT = 3;
32
+ const WINDOW_DAYS = 7;
33
+ const MAX_FACTS = 20;
34
+ const LOCK_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
35
+
36
+ // Ensure output directories exist at startup
37
+ [MEMORY_DIR, DECISIONS_DIR, LESSONS_DIR].forEach(d => fs.mkdirSync(d, { recursive: true }));
38
+
39
+ /**
40
+ * Load callHaiku + buildDistillEnv from deployed path, fallback to scripts dir.
41
+ */
42
+ function loadHelper(name) {
43
+ const candidates = [
44
+ path.join(HOME, '.metame', name),
45
+ path.join(__dirname, name),
46
+ ];
47
+ for (const p of candidates) {
48
+ try { return require(p); } catch {}
49
+ }
50
+ throw new Error(`Cannot load ${name}`);
51
+ }
52
+
53
+ /**
54
+ * Acquire atomic lock using O_EXCL — prevents concurrent runs.
55
+ */
56
+ function acquireLock() {
57
+ try {
58
+ const fd = fs.openSync(LOCK_FILE, 'wx');
59
+ fs.writeSync(fd, process.pid.toString());
60
+ fs.closeSync(fd);
61
+ return true;
62
+ } catch (e) {
63
+ if (e.code === 'EEXIST') {
64
+ try {
65
+ const lockAge = Date.now() - fs.statSync(LOCK_FILE).mtimeMs;
66
+ if (lockAge < LOCK_TIMEOUT_MS) {
67
+ console.log('[NIGHTLY-REFLECT] Already running (lock held), skipping.');
68
+ return false;
69
+ }
70
+ // Stale lock — remove and re-acquire
71
+ fs.unlinkSync(LOCK_FILE);
72
+ const fd = fs.openSync(LOCK_FILE, 'wx');
73
+ fs.writeSync(fd, process.pid.toString());
74
+ fs.closeSync(fd);
75
+ return true;
76
+ } catch {
77
+ console.log('[NIGHTLY-REFLECT] Could not acquire lock, skipping.');
78
+ return false;
79
+ }
80
+ }
81
+ throw e;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Release the atomic lock.
87
+ */
88
+ function releaseLock() {
89
+ try { fs.unlinkSync(LOCK_FILE); } catch { /* non-fatal */ }
90
+ }
91
+
92
+ /**
93
+ * Append a run record to the audit log.
94
+ */
95
+ function writeReflectLog(record) {
96
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...record }) + '\n';
97
+ try {
98
+ fs.mkdirSync(path.dirname(REFLECT_LOG_FILE), { recursive: true });
99
+ fs.appendFileSync(REFLECT_LOG_FILE, line, 'utf8');
100
+ } catch (e) {
101
+ console.log(`[NIGHTLY-REFLECT] Warning: could not write reflect log: ${e.message}`);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Query hot zone facts from memory.db.
107
+ * Returns array of plain objects.
108
+ */
109
+ function queryHotFacts(db) {
110
+ const stmt = db.prepare(`
111
+ SELECT entity, relation, value, confidence, search_count, created_at
112
+ FROM facts
113
+ WHERE search_count >= ${MIN_SEARCH_COUNT}
114
+ AND created_at >= datetime('now', '-${WINDOW_DAYS} days')
115
+ AND superseded_by IS NULL
116
+ AND (conflict_status IS NULL OR conflict_status = 'OK')
117
+ AND relation != 'project_milestone'
118
+ ORDER BY search_count DESC, created_at DESC
119
+ LIMIT ${MAX_FACTS}
120
+ `);
121
+ return stmt.all();
122
+ }
123
+
124
+ /**
125
+ * Write a reflect Markdown file with frontmatter.
126
+ */
127
+ function writeReflectFile(filePath, entries, factsCount, sourceType) {
128
+ const today = new Date().toISOString().slice(0, 10);
129
+ const sections = entries
130
+ .map(e => `## ${e.title}\n\n${e.content}`)
131
+ .join('\n\n---\n\n');
132
+
133
+ const content = `---
134
+ date: ${today}
135
+ source: nightly-reflect
136
+ type: ${sourceType}
137
+ facts_analyzed: ${factsCount}
138
+ ---
139
+
140
+ ${sections}
141
+ `;
142
+ fs.writeFileSync(filePath, content, 'utf8');
143
+ }
144
+
145
+ /**
146
+ * Main nightly reflect run.
147
+ */
148
+ async function run() {
149
+ console.log('[NIGHTLY-REFLECT] Starting nightly reflect run...');
150
+
151
+ if (!fs.existsSync(DB_PATH)) {
152
+ console.log('[NIGHTLY-REFLECT] memory.db not found, skipping.');
153
+ return;
154
+ }
155
+
156
+ if (!acquireLock()) return;
157
+
158
+ const today = new Date().toISOString().slice(0, 10);
159
+ const decisionFile = path.join(DECISIONS_DIR, `${today}-nightly-reflect.md`);
160
+ const lessonFile = path.join(LESSONS_DIR, `${today}-nightly-reflect.md`);
161
+
162
+ // Prevent duplicate runs for the same day
163
+ if (fs.existsSync(decisionFile) || fs.existsSync(lessonFile)) {
164
+ console.log('[NIGHTLY-REFLECT] Already ran today, skipping.');
165
+ releaseLock();
166
+ return;
167
+ }
168
+
169
+ let db;
170
+ try {
171
+ const { DatabaseSync } = require('node:sqlite');
172
+ db = new DatabaseSync(DB_PATH);
173
+ db.exec('PRAGMA journal_mode = WAL');
174
+ db.exec('PRAGMA busy_timeout = 5000');
175
+
176
+ const hotFacts = queryHotFacts(db);
177
+ console.log(`[NIGHTLY-REFLECT] Found ${hotFacts.length} hot-zone facts.`);
178
+
179
+ if (hotFacts.length < 3) {
180
+ console.log('[NIGHTLY-REFLECT] Insufficient hot facts (< 3), skipping distillation.');
181
+ writeReflectLog({ status: 'skipped', reason: 'insufficient_facts', facts_found: hotFacts.length });
182
+ releaseLock();
183
+ return;
184
+ }
185
+
186
+ // Load Haiku helper from providers.js (callHaiku lives there)
187
+ let callHaiku, buildDistillEnv;
188
+ try {
189
+ ({ callHaiku, buildDistillEnv } = loadHelper('providers.js'));
190
+ } catch (e) {
191
+ throw new Error(`Cannot load Haiku helper from providers.js: ${e.message}`);
192
+ }
193
+
194
+ let distillEnv = {};
195
+ try { distillEnv = buildDistillEnv(); } catch {}
196
+
197
+ const factsJson = JSON.stringify(
198
+ hotFacts.map(f => ({
199
+ entity: f.entity,
200
+ relation: f.relation,
201
+ value: f.value,
202
+ confidence: f.confidence,
203
+ search_count: f.search_count,
204
+ })),
205
+ null,
206
+ 2
207
+ );
208
+
209
+ const prompt = `You are extracting high-level patterns from an AI assistant's recent memory facts.
210
+
211
+ Recent high-frequency facts (JSON):
212
+ ${factsJson}
213
+
214
+ Analyze and output a JSON object:
215
+ {
216
+ "decisions": [{"title": "中文标题", "content": "## 背景\\n...\\n## 结论\\n..."}],
217
+ "lessons": [{"title": "中文标题", "content": "## 问题\\n...\\n## 操作手册\\n1. ..."}]
218
+ }
219
+
220
+ Rules:
221
+ - decisions: strategic/architectural insights (why we chose X over Y)
222
+ - lessons: operational SOPs (how to do X correctly)
223
+ - Each array can be empty if no pattern found
224
+ - content in 中文, 100-250 chars each
225
+ - Output ONLY the JSON object`;
226
+
227
+ console.log('[NIGHTLY-REFLECT] Calling Haiku for distillation...');
228
+ let raw;
229
+ try {
230
+ raw = await Promise.race([
231
+ callHaiku(prompt, distillEnv, 90000),
232
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 95000)),
233
+ ]);
234
+ } catch (e) {
235
+ console.log(`[NIGHTLY-REFLECT] Haiku call failed: ${e.message}`);
236
+ writeReflectLog({ status: 'error', reason: 'haiku_failed', error: e.message, facts_found: hotFacts.length });
237
+ return;
238
+ }
239
+
240
+ // Parse Haiku response
241
+ let parsed;
242
+ try {
243
+ const cleaned = raw.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
244
+ parsed = JSON.parse(cleaned);
245
+ } catch (e) {
246
+ console.log(`[NIGHTLY-REFLECT] Failed to parse Haiku output: ${e.message}`);
247
+ writeReflectLog({ status: 'error', reason: 'parse_failed', facts_found: hotFacts.length });
248
+ return;
249
+ }
250
+
251
+ const decisions = Array.isArray(parsed.decisions) ? parsed.decisions.filter(d => d.title && d.content) : [];
252
+ const lessons = Array.isArray(parsed.lessons) ? parsed.lessons.filter(l => l.title && l.content) : [];
253
+
254
+ console.log(`[NIGHTLY-REFLECT] Distilled: ${decisions.length} decision(s), ${lessons.length} lesson(s).`);
255
+
256
+ // Write decisions file (even if empty array — record the run)
257
+ if (decisions.length > 0) {
258
+ writeReflectFile(decisionFile, decisions, hotFacts.length, 'decisions');
259
+ console.log(`[NIGHTLY-REFLECT] Decisions written: ${decisionFile}`);
260
+ }
261
+
262
+ // Write lessons file
263
+ if (lessons.length > 0) {
264
+ writeReflectFile(lessonFile, lessons, hotFacts.length, 'lessons');
265
+ console.log(`[NIGHTLY-REFLECT] Lessons written: ${lessonFile}`);
266
+ }
267
+
268
+ // Write audit log
269
+ writeReflectLog({
270
+ status: 'success',
271
+ facts_analyzed: hotFacts.length,
272
+ decisions_written: decisions.length,
273
+ lessons_written: lessons.length,
274
+ decision_file: decisions.length > 0 ? decisionFile : null,
275
+ lesson_file: lessons.length > 0 ? lessonFile : null,
276
+ });
277
+
278
+ console.log('[NIGHTLY-REFLECT] Run complete.');
279
+
280
+ } catch (e) {
281
+ console.error(`[NIGHTLY-REFLECT] Fatal error: ${e.message}`);
282
+ writeReflectLog({ status: 'error', reason: 'fatal', error: e.message });
283
+ process.exitCode = 1;
284
+ } finally {
285
+ try { if (db) db.close(); } catch { /* non-fatal */ }
286
+ releaseLock();
287
+ }
288
+ }
289
+
290
+ if (require.main === module) {
291
+ run().then(() => {
292
+ console.log('✅ nightly-reflect complete');
293
+ }).catch(e => {
294
+ console.error(`[NIGHTLY-REFLECT] Fatal: ${e.message}`);
295
+ process.exit(1);
296
+ });
297
+ }
298
+
299
+ module.exports = { run };