claude-mem-lite 2.3.1 → 2.3.3

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/server.mjs CHANGED
@@ -4,12 +4,15 @@
4
4
 
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
- import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, scrubSecrets, fmtDate, isoWeekKey, debugLog, debugCatch } from './utils.mjs';
8
- import { ensureDb, DB_PATH, DB_DIR } from './schema.mjs';
7
+ import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, fmtDate, isoWeekKey, debugLog, debugCatch, COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE } from './utils.mjs';
8
+ import { ensureDb, DB_PATH, REGISTRY_DB_PATH } from './schema.mjs';
9
9
  import { reRankWithContext, markSuperseded, extractPRFTerms, expandQueryByConcepts } from './server-internals.mjs';
10
10
  import { memSearchSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memRegistrySchema } from './tool-schemas.mjs';
11
11
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
12
- import { join } from 'path';
12
+ import { createRequire } from 'module';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { version: PKG_VERSION } = require('./package.json');
13
16
 
14
17
  // ─── Database ───────────────────────────────────────────────────────────────
15
18
 
@@ -38,7 +41,6 @@ db.pragma('busy_timeout = 5000');
38
41
 
39
42
  // ─── Registry Database (lazy-loaded on first mem_registry call) ─────────────
40
43
 
41
- const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
42
44
  let registryDb = null;
43
45
 
