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.
- package/README.md +30 -24
- package/index.js +39 -1
- package/package.json +1 -1
- package/scripts/daemon-admin-commands.js +86 -4
- package/scripts/daemon-agent-commands.js +73 -63
- package/scripts/daemon-agent-tools.js +49 -12
- package/scripts/daemon-bridges.js +26 -6
- package/scripts/daemon-claude-engine.js +111 -39
- package/scripts/daemon-command-router.js +38 -35
- package/scripts/daemon-default.yaml +18 -0
- package/scripts/daemon-exec-commands.js +6 -12
- package/scripts/daemon-file-browser.js +6 -5
- package/scripts/daemon-runtime-lifecycle.js +19 -5
- package/scripts/daemon-session-commands.js +8 -3
- package/scripts/daemon-task-scheduler.js +30 -29
- package/scripts/daemon.js +38 -6
- package/scripts/distill.js +11 -12
- package/scripts/memory-gc.js +239 -0
- package/scripts/memory-index.js +103 -0
- package/scripts/memory-nightly-reflect.js +299 -0
- package/scripts/memory-write.js +192 -0
- package/scripts/memory.js +144 -6
- package/scripts/schema.js +30 -9
- package/scripts/self-reflect.js +121 -5
- package/scripts/session-analytics.js +9 -10
- package/scripts/task-board.js +9 -3
- package/scripts/telegram-adapter.js +77 -9
|
@@ -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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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: () =>
|
|
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.');
|
package/scripts/distill.js
CHANGED
|
@@ -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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
+
}
|