metame-cli 1.4.19 → 1.4.21

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.
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
3
4
  const { classifyTaskUsage } = require('./usage-classifier');
4
5
 
5
6
  const WEEKDAY_INDEX = Object.freeze({
@@ -229,25 +230,29 @@ function createTaskScheduler(deps) {
229
230
  if (!task || !task.memory_log) return;
230
231
  try {
231
232
  const memory = require('./memory');
232
- const nowIso = new Date().toISOString();
233
- const projectKey = (task._project && task._project.key) || 'heartbeat';
233
+ memory.acquire();
234
234
  const memoryId = `${task.name}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
235
- const summaryText = String(output || '(no output)').trim() || '(no output)';
236
- const summary = [
237
- `[heartbeat task] ${task.name}`,
238
- sessionId ? `session: ${sessionId}` : '',
239
- summaryText,
240
- ].filter(Boolean).join('\n').slice(0, 8000);
241
- const keywords = [task.name, 'heartbeat', 'evolution', nowIso.slice(0, 10)].join(',');
242
- memory.saveSession({
243
- sessionId: memoryId,
244
- project: projectKey,
245
- summary,
246
- keywords,
247
- mood: '',
248
- tokenCost: Number(tokenCost) || 0,
249
- });
250
- memory.close();
235
+ try {
236
+ const nowIso = new Date().toISOString();
237
+ const projectKey = (task._project && task._project.key) || 'heartbeat';
238
+ const summaryText = String(output || '(no output)').trim() || '(no output)';
239
+ const summary = [
240
+ `[heartbeat task] ${task.name}`,
241
+ sessionId ? `session: ${sessionId}` : '',
242
+ summaryText,
243
+ ].filter(Boolean).join('\n').slice(0, 8000);
244
+ const keywords = [task.name, 'heartbeat', 'evolution', nowIso.slice(0, 10)].join(',');
245
+ memory.saveSession({
246
+ sessionId: memoryId,
247
+ project: projectKey,
248
+ summary,
249
+ keywords,
250
+ mood: '',
251
+ tokenCost: Number(tokenCost) || 0,
252
+ });
253
+ } finally {
254
+ memory.release();
255
+ }
251
256
  log('INFO', `Task ${task.name}: memory_log saved (${memoryId})`);
252
257
  } catch (e) {
253
258
  log('WARN', `Task ${task.name}: memory_log failed: ${e.message}`);
@@ -281,7 +286,7 @@ function createTaskScheduler(deps) {
281
286
 
282
287
  // Workflow tasks: multi-step skill chain via --resume session
283
288
  if (task.type === 'workflow') {
284
- return executeWorkflow(task, config);
289
+ return executeWorkflow(task, config, precheck);
285
290
  }
286
291
 
287
292
  // Script tasks: run a local script directly (e.g. distill.js), no claude -p
@@ -488,18 +493,13 @@ function createTaskScheduler(deps) {
488
493
 
489
494
  // parseInterval — imported from ./utils
490
495
 
491
- function executeWorkflow(task, config) {
496
+ function executeWorkflow(task, config, precheck) {
492
497
  const state = loadState();
493
498
  if (!checkBudget(config, state)) {
494
499
  log('WARN', `Budget exceeded, skipping workflow: ${task.name}`);
495
500
  return { success: false, error: 'budget_exceeded', output: '' };
496
501
  }
497
- const precheck = checkPrecondition(task);
498
- if (!precheck.pass) {
499
- state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'skipped', output_preview: 'Precondition not met' };
500
- saveState(state);
501
- return { success: true, output: '(skipped)', skipped: true };
502
- }
502
+ // precheck.pass is guaranteed true here — executeTask() already returns early when false
503
503
  const steps = task.steps || [];
504
504
  if (steps.length === 0) return { success: false, error: 'No steps defined', output: '' };
505
505
 
@@ -518,6 +518,7 @@ function createTaskScheduler(deps) {
518
518
 
519
519
  log('INFO', `Workflow ${task.name}: ${steps.length} steps, session ${sessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''}`);
520
520
 
521
+ let loopState = loadState();
521
522
  for (let i = 0; i < steps.length; i++) {
522
523
  const step = steps[i];
523
524
  let prompt = (step.skill ? `/${step.skill} ` : '') + (step.prompt || '');
@@ -546,19 +547,19 @@ function createTaskScheduler(deps) {
546
547
  totalTokens += tk;
547
548
  outputs.push({ step: i + 1, skill: step.skill || null, output: output.slice(0, 500), tokens: tk });
548
549
  log('INFO', `Workflow ${task.name} step ${i + 1} done (${tk} tokens)`);
549
- if (!checkBudget(config, loadState())) { log('WARN', 'Budget exceeded mid-workflow'); break; }
550
+ if (!checkBudget(config, loopState)) { log('WARN', 'Budget exceeded mid-workflow'); break; }
550
551
  } catch (e) {
551
552
  log('ERROR', `Workflow ${task.name} step ${i + 1} failed: ${e.message.slice(0, 200)}`);
552
553
  outputs.push({ step: i + 1, skill: step.skill || null, error: e.message.slice(0, 200) });
553
554
  if (!step.optional) {
554
- recordTokens(loadState(), totalTokens, { category: classifyTaskUsage(task) });
555
+ recordTokens(loopState, totalTokens, { category: classifyTaskUsage(task) });
555
556
  state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'error', error: `Step ${i + 1} failed`, steps_completed: i, steps_total: steps.length };
556
557
  saveState(state);
557
558
  return { success: false, error: `Step ${i + 1} failed`, output: outputs.map(o => `Step ${o.step}: ${o.error ? 'FAILED' : 'OK'}`).join('\n'), tokens: totalTokens };
558
559
  }
559
560
  }
560
561
  }
561
- recordTokens(loadState(), totalTokens, { category: classifyTaskUsage(task) });
562
+ recordTokens(loopState, totalTokens, { category: classifyTaskUsage(task) });
562
563
  const lastOk = [...outputs].reverse().find(o => !o.error);
563
564
  state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'success', output_preview: (lastOk ? lastOk.output : '').slice(0, 200), steps_completed: outputs.filter(o => !o.error).length, steps_total: steps.length };
564
565
  saveState(state);
package/scripts/daemon.js CHANGED
@@ -248,11 +248,11 @@ function restoreConfig() {
248
248
  }
249
249
  }
250
250
  writeConfigSafe(bakCfg);
251
- config = loadConfig();
251
+ config = loadConfig(); // eslint-disable-line no-undef -- config is declared in main() closure
252
252
  return true;
253
253
  } catch {
254
254
  fs.copyFileSync(bak, CONFIG_FILE);
255
- config = loadConfig();
255
+ config = loadConfig(); // eslint-disable-line no-undef
256
256
  return true;
257
257
  }
258
258
  }
@@ -1352,6 +1352,11 @@ const pendingBinds = new Map(); // chatId -> agentName
1352
1352
  // chatId -> { step: 'dir'|'name'|'desc', dir: string, name: string }
1353
1353
  const pendingAgentFlows = new Map();
1354
1354
 
1355
+ // Pending activation: after creating an agent with skipChatBinding=true,
1356
+ // store here so any new unbound group can activate it with /activate
1357
+ // { agentKey, agentName, cwd, createdAt }
1358
+ const pendingActivations = new Map(); // key: agentKey -> activation record
1359
+
1355
1360
  const { handleAdminCommand } = createAdminCommandHandler({
1356
1361
  fs,
1357
1362
  yaml,
@@ -1371,6 +1376,10 @@ const { handleAdminCommand } = createAdminCommandHandler({
1371
1376
  skillEvolution,
1372
1377
  taskBoard,
1373
1378
  taskEnvelope,
1379
+ getActiveProcesses: () => activeProcesses,
1380
+ getMessageQueue: () => messageQueue,
1381
+ loadState,
1382
+ saveState,
1374
1383
  });
1375
1384
 
1376
1385
  const { handleSessionCommand } = createSessionCommandHandler({
@@ -1492,6 +1501,7 @@ const { handleAgentCommand } = createAgentCommandHandler({
1492
1501
  getSessionRecentContext,
1493
1502
  pendingBinds,
1494
1503
  pendingAgentFlows,
1504
+ pendingActivations,
1495
1505
  doBindAgent,
1496
1506
  mergeAgentRole,
1497
1507
  agentTools,
@@ -1569,6 +1579,7 @@ const { handleCommand } = createCommandRouter({
1569
1579
  log,
1570
1580
  agentTools,
1571
1581
  pendingAgentFlows,
1582
+ pendingActivations,
1572
1583
  agentFlowTtlMs: getAgentFlowTtlMs,
1573
1584
  });
1574
1585
 
@@ -1589,6 +1600,7 @@ const { startTelegramBridge, startFeishuBridge } = createBridgeStarter({
1589
1600
  saveState,
1590
1601
  getSession,
1591
1602
  handleCommand,
1603
+ pendingActivations,
1592
1604
  });
1593
1605
 
1594
1606
  const { killExistingDaemon, writePid, cleanPid } = createPidManager({
@@ -1740,7 +1752,10 @@ async function main() {
1740
1752
  setConfig: (next) => { config = next; },
1741
1753
  getHeartbeatTimer: () => heartbeatTimer,
1742
1754
  setHeartbeatTimer: (next) => { heartbeatTimer = next; },
1743
- onRestartRequested: () => process.exit(0),
1755
+ onRestartRequested: () => {
1756
+ // Reuse full shutdown logic (kill child processes, clean PID, stop bridges)
1757
+ shutdown().catch(() => process.exit(0));
1758
+ },
1744
1759
  });
1745
1760
  // Expose reloadConfig to handleCommand via closure
1746
1761
  global._metameReload = runtimeWatchers.reloadConfig;
@@ -1754,9 +1769,26 @@ async function main() {
1754
1769
  await sleep(1500); // Let polling settle
1755
1770
  await adminNotifyFn('✅ Daemon ready.').catch(() => { });
1756
1771
 
1772
+ // Notify active users before restart/shutdown
1773
+ async function notifyActiveUsers(reason) {
1774
+ if (activeProcesses.size === 0) return;
1775
+ const bots = [];
1776
+ if (feishuBridge && feishuBridge.bot) bots.push(feishuBridge.bot);
1777
+ if (telegramBridge && telegramBridge.bot) bots.push(telegramBridge.bot);
1778
+ if (bots.length === 0) return;
1779
+ const notifs = [];
1780
+ for (const [cid] of activeProcesses) {
1781
+ for (const bot of bots) {
1782
+ notifs.push(bot.sendMessage(cid, `⚠️ 系统正在重启(${reason}),任务已中断,请重新发送指令。`).catch(() => {}));
1783
+ }
1784
+ }
1785
+ await Promise.race([Promise.all(notifs), new Promise(r => setTimeout(r, 3000))]);
1786
+ }
1787
+
1757
1788
  // Graceful shutdown
1758
- const shutdown = () => {
1789
+ const shutdown = async () => {
1759
1790
  log('INFO', 'Daemon shutting down...');
1791
+ await notifyActiveUsers('关闭').catch(() => {});
1760
1792
  runtimeWatchers.stop();
1761
1793
  if (heartbeatTimer) clearInterval(heartbeatTimer);
1762
1794
  if (dispatchSocket) try { dispatchSocket.close(); } catch { }
@@ -1779,8 +1811,8 @@ async function main() {
1779
1811
  process.exit(0);
1780
1812
  };
1781
1813
 
1782
- process.on('SIGTERM', shutdown);
1783
- process.on('SIGINT', shutdown);
1814
+ process.on('SIGTERM', () => { shutdown().catch(() => process.exit(0)); });
1815
+ process.on('SIGINT', () => { shutdown().catch(() => process.exit(0)); });
1784
1816
 
1785
1817
  // Keep alive
1786
1818
  log('INFO', 'Daemon running. Send SIGTERM to stop.');
@@ -636,15 +636,6 @@ function strategicMerge(profile, updates, lockedKeys, pendingTraits, confidenceM
636
636
  break;
637
637
  }
638
638
 
639
- case 'T4':
640
- setNested(result, key, value);
641
-
642
- // Auto-set focus_since when focus changes
643
- if (key === 'context.focus') {
644
- setNested(result, 'context.focus_since', new Date().toISOString().slice(0, 10));
645
- }
646
- break;
647
-
648
639
  case 'T5':
649
640
  setNested(result, key, value);
650
641
  break;
@@ -717,9 +708,17 @@ function filterBySchema(obj, parentKey = '') {
717
708
  const value = obj[key];
718
709
 
719
710
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
720
- const nested = filterBySchema(value, fullKey);
721
- if (Object.keys(nested).length > 0) {
722
- result[key] = nested;
711
+ // map type: accept entire object as-is, do not recurse into sub-keys
712
+ const def = getDefinition(fullKey);
713
+ if (def && def.type === 'map') {
714
+ if (hasKey(fullKey) && !isLocked(fullKey)) {
715
+ result[key] = value;
716
+ }
717
+ } else {
718
+ const nested = filterBySchema(value, fullKey);
719
+ if (Object.keys(nested).length > 0) {
720
+ result[key] = nested;
721
+ }
723
722
  }
724
723
  } else {
725
724
  // Check schema whitelist — allow if key exists and is not locked
@@ -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
+ }