44
46
  function getRegistryDb() {
@@ -79,7 +81,7 @@ const RECENCY_HALF_LIFE_MS = 1209600000; // 14 days in milliseconds
79
81
  // ─── MCP Server ─────────────────────────────────────────────────────────────
80
82
 
81
83
  const server = new McpServer(
82
- { name: 'claude-mem-lite', version: '2.2.2' },
84
+ { name: 'claude-mem-lite', version: PKG_VERSION },
83
85
  {
84
86
  instructions: [
85
87
  'Proactively search memory to leverage past experience. This is your long-term memory across sessions.',
@@ -119,6 +121,69 @@ function safeHandler(fn) {
119
121
 
120
122
  // ─── Tool: mem_search — helper functions ────────────────────────────────────
121
123
 
124
+ // Score expression variants for FTS5 queries (see Scoring Model Constants above)
125
+ const FULL_SCORE = `${OBS_BM25}
126
+ * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${RECENCY_HALF_LIFE_MS}.0))
127
+ * (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
128
+ * (0.5 + 0.5 * COALESCE(o.importance, 1))
129
+ * (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))`;
130
+
131
+ const SIMPLE_SCORE = `${OBS_BM25}
132
+ * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${RECENCY_HALF_LIFE_MS}.0))
133
+ * (0.5 + 0.5 * COALESCE(o.importance, 1))`;
134
+
135
+ /**
136
+ * Build an FTS5 observation search query.
137
+ * @param {'full'|'simple'} scoring - full includes project boost + access bonus
138
+ * @param {object} opts - { multiplier, withSnippet, withOffset }
139
+ */
140
+ function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset } = {}) {
141
+ const scoreExpr = scoring === 'full' ? FULL_SCORE : SIMPLE_SCORE;
142
+ const mult = multiplier ? ` * ${multiplier}` : '';
143
+ return `
144
+ SELECT o.id, o.type, o.title, o.subtitle, o.project, o.created_at, o.importance,
145
+ o.files_modified,
146
+ ${withSnippet ? "snippet(observations_fts, 2, '»', '«', '…', 10) as match_snippet," : ''}
147
+ ${scoreExpr}${mult} as score
148
+ FROM observations_fts
149
+ JOIN observations o ON observations_fts.rowid = o.id
150
+ WHERE observations_fts MATCH ?
151
+ AND COALESCE(o.compressed_into, 0) = 0
152
+ AND (? IS NULL OR o.project = ?)
153
+ AND (? IS NULL OR o.type = ?)
154
+ AND (? IS NULL OR o.created_at_epoch >= ?)
155
+ AND (? IS NULL OR o.created_at_epoch <= ?)
156
+ AND (? IS NULL OR COALESCE(o.importance, 1) >= ?)
157
+ ORDER BY score
158
+ LIMIT ?${withOffset ? ' OFFSET ?' : ''}`;
159
+ }
160
+
161
+ /** Build params array for an FTS5 observation query. */
162
+ function buildObsFtsParams({ now, projectBoost, ftsQuery, args, epochFrom, epochTo, limit, offset }) {
163
+ const params = [now];
164
+ if (projectBoost !== undefined) params.push(projectBoost, projectBoost); // full scoring only
165
+ params.push(
166
+ ftsQuery,
167
+ args.project ?? null, args.project ?? null,
168
+ args.obs_type ?? null, args.obs_type ?? null,
169
+ epochFrom, epochFrom,
170
+ epochTo, epochTo,
171
+ args.importance ?? null, args.importance ?? null,
172
+ limit,
173
+ );
174
+ if (offset !== undefined) params.push(offset);
175
+ return params;
176
+ }
177
+
178
+ /** Map a raw FTS5 row to a result object. */
179
+ function ftsRowToResult(r, { scoreMultiplier, snippet } = {}) {
180
+ return {
181
+ source: 'obs', id: r.id, type: r.type, title: r.title, subtitle: r.subtitle,
182
+ project: r.project, date: r.created_at, score: scoreMultiplier ? r.score * scoreMultiplier : r.score,
183
+ files_modified: r.files_modified, importance: r.importance, snippet: snippet ? (r.match_snippet || '') : '',
184
+ };
185
+ }
186
+
122
187
  function searchObservations(ctx) {
123
188
  const { ftsQuery, args, epochFrom, epochTo, perSourceLimit, perSourceOffset, currentProject, limit } = ctx;
124
189
  const results = [];
@@ -126,81 +191,19 @@ function searchObservations(ctx) {
126
191
  if (ftsQuery) {
127
192
  const now = Date.now();
128
193
  const projectBoost = args.project ? null : currentProject;
129
- const rows = db.prepare(`
130
- SELECT o.id, o.type, o.title, o.subtitle, o.project, o.created_at, o.importance,
131
- o.files_modified,
132
- snippet(observations_fts, 2, '»', '«', '…', 10) as match_snippet,
133
- ${OBS_BM25}
134
- * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${RECENCY_HALF_LIFE_MS}.0))
135
- * (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
136
- * (0.5 + 0.5 * COALESCE(o.importance, 1))
137
- * (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0))) as score
138
- FROM observations_fts
139
- JOIN observations o ON observations_fts.rowid = o.id
140
- WHERE observations_fts MATCH ?
141
- AND COALESCE(o.compressed_into, 0) = 0
142
- AND (? IS NULL OR o.project = ?)
143
- AND (? IS NULL OR o.type = ?)
144
- AND (? IS NULL OR o.created_at_epoch >= ?)
145
- AND (? IS NULL OR o.created_at_epoch <= ?)
146
- AND (? IS NULL OR COALESCE(o.importance, 1) >= ?)
147
- ORDER BY score
148
- LIMIT ? OFFSET ?
149
- `).all(
150
- now,
151
- projectBoost, projectBoost,
152
- ftsQuery,
153
- args.project ?? null, args.project ?? null,
154
- args.obs_type ?? null, args.obs_type ?? null,
155
- epochFrom, epochFrom,
156
- epochTo, epochTo,
157
- args.importance ?? null, args.importance ?? null,
158
- perSourceLimit, perSourceOffset
159
- );
160
- for (const r of rows) {
161
- results.push({ source: 'obs', id: r.id, type: r.type, title: r.title, subtitle: r.subtitle, project: r.project, date: r.created_at, score: r.score, files_modified: r.files_modified, importance: r.importance, snippet: r.match_snippet || '' });
162
- }
194
+
195
+ const rows = db.prepare(buildObsFtsQuery('full', { withSnippet: true, withOffset: true }))
196
+ .all(...buildObsFtsParams({ now, projectBoost, ftsQuery, args, epochFrom, epochTo, limit: perSourceLimit, offset: perSourceOffset }));
197
+ for (const r of rows) results.push(ftsRowToResult(r, { snippet: true }));
163
198
 
164
199
  // OR fallback: when AND query returns 0 results, retry with OR semantics
165
200
  if (rows.length === 0) {
166
201
  const orQuery = relaxFtsQueryToOr(ftsQuery);
167
202
  if (orQuery) {
168
203
  try {
169
- const orRows = db.prepare(`
170
- SELECT o.id, o.type, o.title, o.subtitle, o.project, o.created_at, o.importance,
171
- o.files_modified,
172
- snippet(observations_fts, 2, '»', '«', '…', 10) as match_snippet,
173
- ${OBS_BM25}
174
- * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${RECENCY_HALF_LIFE_MS}.0))
175
- * (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
176
- * (0.5 + 0.5 * COALESCE(o.importance, 1))
177
- * (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))
178
- * 0.8 as score
179
- FROM observations_fts
180
- JOIN observations o ON observations_fts.rowid = o.id
181
- WHERE observations_fts MATCH ?
182
- AND COALESCE(o.compressed_into, 0) = 0
183
- AND (? IS NULL OR o.project = ?)
184
- AND (? IS NULL OR o.type = ?)
185
- AND (? IS NULL OR o.created_at_epoch >= ?)
186
- AND (? IS NULL OR o.created_at_epoch <= ?)
187
- AND (? IS NULL OR COALESCE(o.importance, 1) >= ?)
188
- ORDER BY score
189
- LIMIT ? OFFSET ?
190
- `).all(
191
- now,
192
- projectBoost, projectBoost,
193
- orQuery,
194
- args.project ?? null, args.project ?? null,
195
- args.obs_type ?? null, args.obs_type ?? null,
196
- epochFrom, epochFrom,
197
- epochTo, epochTo,
198
- args.importance ?? null, args.importance ?? null,
199
- perSourceLimit, perSourceOffset
200
- );
201
- for (const r of orRows) {
202
- results.push({ source: 'obs', id: r.id, type: r.type, title: r.title, subtitle: r.subtitle, project: r.project, date: r.created_at, score: r.score, files_modified: r.files_modified, importance: r.importance, snippet: r.match_snippet || '' });
203
- }
204
+ const orRows = db.prepare(buildObsFtsQuery('full', { multiplier: 0.8, withSnippet: true, withOffset: true }))
205
+ .all(...buildObsFtsParams({ now, projectBoost, ftsQuery: orQuery, args, epochFrom, epochTo, limit: perSourceLimit, offset: perSourceOffset }));
206
+ for (const r of orRows) results.push(ftsRowToResult(r, { snippet: true }));
204
207
  } catch (e) { debugCatch(e, 'searchObservations-or-fallback'); }
205
208
  }
206
209
  }
@@ -242,36 +245,12 @@ function expandObsByConceptCo(ctx, now, existingIds, results) {
242
245
  if (expanded.length === 0) return;
243
246
  const expansionFts = expanded.map(c => `"${c.replace(/"/g, '""')}"`).join(' OR ');
244
247
  try {
245
- const expRows = db.prepare(`
246
- SELECT o.id, o.type, o.title, o.subtitle, o.project, o.created_at, o.importance,
247
- o.files_modified,
248
- ${OBS_BM25}
249
- * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${RECENCY_HALF_LIFE_MS}.0))
250
- * (0.5 + 0.5 * COALESCE(o.importance, 1)) as score
251
- FROM observations_fts
252
- JOIN observations o ON observations_fts.rowid = o.id
253
- WHERE observations_fts MATCH ?
254
- AND COALESCE(o.compressed_into, 0) = 0
255
- AND (? IS NULL OR o.project = ?)
256
- AND (? IS NULL OR o.type = ?)
257
- AND (? IS NULL OR o.created_at_epoch >= ?)
258
- AND (? IS NULL OR o.created_at_epoch <= ?)
259
- AND (? IS NULL OR COALESCE(o.importance, 1) >= ?)
260
- ORDER BY score
261
- LIMIT ?
262
- `).all(
263
- now, expansionFts,
264
- args.project ?? null, args.project ?? null,
265
- args.obs_type ?? null, args.obs_type ?? null,
266
- epochFrom, epochFrom,
267
- epochTo, epochTo,
268
- args.importance ?? null, args.importance ?? null,
269
- limit
270
- );
248
+ const expRows = db.prepare(buildObsFtsQuery('simple'))
249
+ .all(...buildObsFtsParams({ now, ftsQuery: expansionFts, args, epochFrom, epochTo, limit }));
271
250
  for (const r of expRows) {
272
251
  if (!existingIds.has(r.id)) {
273
252
  existingIds.add(r.id);
274
- results.push({ source: 'obs', id: r.id, type: r.type, title: r.title, subtitle: r.subtitle, project: r.project, date: r.created_at, score: r.score * 0.7, files_modified: r.files_modified, importance: r.importance, snippet: '' });
253
+ results.push(ftsRowToResult(r, { scoreMultiplier: 0.7 }));
275
254
  }
276
255
  }
277
256
  } catch (e) { debugLog('WARN', 'mem_search', `concept expansion error: ${e.message}`); }
@@ -292,36 +271,12 @@ function expandObsByPRF(ctx, now, primaryCount, existingIds, results) {
292
271
  if (prfTerms.length === 0) return;
293
272
  const prfFts = prfTerms.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
294
273
  try {
295
- const prfRows = db.prepare(`
296
- SELECT o.id, o.type, o.title, o.subtitle, o.project, o.created_at, o.importance,
297
- o.files_modified,
298
- ${OBS_BM25}
299
- * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${RECENCY_HALF_LIFE_MS}.0))
300
- * (0.5 + 0.5 * COALESCE(o.importance, 1)) as score
301
- FROM observations_fts
302
- JOIN observations o ON observations_fts.rowid = o.id
303
- WHERE observations_fts MATCH ?
304
- AND COALESCE(o.compressed_into, 0) = 0
305
- AND (? IS NULL OR o.project = ?)
306
- AND (? IS NULL OR o.type = ?)
307
- AND (? IS NULL OR o.created_at_epoch >= ?)
308
- AND (? IS NULL OR o.created_at_epoch <= ?)
309
- AND (? IS NULL OR COALESCE(o.importance, 1) >= ?)
310
- ORDER BY score
311
- LIMIT ?
312
- `).all(
313
- now, prfFts,
314
- args.project ?? null, args.project ?? null,
315
- args.obs_type ?? null, args.obs_type ?? null,
316
- epochFrom, epochFrom,
317
- epochTo, epochTo,
318
- args.importance ?? null, args.importance ?? null,
319
- limit
320
- );
274
+ const prfRows = db.prepare(buildObsFtsQuery('simple'))
275
+ .all(...buildObsFtsParams({ now, ftsQuery: prfFts, args, epochFrom, epochTo, limit }));
321
276
  for (const r of prfRows) {
322
277
  if (!existingIds.has(r.id)) {
323
278
  existingIds.add(r.id);
324
- results.push({ source: 'obs', id: r.id, type: r.type, title: r.title, subtitle: r.subtitle, project: r.project, date: r.created_at, score: r.score * 0.6, files_modified: r.files_modified, importance: r.importance, snippet: '' });
279
+ results.push(ftsRowToResult(r, { scoreMultiplier: 0.6 }));
325
280
  }
326
281
  }
327
282
  } catch (e) { debugLog('WARN', 'mem_search', `PRF expansion error: ${e.message}`); }
@@ -1016,7 +971,7 @@ server.registerTool(
1016
971
  const baseParams = project ? [project] : [];
1017
972
 
1018
973
  if (action === 'scan') {
1019
- // 1. Find near-duplicate titles (pre-compute word sets, then O(n²) Jaccard)
974
+ // 1. Find near-duplicate titles (MinHash pre-filter exact Jaccard on candidates)
1020
975
  const recent = db.prepare(`
1021
976
  SELECT id, title, project, importance, access_count, created_at_epoch
1022
977
  FROM observations
@@ -1025,13 +980,17 @@ server.registerTool(
1025
980
  LIMIT ${SCAN_LIMIT}
1026
981
  `).all(...baseParams);
1027
982
 
1028
- const wordSets = recent.map(r => new Set((r.title || '').toLowerCase().split(/\s+/).filter(w => w.length > 2)));
983
+ const titles = recent.map(r => (r.title || '').trim());
984
+ const minhashes = titles.map(t => t ? computeMinHash(t) : null);
985
+ const MINHASH_PRE_THRESHOLD = 0.5; // loose pre-filter to catch candidates
1029
986
  const duplicates = [];
1030
987
  for (let i = 0; i < recent.length && duplicates.length < DUPLICATE_LIMIT; i++) {
1031
- if (wordSets[i].size === 0) continue;
988
+ if (!titles[i] || !minhashes[i]) continue;
1032
989
  for (let j = i + 1; j < recent.length; j++) {
1033
- if (wordSets[j].size === 0) continue;
1034
- const sim = jaccardSimilarity(wordSets[i], wordSets[j]);
990
+ if (!titles[j] || !minhashes[j]) continue;
991
+ // Fast MinHash estimate to skip obvious non-matches
992
+ if (estimateJaccardFromMinHash(minhashes[i], minhashes[j]) < MINHASH_PRE_THRESHOLD) continue;
993
+ const sim = jaccardSimilarity(titles[i], titles[j]);
1035
994
  if (sim > SIMILARITY_THRESHOLD) {
1036
995
  duplicates.push({
1037
996
  a: { id: recent[i].id, title: recent[i].title, importance: recent[i].importance },
@@ -1058,6 +1017,11 @@ server.registerTool(
1058
1017
  WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}
1059
1018
  `).get(staleAge, ...baseParams);
1060
1019
 
1020
+ // Count pending-purge items (marked by idle cleanup)
1021
+ const pendingPurge = db.prepare(`
1022
+ SELECT COUNT(*) as count FROM observations WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} ${projectFilter}
1023
+ `).get(...baseParams);
1024
+
1061
1025
  const lines = [
1062
1026
  `Memory maintenance scan:`,
1063
1027
  ` Total active observations: ${stats.total}`,
@@ -1065,6 +1029,7 @@ server.registerTool(
1065
1029
  ` Stale (>30d, imp=1, no access): ${stats.stale}`,
1066
1030
  ` Broken (no title/narrative): ${stats.broken}`,
1067
1031
  ` Boostable (accessed>3, imp<3): ${stats.boostable}`,
1032
+ ` Pending purge (idle-marked): ${pendingPurge.count}`,
1068
1033
  ];
1069
1034
  if (duplicates.length > 0) {
1070
1035
  lines.push('', 'Top duplicates:');
@@ -1079,40 +1044,53 @@ server.registerTool(
1079
1044
  const ops = args.operations || [];
1080
1045
  const results = [];
1081
1046
  const staleAge = Date.now() - STALE_AGE_MS;
1047
+ const OP_ROW_CAP = 1000; // safety cap per operation
1082
1048
 
1083
1049
  db.transaction(() => {
1084
1050
  if (ops.includes('cleanup')) {
1085
1051
  const deleted = db.prepare(`
1086
1052
  DELETE FROM observations
1087
- WHERE COALESCE(compressed_into, 0) = 0
1088
- AND (title IS NULL OR title = '')
1089
- AND (narrative IS NULL OR narrative = '')
1090
- ${projectFilter}
1053
+ WHERE id IN (
1054
+ SELECT id FROM observations
1055
+ WHERE COALESCE(compressed_into, 0) = 0
1056
+ AND (title IS NULL OR title = '')
1057
+ AND (narrative IS NULL OR narrative = '')
1058
+ ${projectFilter}
1059
+ LIMIT ${OP_ROW_CAP}
1060
+ )
1091
1061
  `).run(...baseParams);
1092
- results.push(`Cleaned up ${deleted.changes} broken observations`);
1062
+ results.push(`Cleaned up ${deleted.changes} broken observations` + (deleted.changes >= OP_ROW_CAP ? ' (cap reached, re-run for more)' : ''));
1093
1063
  }
1094
1064
 
1095
1065
  if (ops.includes('decay')) {
1096
1066
  const decayed = db.prepare(`
1097
1067
  UPDATE observations SET importance = MAX(1, COALESCE(importance, 1) - 1)
1098
- WHERE COALESCE(compressed_into, 0) = 0
1099
- AND COALESCE(importance, 1) > 1
1100
- AND COALESCE(access_count, 0) = 0
1101
- AND created_at_epoch < ?
1102
- ${projectFilter}
1068
+ WHERE id IN (
1069
+ SELECT id FROM observations
1070
+ WHERE COALESCE(compressed_into, 0) = 0
1071
+ AND COALESCE(importance, 1) > 1
1072
+ AND COALESCE(access_count, 0) = 0
1073
+ AND created_at_epoch < ?
1074
+ ${projectFilter}
1075
+ LIMIT ${OP_ROW_CAP}
1076
+ )
1103
1077
  `).run(staleAge, ...baseParams);
1104
- results.push(`Decayed ${decayed.changes} stale observations`);
1078
+ results.push(`Decayed ${decayed.changes} stale observations` + (decayed.changes >= OP_ROW_CAP ? ' (cap reached, re-run for more)' : ''));
1105
1079
  }
1106
1080
 
1107
1081
  if (ops.includes('boost')) {
1108
1082
  const boosted = db.prepare(`
1109
1083
  UPDATE observations SET importance = MIN(3, COALESCE(importance, 1) + 1)
1110
- WHERE COALESCE(compressed_into, 0) = 0
1111
- AND COALESCE(access_count, 0) > 3
1112
- AND COALESCE(importance, 1) < 3
1113
- ${projectFilter}
1084
+ WHERE id IN (
1085
+ SELECT id FROM observations
1086
+ WHERE COALESCE(compressed_into, 0) = 0
1087
+ AND COALESCE(access_count, 0) > 3
1088
+ AND COALESCE(importance, 1) < 3
1089
+ ${projectFilter}
1090
+ LIMIT ${OP_ROW_CAP}
1091
+ )
1114
1092
  `).run(...baseParams);
1115
- results.push(`Boosted ${boosted.changes} frequently-accessed observations`);
1093
+ results.push(`Boosted ${boosted.changes} frequently-accessed observations` + (boosted.changes >= OP_ROW_CAP ? ' (cap reached, re-run for more)' : ''));
1116
1094
  }
1117
1095
 
1118
1096
  if (ops.includes('dedup') && args.merge_ids) {
@@ -1126,6 +1104,22 @@ server.registerTool(
1126
1104
  }
1127
1105
  results.push(`Merged ${totalMerged} duplicate observations`);
1128
1106
  }
1107
+
1108
+ if (ops.includes('purge_stale')) {
1109
+ // Delete observations previously marked as pending-purge by idle cleanup.
1110
+ // Requires user confirmation via /mem:update or /mem:mem.
1111
+ const retainDays = args.retain_days ?? 30;
1112
+ const retainCutoff = Date.now() - retainDays * 86400000;
1113
+ const purged = db.prepare(`
1114
+ DELETE FROM observations
1115
+ WHERE id IN (
1116
+ SELECT id FROM observations
1117
+ WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ? ${projectFilter}
1118
+ LIMIT ${OP_ROW_CAP}
1119
+ )
1120
+ `).run(retainCutoff, ...baseParams);
1121
+ results.push(`Purged ${purged.changes} stale observations (retained last ${retainDays} days)` + (purged.changes >= OP_ROW_CAP ? ' (cap reached, re-run for more)' : ''));
1122
+ }
1129
1123
  })();
1130
1124
 
1131
1125
  // FTS5 optimize (outside transaction)
@@ -1258,23 +1252,23 @@ const idleTimer = setInterval(() => {
1258
1252
  const thirtyDaysAgo = Date.now() - 30 * 86400000;
1259
1253
 
1260
1254
  db.transaction(() => {
1261
- // Delete old low-quality observations (importance<=1, never accessed, 30+ days).
1255
+ // Mark old low-quality observations as pending-purge (importance<=1, never accessed, 30+ days).
1256
+ // Actual deletion only happens when user confirms via mem_maintain execute purge_stale.
1262
1257
  // NOTE: no project filter — MCP server is global, operates across all projects.
1263
- // This is intentionally broader than hook.mjs auto-compress (which scopes to current project).
1264
- const deleted = db.prepare(`
1265
- DELETE FROM observations
1258
+ const marked = db.prepare(`
1259
+ UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
1266
1260
  WHERE importance <= 1 AND COALESCE(access_count, 0) = 0
1267
1261
  AND created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0
1268
1262
  `).run(thirtyDaysAgo);
1269
- if (deleted.changes > 0) {
1270
- debugLog('INFO', 'idle-cleanup', `Deleted ${deleted.changes} stale low-quality observations`);
1263
+ if (marked.changes > 0) {
1264
+ debugLog('INFO', 'idle-cleanup', `Marked ${marked.changes} stale observations as pending-purge`);
1271
1265
  }
1272
1266
 
1273
- // Mark old importance=1 as compressed (30+ days)
1274
- // NOTE: compressed_into = -1 is an established sentinel meaning "auto-compressed without merge target"
1275
- // (same pattern used in hook.mjs:456 for time-based compression)
1267
+ // Mark old importance=1 with access_count>0 as compressed (30+ days).
1268
+ // Note: importance=1, access_count=0 rows were already marked pending-purge above,
1269
+ // so this only catches importance=1 rows that HAVE been accessed.
1276
1270
  const compressed = db.prepare(`
1277
- UPDATE observations SET compressed_into = -1
1271
+ UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
1278
1272
  WHERE COALESCE(compressed_into, 0) = 0 AND importance = 1
1279
1273
  AND created_at_epoch < ?
1280
1274
  `).run(thirtyDaysAgo);
package/tool-schemas.mjs CHANGED
@@ -57,10 +57,12 @@ export const memCompressSchema = {
57
57
 
58
58
  export const memMaintainSchema = {
59
59
  action: z.enum(['scan', 'execute']).describe('scan=analyze candidates, execute=apply changes'),
60
- operations: z.array(z.enum(['dedup', 'decay', 'cleanup', 'boost'])).optional()
61
- .describe('Operations to execute (for action=execute)'),
60
+ operations: z.array(z.enum(['dedup', 'decay', 'cleanup', 'boost', 'purge_stale'])).optional()
61
+ .describe('Operations to execute (for action=execute). purge_stale deletes idle-marked observations after user confirmation.'),
62
62
  merge_ids: z.array(z.array(z.number().int()).min(2)).optional()
63
63
  .describe('For dedup: [[keepId, removeId1, removeId2], ...] — first ID in each group is kept'),
64
+ retain_days: z.number().int().min(7).max(365).optional()
65
+ .describe('For purge_stale: keep observations newer than N days (default 30)'),
64
66
  project: z.string().optional().describe('Filter by project'),
65
67
  };
66
68
 
package/utils.mjs CHANGED
@@ -3,6 +3,13 @@
3
3
 
4
4
  import { basename, dirname } from 'path';
5
5
 
6
+ // ─── Sentinel Values ────────────────────────────────────────────────────────
7
+
8
+ /** compressed_into sentinel: auto-compressed without merge target */
9
+ export const COMPRESSED_AUTO = -1;
10
+ /** compressed_into sentinel: pending user-confirmed purge (marked by idle cleanup) */
11
+ export const COMPRESSED_PENDING_PURGE = -2;
12
+
6
13
  // ─── String Utilities ────────────────────────────────────────────────────────
7
14
 
8
15
  /**
@@ -420,7 +427,8 @@ export function inferProject() {
420
427
  const parent = basename(dirname(p));
421
428
  const raw = parent && parent !== '.' && parent !== '/' ? `${parent}--${base}` : base;
422
429
  // Sanitize to prevent path traversal when used in filenames (ep-<project>.json)
423
- return raw.replace(/[^a-zA-Z0-9_.-]/g, '-');
430
+ // Truncate to 100 chars to avoid exceeding filesystem name limits (255 bytes)
431
+ return raw.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 100);
424
432
  }
425
433
 
426
434
  // ─── Bash Analysis ───────────────────────────────────────────────────────────
@@ -700,7 +708,7 @@ export function tokenizeHandoff(text) {
700
708
  if (!text) return [];
701
709
  return text
702
710
  .split(/[\s,;:.()[\]{}'"`<>→|/\\#@!?=+*&^%$~]+/)
703
- .map(w => w.toLowerCase().replace(/^[.\-]+|[.\-]+$/g, ''))
711
+ .map(w => w.toLowerCase().replace(/^[.-]+|[.-]+$/g, ''))
704
712
  .filter(w => w.length >= 3);
705
713
  }
706
714