claude-mem-lite 2.86.0 → 2.88.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook.mjs +21 -106
- package/lib/compress-core.mjs +98 -0
- package/lib/maintain-core.mjs +236 -0
- package/mem-cli.mjs +50 -248
- package/package.json +3 -1
- package/schema.mjs +33 -2
- package/server.mjs +40 -252
- package/source-files.mjs +9 -0
package/server.mjs
CHANGED
|
@@ -5,13 +5,18 @@
|
|
|
5
5
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
6
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
7
|
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
-
import {
|
|
8
|
+
import { truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, scrubSecrets, cjkBigrams, fmtDate, debugLog, debugCatch, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined, notLowSignalTitleClause } from './utils.mjs';
|
|
9
9
|
import { extractCjkLikePatterns, cjkPrecisionOk } from './nlp.mjs';
|
|
10
10
|
import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
|
|
11
11
|
import { ensureDb, DB_PATH, REGISTRY_DB_PATH } from './schema.mjs';
|
|
12
12
|
import { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './server-internals.mjs';
|
|
13
13
|
import { searchObservationsHybrid, findFtsAnchor } from './search-engine.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
15
|
+
import {
|
|
16
|
+
cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
|
|
17
|
+
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
18
|
+
OP_CAP, STALE_AGE_MS,
|
|
19
|
+
} from './lib/maintain-core.mjs';
|
|
15
20
|
import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
|
|
16
21
|
import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
17
22
|
import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, memDeferSchema, memDeferListSchema, memDeferDropSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
|
|
@@ -35,7 +40,7 @@ import {
|
|
|
35
40
|
insertDeferred, listOpenWithOrdinal, dropDeferred,
|
|
36
41
|
resolveDeferredIds, closeDeferredItems,
|
|
37
42
|
} from './lib/deferred-work.mjs';
|
|
38
|
-
import { getVocabulary,
|
|
43
|
+
import { getVocabulary, _resetVocabCache, computeVector } from './tfidf.mjs';
|
|
39
44
|
import { createRequire } from 'module';
|
|
40
45
|
|
|
41
46
|
const require = createRequire(import.meta.url);
|
|
@@ -1168,35 +1173,13 @@ server.registerTool(
|
|
|
1168
1173
|
const preview = args.preview !== false;
|
|
1169
1174
|
const ageDays = args.age_days ?? 30;
|
|
1170
1175
|
const cutoff = Date.now() - ageDays * 86400000;
|
|
1171
|
-
const
|
|
1172
|
-
const baseParams = args.project ? [args.project] : [];
|
|
1173
|
-
|
|
1174
|
-
// Find low-value candidates: importance=1, never accessed, old, not already compressed
|
|
1175
|
-
const candidates = db.prepare(`
|
|
1176
|
-
SELECT id, project, type, title, created_at, created_at_epoch
|
|
1177
|
-
FROM observations
|
|
1178
|
-
WHERE COALESCE(importance, 1) = 1
|
|
1179
|
-
AND COALESCE(access_count, 0) = 0
|
|
1180
|
-
AND created_at_epoch < ?
|
|
1181
|
-
AND compressed_into IS NULL
|
|
1182
|
-
${projectFilter}
|
|
1183
|
-
ORDER BY project, created_at_epoch
|
|
1184
|
-
`).all(cutoff, ...baseParams);
|
|
1176
|
+
const candidates = selectCompressionCandidates(db, { cutoff, project: args.project || null });
|
|
1185
1177
|
|
|
1186
1178
|
if (candidates.length === 0) {
|
|
1187
1179
|
return { content: [{ type: 'text', text: 'No candidates for compression.' }] };
|
|
1188
1180
|
}
|
|
1189
1181
|
|
|
1190
|
-
|
|
1191
|
-
const groups = new Map();
|
|
1192
|
-
for (const c of candidates) {
|
|
1193
|
-
const key = `${c.project}::${isoWeekKey(c.created_at_epoch)}`;
|
|
1194
|
-
if (!groups.has(key)) groups.set(key, []);
|
|
1195
|
-
groups.get(key).push(c);
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
// Filter groups with < 3 observations (not worth compressing)
|
|
1199
|
-
const compressableGroups = [...groups.entries()].filter(([, obs]) => obs.length >= 3);
|
|
1182
|
+
const compressableGroups = groupByProjectWeek(candidates);
|
|
1200
1183
|
|
|
1201
1184
|
if (preview) {
|
|
1202
1185
|
const totalCandidates = compressableGroups.reduce((s, [, obs]) => s + obs.length, 0);
|
|
@@ -1220,49 +1203,12 @@ server.registerTool(
|
|
|
1220
1203
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1221
1204
|
}
|
|
1222
1205
|
|
|
1223
|
-
// Execute compression
|
|
1206
|
+
// Execute compression — one transaction over all groups (the hook transacts per group).
|
|
1224
1207
|
let totalCompressed = 0;
|
|
1225
|
-
const insertSummary = db.prepare(`
|
|
1226
|
-
INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, created_at, created_at_epoch)
|
|
1227
|
-
VALUES (?, ?, ?, ?, ?, '', ?, '', '', '[]', '[]', 2, ?, ?)
|
|
1228
|
-
`);
|
|
1229
1208
|
const compress = db.transaction(() => {
|
|
1230
1209
|
for (const [key, obs] of compressableGroups) {
|
|
1231
1210
|
const [proj] = key.split('::');
|
|
1232
|
-
|
|
1233
|
-
for (const o of obs) types[o.type] = (types[o.type] || 0) + 1;
|
|
1234
|
-
const dominantType = Object.entries(types).sort((a, b) => b[1] - a[1])[0][0];
|
|
1235
|
-
const title = `Weekly summary: ${obs.length} ${dominantType} observations`;
|
|
1236
|
-
const narrative = obs.map(o => `- ${o.title || '(untitled)'}`).join('\n');
|
|
1237
|
-
const sessionId = obs[0].project ? `compress-${obs[0].project}` : 'compress-manual';
|
|
1238
|
-
|
|
1239
|
-
// Use median timestamp of compressed observations instead of now,
|
|
1240
|
-
// so the summary appears at the correct position in timeline/recency scoring.
|
|
1241
|
-
const sortedEpochs = obs.map(o => o.created_at_epoch).sort((a, b) => a - b);
|
|
1242
|
-
const medianEpoch = sortedEpochs[Math.floor(sortedEpochs.length / 2)];
|
|
1243
|
-
const medianDate = new Date(medianEpoch);
|
|
1244
|
-
|
|
1245
|
-
// Ensure session exists (INSERT OR IGNORE avoids race condition)
|
|
1246
|
-
const now = new Date();
|
|
1247
|
-
db.prepare(`
|
|
1248
|
-
INSERT OR IGNORE INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
|
|
1249
|
-
VALUES (?, ?, ?, ?, ?, 'active')
|
|
1250
|
-
`).run(sessionId, sessionId, proj, now.toISOString(), now.getTime());
|
|
1251
|
-
|
|
1252
|
-
// Defense-in-depth: source rows already scrubbed at original ingest,
|
|
1253
|
-
// but the new compressed narrative is constructed here and re-persisted.
|
|
1254
|
-
const safe = scrubRecord('observations', { text: narrative, title, narrative });
|
|
1255
|
-
const summaryResult = insertSummary.run(
|
|
1256
|
-
sessionId, proj, safe.text, dominantType, safe.title, safe.narrative,
|
|
1257
|
-
medianDate.toISOString(), medianEpoch
|
|
1258
|
-
);
|
|
1259
|
-
const summaryId = Number(summaryResult.lastInsertRowid);
|
|
1260
|
-
|
|
1261
|
-
// Batch UPDATE instead of per-row loop
|
|
1262
|
-
const obsIds = obs.map(o => o.id);
|
|
1263
|
-
const obsPh = obsIds.map(() => '?').join(',');
|
|
1264
|
-
db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${obsPh})`).run(summaryId, ...obsIds);
|
|
1265
|
-
totalCompressed += obs.length;
|
|
1211
|
+
totalCompressed += compressGroup(db, proj, obs).compressed;
|
|
1266
1212
|
}
|
|
1267
1213
|
});
|
|
1268
1214
|
compress();
|
|
@@ -1281,10 +1227,6 @@ server.registerTool(
|
|
|
1281
1227
|
},
|
|
1282
1228
|
safeHandler(async (args) => {
|
|
1283
1229
|
if (args.project) args = { ...args, project: resolveProject(args.project) };
|
|
1284
|
-
const STALE_AGE_MS = 30 * 86400000;
|
|
1285
|
-
const SIMILARITY_THRESHOLD = 0.7;
|
|
1286
|
-
const SCAN_LIMIT = 500;
|
|
1287
|
-
const DUPLICATE_LIMIT = 50;
|
|
1288
1230
|
const DUPLICATE_DISPLAY = 15;
|
|
1289
1231
|
|
|
1290
1232
|
const action = args.action;
|
|
@@ -1293,56 +1235,10 @@ server.registerTool(
|
|
|
1293
1235
|
const baseParams = project ? [project] : [];
|
|
1294
1236
|
|
|
1295
1237
|
if (action === 'scan') {
|
|
1296
|
-
// 1. Find near-duplicate titles (MinHash pre-filter → exact Jaccard on candidates)
|
|
1297
|
-
const recent = db.prepare(`
|
|
1298
|
-
SELECT id, title, project, importance, access_count, created_at_epoch
|
|
1299
|
-
FROM observations
|
|
1300
|
-
WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}
|
|
1301
|
-
ORDER BY created_at_epoch DESC
|
|
1302
|
-
LIMIT ${SCAN_LIMIT}
|
|
1303
|
-
`).all(...baseParams);
|
|
1304
|
-
|
|
1305
|
-
const titles = recent.map(r => (r.title || '').trim());
|
|
1306
|
-
const minhashes = titles.map(t => t ? computeMinHash(t) : null);
|
|
1307
|
-
const MINHASH_PRE_THRESHOLD = 0.5; // loose pre-filter to catch candidates
|
|
1308
|
-
const duplicates = [];
|
|
1309
|
-
for (let i = 0; i < recent.length && duplicates.length < DUPLICATE_LIMIT; i++) {
|
|
1310
|
-
if (!titles[i] || !minhashes[i]) continue;
|
|
1311
|
-
for (let j = i + 1; j < recent.length; j++) {
|
|
1312
|
-
if (!titles[j] || !minhashes[j]) continue;
|
|
1313
|
-
// Fast MinHash estimate to skip obvious non-matches
|
|
1314
|
-
if (estimateJaccardFromMinHash(minhashes[i], minhashes[j]) < MINHASH_PRE_THRESHOLD) continue;
|
|
1315
|
-
const sim = jaccardSimilarity(titles[i], titles[j]);
|
|
1316
|
-
if (sim > SIMILARITY_THRESHOLD) {
|
|
1317
|
-
duplicates.push({
|
|
1318
|
-
a: { id: recent[i].id, title: recent[i].title, importance: recent[i].importance },
|
|
1319
|
-
b: { id: recent[j].id, title: recent[j].title, importance: recent[j].importance },
|
|
1320
|
-
similarity: sim.toFixed(2),
|
|
1321
|
-
});
|
|
1322
|
-
}
|
|
1323
|
-
if (duplicates.length >= DUPLICATE_LIMIT) break;
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
// 2. Consolidated stats query (single table scan instead of 4 separate COUNTs)
|
|
1328
1238
|
const staleAge = Date.now() - STALE_AGE_MS;
|
|
1329
|
-
const
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
COALESCE(SUM(CASE WHEN COALESCE(importance, 1) = 1 AND COALESCE(access_count, 0) = 0
|
|
1333
|
-
AND created_at_epoch < ? THEN 1 ELSE 0 END), 0) as stale,
|
|
1334
|
-
COALESCE(SUM(CASE WHEN (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
|
|
1335
|
-
THEN 1 ELSE 0 END), 0) as broken,
|
|
1336
|
-
COALESCE(SUM(CASE WHEN COALESCE(access_count, 0) > 3 AND COALESCE(importance, 1) < 3
|
|
1337
|
-
THEN 1 ELSE 0 END), 0) as boostable
|
|
1338
|
-
FROM observations
|
|
1339
|
-
WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}
|
|
1340
|
-
`).get(staleAge, ...baseParams);
|
|
1341
|
-
|
|
1342
|
-
// Count pending-purge items (marked by idle cleanup)
|
|
1343
|
-
const pendingPurge = db.prepare(`
|
|
1344
|
-
SELECT COUNT(*) as count FROM observations WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} ${projectFilter}
|
|
1345
|
-
`).get(...baseParams);
|
|
1239
|
+
const mctx = { projectFilter, baseParams, staleAge };
|
|
1240
|
+
const duplicates = findDuplicates(db, mctx);
|
|
1241
|
+
const stats = maintenanceStats(db, mctx);
|
|
1346
1242
|
|
|
1347
1243
|
const lines = [
|
|
1348
1244
|
`Memory maintenance scan:`,
|
|
@@ -1351,7 +1247,7 @@ server.registerTool(
|
|
|
1351
1247
|
` Stale (>30d, imp=1, no access): ${stats.stale}`,
|
|
1352
1248
|
` Broken (no title/narrative): ${stats.broken}`,
|
|
1353
1249
|
` Boostable (accessed>3, imp<3): ${stats.boostable}`,
|
|
1354
|
-
` Pending purge (idle-marked): ${pendingPurge
|
|
1250
|
+
` Pending purge (idle-marked): ${stats.pendingPurge}`,
|
|
1355
1251
|
];
|
|
1356
1252
|
if (duplicates.length > 0) {
|
|
1357
1253
|
const AUTO_MERGE_THRESHOLD = 0.85;
|
|
@@ -1396,7 +1292,7 @@ server.registerTool(
|
|
|
1396
1292
|
}
|
|
1397
1293
|
const results = [];
|
|
1398
1294
|
const staleAge = Date.now() - STALE_AGE_MS;
|
|
1399
|
-
const
|
|
1295
|
+
const mctx = { projectFilter, baseParams, staleAge, opCap: OP_CAP };
|
|
1400
1296
|
|
|
1401
1297
|
// T2-P0-A: purge_stale is the only DELETE in this handler. Require confirm=true;
|
|
1402
1298
|
// a first call without confirm returns a dry-run preview so callers know the blast radius.
|
|
@@ -1404,11 +1300,7 @@ server.registerTool(
|
|
|
1404
1300
|
if (purgeRequested && args.confirm !== true) {
|
|
1405
1301
|
const retainDays = args.retain_days ?? 30;
|
|
1406
1302
|
const retainCutoff = Date.now() - retainDays * 86400000;
|
|
1407
|
-
const previewRow = db
|
|
1408
|
-
SELECT COUNT(*) AS candidates, MIN(created_at_epoch) AS oldest, MAX(created_at_epoch) AS newest
|
|
1409
|
-
FROM observations
|
|
1410
|
-
WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ? ${projectFilter}
|
|
1411
|
-
`).get(retainCutoff, ...baseParams);
|
|
1303
|
+
const previewRow = purgeStalePreview(db, mctx, retainCutoff);
|
|
1412
1304
|
const lines = [
|
|
1413
1305
|
'purge_stale preview (confirm=false):',
|
|
1414
1306
|
` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`,
|
|
@@ -1425,99 +1317,29 @@ server.registerTool(
|
|
|
1425
1317
|
|
|
1426
1318
|
db.transaction(() => {
|
|
1427
1319
|
if (ops.includes('cleanup')) {
|
|
1428
|
-
const deleted = db
|
|
1429
|
-
|
|
1430
|
-
WHERE id IN (
|
|
1431
|
-
SELECT id FROM observations
|
|
1432
|
-
WHERE COALESCE(compressed_into, 0) = 0
|
|
1433
|
-
AND (title IS NULL OR title = '')
|
|
1434
|
-
AND (narrative IS NULL OR narrative = '')
|
|
1435
|
-
${projectFilter}
|
|
1436
|
-
LIMIT ${OP_ROW_CAP}
|
|
1437
|
-
)
|
|
1438
|
-
`).run(...baseParams);
|
|
1439
|
-
results.push(`Cleaned up ${deleted.changes} broken observations` + (deleted.changes >= OP_ROW_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1320
|
+
const deleted = cleanupBroken(db, mctx);
|
|
1321
|
+
results.push(`Cleaned up ${deleted} broken observations` + (deleted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1440
1322
|
}
|
|
1441
1323
|
|
|
1442
1324
|
if (ops.includes('decay')) {
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
WHERE COALESCE(compressed_into, 0) = 0
|
|
1448
|
-
AND COALESCE(importance, 1) > 1
|
|
1449
|
-
AND COALESCE(access_count, 0) = 0
|
|
1450
|
-
AND created_at_epoch < ?
|
|
1451
|
-
${projectFilter}
|
|
1452
|
-
LIMIT ${OP_ROW_CAP}
|
|
1453
|
-
)
|
|
1454
|
-
`).run(staleAge, ...baseParams);
|
|
1455
|
-
|
|
1456
|
-
// Mark importance=1, never-accessed, old observations as pending-purge
|
|
1457
|
-
const idleMarked = db.prepare(`
|
|
1458
|
-
UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
|
|
1459
|
-
WHERE id IN (
|
|
1460
|
-
SELECT id FROM observations
|
|
1461
|
-
WHERE COALESCE(compressed_into, 0) = 0
|
|
1462
|
-
AND COALESCE(importance, 1) = 1
|
|
1463
|
-
AND COALESCE(access_count, 0) = 0
|
|
1464
|
-
AND created_at_epoch < ?
|
|
1465
|
-
${projectFilter}
|
|
1466
|
-
LIMIT ${OP_ROW_CAP}
|
|
1467
|
-
)
|
|
1468
|
-
`).run(staleAge, ...baseParams);
|
|
1469
|
-
results.push(`Decayed ${decayed.changes} stale observations, marked ${idleMarked.changes} idle as pending-purge` + ((decayed.changes >= OP_ROW_CAP || idleMarked.changes >= OP_ROW_CAP) ? ' (cap reached, re-run for more)' : ''));
|
|
1325
|
+
// injection_count>0 protected (maintain-core; unifies with CLI + hook —
|
|
1326
|
+
// the MCP copy previously lacked this clause and decayed/purged injected memories).
|
|
1327
|
+
const { decayed, idleMarked } = decayAndMarkIdle(db, mctx);
|
|
1328
|
+
results.push(`Decayed ${decayed} stale observations, marked ${idleMarked} idle as pending-purge` + ((decayed >= OP_CAP || idleMarked >= OP_CAP) ? ' (cap reached, re-run for more)' : ''));
|
|
1470
1329
|
}
|
|
1471
1330
|
|
|
1472
1331
|
if (ops.includes('boost')) {
|
|
1473
|
-
const boosted = db
|
|
1474
|
-
|
|
1475
|
-
WHERE id IN (
|
|
1476
|
-
SELECT id FROM observations
|
|
1477
|
-
WHERE COALESCE(compressed_into, 0) = 0
|
|
1478
|
-
AND COALESCE(access_count, 0) > 3
|
|
1479
|
-
AND COALESCE(importance, 1) < 3
|
|
1480
|
-
${projectFilter}
|
|
1481
|
-
LIMIT ${OP_ROW_CAP}
|
|
1482
|
-
)
|
|
1483
|
-
`).run(...baseParams);
|
|
1484
|
-
results.push(`Boosted ${boosted.changes} frequently-accessed observations` + (boosted.changes >= OP_ROW_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1332
|
+
const boosted = boostAccessed(db, mctx);
|
|
1333
|
+
results.push(`Boosted ${boosted} frequently-accessed observations` + (boosted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1485
1334
|
}
|
|
1486
1335
|
|
|
1487
1336
|
if (ops.includes('demote_pinned')) {
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
// times but never cited stays pinned at max importance and keeps
|
|
1491
|
-
// dominating injection. Target heavy-injection + zero-citation and
|
|
1492
|
-
// drop importance to 1 in one pass — injection priority is binary
|
|
1493
|
-
// (importance>=2), so a 3→2 step would not de-rank it. Floor 1 (not
|
|
1494
|
-
// purge). PINNED_INJ_THRESHOLD=8.
|
|
1495
|
-
const demoted = db.prepare(`
|
|
1496
|
-
UPDATE observations SET importance = 1
|
|
1497
|
-
WHERE id IN (
|
|
1498
|
-
SELECT id FROM observations
|
|
1499
|
-
WHERE COALESCE(compressed_into, 0) = 0
|
|
1500
|
-
AND COALESCE(injection_count, 0) >= 8
|
|
1501
|
-
AND COALESCE(cited_count, 0) = 0
|
|
1502
|
-
AND COALESCE(importance, 1) > 1
|
|
1503
|
-
${projectFilter}
|
|
1504
|
-
LIMIT ${OP_ROW_CAP}
|
|
1505
|
-
)
|
|
1506
|
-
`).run(...baseParams);
|
|
1507
|
-
results.push(`Demoted ${demoted.changes} pinned-but-uncited observations to importance 1 (inj>=8, cited=0)` + (demoted.changes >= OP_ROW_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1337
|
+
const demoted = demotePinned(db, mctx);
|
|
1338
|
+
results.push(`Demoted ${demoted} pinned-but-uncited observations to importance 1 (inj>=8, cited=0)` + (demoted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1508
1339
|
}
|
|
1509
1340
|
|
|
1510
1341
|
if (ops.includes('dedup') && args.merge_ids) {
|
|
1511
|
-
|
|
1512
|
-
const mergeStmt = db.prepare('UPDATE observations SET compressed_into = ? WHERE id = ? AND COALESCE(compressed_into, 0) = 0');
|
|
1513
|
-
for (const group of args.merge_ids) {
|
|
1514
|
-
if (group.length < 2) continue;
|
|
1515
|
-
const [keepId, ...removeIds] = group;
|
|
1516
|
-
for (const removeId of removeIds) {
|
|
1517
|
-
const result = mergeStmt.run(keepId, removeId);
|
|
1518
|
-
totalMerged += result.changes;
|
|
1519
|
-
}
|
|
1520
|
-
}
|
|
1342
|
+
const totalMerged = mergeDuplicates(db, args.merge_ids);
|
|
1521
1343
|
results.push(`Merged ${totalMerged} duplicate observations`);
|
|
1522
1344
|
}
|
|
1523
1345
|
|
|
@@ -1526,19 +1348,10 @@ server.registerTool(
|
|
|
1526
1348
|
}
|
|
1527
1349
|
|
|
1528
1350
|
if (ops.includes('purge_stale')) {
|
|
1529
|
-
// Delete observations previously marked as pending-purge by idle cleanup.
|
|
1530
|
-
// Requires user confirmation via /mem:update or /mem:mem.
|
|
1531
1351
|
const retainDays = args.retain_days ?? 30;
|
|
1532
1352
|
const retainCutoff = Date.now() - retainDays * 86400000;
|
|
1533
|
-
const purged = db
|
|
1534
|
-
|
|
1535
|
-
WHERE id IN (
|
|
1536
|
-
SELECT id FROM observations
|
|
1537
|
-
WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ? ${projectFilter}
|
|
1538
|
-
LIMIT ${OP_ROW_CAP}
|
|
1539
|
-
)
|
|
1540
|
-
`).run(retainCutoff, ...baseParams);
|
|
1541
|
-
results.push(`Purged ${purged.changes} stale observations (retained last ${retainDays} days)` + (purged.changes >= OP_ROW_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1353
|
+
const purged = purgeStale(db, mctx, retainCutoff);
|
|
1354
|
+
results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1542
1355
|
}
|
|
1543
1356
|
})();
|
|
1544
1357
|
|
|
@@ -1546,50 +1359,25 @@ server.registerTool(
|
|
|
1546
1359
|
db.exec("INSERT INTO observations_fts(observations_fts) VALUES('optimize')");
|
|
1547
1360
|
results.push('FTS5 index optimized');
|
|
1548
1361
|
|
|
1549
|
-
// rebuild_vectors: outside main transaction (
|
|
1362
|
+
// rebuild_vectors: outside main transaction (maintain-core, shared with CLI).
|
|
1550
1363
|
if (ops.includes('rebuild_vectors')) {
|
|
1551
1364
|
try {
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
} else {
|
|
1557
|
-
const allObs = db.prepare(`
|
|
1558
|
-
SELECT id, title, narrative, concepts FROM observations
|
|
1559
|
-
WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
|
|
1560
|
-
`).all();
|
|
1561
|
-
let updated = 0;
|
|
1562
|
-
const insertStmt = db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)');
|
|
1563
|
-
const now = Date.now();
|
|
1564
|
-
db.transaction(() => {
|
|
1565
|
-
db.prepare('DELETE FROM observation_vectors').run();
|
|
1566
|
-
for (const obs of allObs) {
|
|
1567
|
-
const text = [obs.title || '', obs.narrative || '', obs.concepts || ''].filter(Boolean).join(' ');
|
|
1568
|
-
const vec = computeVector(text, vocab);
|
|
1569
|
-
if (vec) {
|
|
1570
|
-
insertStmt.run(obs.id, Buffer.from(vec.buffer), vocab.version, now);
|
|
1571
|
-
updated++;
|
|
1572
|
-
}
|
|
1573
|
-
}
|
|
1574
|
-
})();
|
|
1575
|
-
results.push(`Vectors: rebuilt vocabulary (${vocab.terms.size} terms), updated ${updated}/${allObs.length} vectors`);
|
|
1576
|
-
}
|
|
1365
|
+
const r = rebuildVectors(db);
|
|
1366
|
+
results.push(r.ok
|
|
1367
|
+
? `Vectors: rebuilt vocabulary (${r.terms} terms), updated ${r.updated}/${r.total} vectors`
|
|
1368
|
+
: `Vectors: ${r.reason}`);
|
|
1577
1369
|
} catch (e) {
|
|
1578
1370
|
debugCatch(e, 'rebuild_vectors');
|
|
1579
1371
|
results.push(`Vectors: rebuild failed — ${e.message}`);
|
|
1580
1372
|
}
|
|
1581
1373
|
}
|
|
1582
1374
|
|
|
1583
|
-
// vacuum: reclaim freelist dead space left by DELETEs.
|
|
1584
|
-
//
|
|
1375
|
+
// vacuum: reclaim freelist dead space left by DELETEs. Whole-DB, outside any
|
|
1376
|
+
// transaction. maintain-core, shared with CLI.
|
|
1585
1377
|
if (ops.includes('vacuum')) {
|
|
1586
1378
|
try {
|
|
1587
|
-
const
|
|
1588
|
-
|
|
1589
|
-
db.exec('VACUUM');
|
|
1590
|
-
const freeAfter = db.pragma('freelist_count', { simple: true });
|
|
1591
|
-
const reclaimedMB = ((Math.max(0, freeBefore - freeAfter) * pageSize) / 1048576).toFixed(1);
|
|
1592
|
-
results.push(`VACUUM: reclaimed ~${reclaimedMB}MB (freelist ${freeBefore} → ${freeAfter} pages)`);
|
|
1379
|
+
const v = vacuum(db);
|
|
1380
|
+
results.push(`VACUUM: reclaimed ~${v.reclaimedMB}MB (freelist ${v.freeBefore} → ${v.freeAfter} pages)`);
|
|
1593
1381
|
} catch (e) {
|
|
1594
1382
|
debugCatch(e, 'vacuum');
|
|
1595
1383
|
results.push(`VACUUM failed — ${e.message}`);
|
package/source-files.mjs
CHANGED
|
@@ -77,6 +77,15 @@ export const SOURCE_FILES = [
|
|
|
77
77
|
// mem-cli.mjs::cmdSave and server.mjs::mem_save. Statically imported from both
|
|
78
78
|
// entry points; missing it from the manifest broke MCP saves on auto-update.
|
|
79
79
|
'lib/save-observation.mjs',
|
|
80
|
+
// Shared "compress old low-value observations into weekly summaries" core.
|
|
81
|
+
// Statically imported by mem-cli.mjs (cmdCompress), server.mjs (mem_compress),
|
|
82
|
+
// and hook.mjs (handleAutoCompress) — same single-source-of-truth pattern as
|
|
83
|
+
// save-observation.mjs; missing it from the manifest would break compress on auto-update.
|
|
84
|
+
'lib/compress-core.mjs',
|
|
85
|
+
// Shared maintenance ops (decay/cleanup/boost/demote/dedup/purge/vacuum/rebuild).
|
|
86
|
+
// Statically imported by mem-cli.mjs (cmdMaintain), server.mjs (mem_maintain),
|
|
87
|
+
// and hook.mjs (handleAutoMaintain) — missing it would break maintain on auto-update.
|
|
88
|
+
'lib/maintain-core.mjs',
|
|
80
89
|
// v2.70 deferred-work: carry-forward TODO primitives. Statically imported by
|
|
81
90
|
// server.mjs (mem_defer family) and mem-cli.mjs (defer subcommand).
|
|
82
91
|
'lib/deferred-work.mjs',
|