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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +2 -7
- package/commands/mem.md +7 -0
- package/commands/update.md +2 -1
- package/dispatch-inject.mjs +5 -4
- package/dispatch.mjs +37 -15
- package/hook-shared.mjs +10 -6
- package/hook.mjs +6 -5
- package/hooks/hooks.json +1 -1
- package/install.mjs +440 -11
- package/package.json +1 -1
- package/registry/preinstalled.json +0 -13
- package/registry-retriever.mjs +0 -3
- package/registry.mjs +1 -1
- package/schema.mjs +1 -0
- package/scripts/setup.sh +20 -1
- package/server.mjs +153 -159
- package/tool-schemas.mjs +4 -2
- package/utils.mjs +10 -2
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,
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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-
|
|
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
|
|
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 (
|
|
988
|
+
if (!titles[i] || !minhashes[i]) continue;
|
|
1032
989
|
for (let j = i + 1; j < recent.length; j++) {
|
|
1033
|
-
if (
|
|
1034
|
-
|
|
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
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1264
|
-
|
|
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 (
|
|
1270
|
-
debugLog('INFO', 'idle-cleanup', `
|
|
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
|
-
//
|
|
1275
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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(/^[
|
|
711
|
+
.map(w => w.toLowerCase().replace(/^[.-]+|[.-]+$/g, ''))
|
|
704
712
|
.filter(w => w.length >= 3);
|
|
705
713
|
}
|
|
706
714
|
|