claude-mem-lite 2.90.1 → 2.91.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/lib/maintain-core.mjs +7 -1
- package/lib/save-observation.mjs +7 -0
- package/mem-cli.mjs +20 -5
- package/package.json +1 -1
- package/search-engine.mjs +104 -0
- package/server.mjs +35 -9
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "2.
|
|
13
|
+
"version": "2.91.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.91.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -120,7 +120,13 @@ export function mergeDuplicates(db, groups) {
|
|
|
120
120
|
for (const group of groups) {
|
|
121
121
|
if (!group || group.length < 2) continue;
|
|
122
122
|
const [keepId, ...removeIds] = group;
|
|
123
|
-
for (const removeId of removeIds)
|
|
123
|
+
for (const removeId of removeIds) {
|
|
124
|
+
// Skip self-merge: compressed_into=self hides the row from every
|
|
125
|
+
// compressed_into=0 view (recent/search/browse) — silent data loss from a
|
|
126
|
+
// typo like `--merge-ids 5:5`. Shared core, so this covers CLI + MCP.
|
|
127
|
+
if (removeId === keepId) continue;
|
|
128
|
+
merged += mergeStmt.run(keepId, removeId).changes;
|
|
129
|
+
}
|
|
124
130
|
}
|
|
125
131
|
return merged;
|
|
126
132
|
}
|
package/lib/save-observation.mjs
CHANGED
|
@@ -40,6 +40,13 @@ export function saveObservation(db, params) {
|
|
|
40
40
|
const project = params.project;
|
|
41
41
|
const type = params.type || 'discovery';
|
|
42
42
|
const content = params.content;
|
|
43
|
+
// Defensive single-source guard: never persist an empty/whitespace-only row.
|
|
44
|
+
// CLI's `!text` check and MCP's `z.string().min(1)` both let whitespace-only
|
|
45
|
+
// content through (" " is length>=1 and truthy), creating junk observations
|
|
46
|
+
// with blank title/text. Reject here so both call sites are covered at once.
|
|
47
|
+
if (typeof content !== 'string' || content.trim().length === 0) {
|
|
48
|
+
throw new Error('mem_save: content is empty or whitespace-only');
|
|
49
|
+
}
|
|
43
50
|
const rawTitle = params.title || content.slice(0, 100);
|
|
44
51
|
const importance = params.importance ?? 2;
|
|
45
52
|
const files = Array.isArray(params.files)
|
package/mem-cli.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { resolveProject } from './project-utils.mjs';
|
|
|
11
11
|
import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
12
12
|
import { getVocabulary, computeVector, _resetVocabCache } from './tfidf.mjs';
|
|
13
13
|
import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-internals.mjs';
|
|
14
|
-
import { searchObservationsHybrid, findFtsAnchor } from './search-engine.mjs';
|
|
14
|
+
import { searchObservationsHybrid, findFtsAnchor, countSearchTotal } from './search-engine.mjs';
|
|
15
15
|
import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
16
16
|
import { searchResources } from './registry-retriever.mjs';
|
|
17
17
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
@@ -313,9 +313,24 @@ function cmdSearch(db, args) {
|
|
|
313
313
|
|
|
314
314
|
// Trim to limit with offset. The engine always received perSourceOffset=0 and
|
|
315
315
|
// over-fetched (see above), so the merged+reranked `results` start at row 0 and
|
|
316
|
-
// the offset is applied exactly ONCE here — for every mode.
|
|
317
|
-
//
|
|
318
|
-
|
|
316
|
+
// the offset is applied exactly ONCE here — for every mode.
|
|
317
|
+
//
|
|
318
|
+
// `total` must be the TRUE population, independent of --limit/--offset (else the
|
|
319
|
+
// over-fetched candidate count grew with the page and broke the "N of M" /
|
|
320
|
+
// pagination contract). countSearchTotal mirrors each source's MATCH+filters;
|
|
321
|
+
// clamp to >= results.length so it never understates the rows actually shown
|
|
322
|
+
// (vector/concept augmentation can add obs rows beyond the FTS count).
|
|
323
|
+
const trueTotal = countSearchTotal(db, {
|
|
324
|
+
effectiveSource,
|
|
325
|
+
ftsQuery,
|
|
326
|
+
obsFtsQuery: orFallbackFired ? (relaxFtsQueryToOr(ftsQuery) || ftsQuery) : ftsQuery,
|
|
327
|
+
args: { project: project || null, obs_type: type || null, importance: minImportance || null, branch: branch || null },
|
|
328
|
+
project: project || null,
|
|
329
|
+
epochFrom: dateFrom,
|
|
330
|
+
epochTo: dateTo,
|
|
331
|
+
includeNoise,
|
|
332
|
+
});
|
|
333
|
+
const total = Math.max(trueTotal, results.length);
|
|
319
334
|
const paged = results.slice(offset, offset + limit);
|
|
320
335
|
|
|
321
336
|
if (paged.length === 0) {
|
|
@@ -924,7 +939,7 @@ function cmdTimeline(db, args) {
|
|
|
924
939
|
function cmdSave(db, args) {
|
|
925
940
|
const { positional, flags } = parseArgs(args);
|
|
926
941
|
const text = positional.join(' ');
|
|
927
|
-
if (!text) {
|
|
942
|
+
if (!text.trim()) {
|
|
928
943
|
fail('[mem] Usage: claude-mem-lite save "<text>" [--type T] [--title T] [--importance N] [--project P] [--files f1,f2] [--lesson T] [--closes-deferred 1,D#42]');
|
|
929
944
|
return;
|
|
930
945
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.91.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
package/search-engine.mjs
CHANGED
|
@@ -72,6 +72,110 @@ export function buildObsFtsParams({ now, projectBoost, ftsQuery, args, epochFrom
|
|
|
72
72
|
return params;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// --- True match-count helpers (limit/offset-invariant search totals) ----------
|
|
76
|
+
// The search path over-fetches per source — perSourceLimit = max(limit*3,
|
|
77
|
+
// offset+limit+10) — and historically reported `total = results.length`. That made
|
|
78
|
+
// "Found N of M" and the JSON `total` field grow with --limit/--offset, breaking
|
|
79
|
+
// the documented pagination contract (a query's population must not change when you
|
|
80
|
+
// page through it). These COUNT(*) helpers mirror each source's MATCH + filters
|
|
81
|
+
// exactly, so `total` reflects the real population independent of paging. Shared by
|
|
82
|
+
// CLI and MCP per the paired-path single-source-of-truth rule (#8217).
|
|
83
|
+
//
|
|
84
|
+
// Known approximation: post-SQL filters that DROP rows after the query (CJK
|
|
85
|
+
// precision gate on prompts, --tier on obs) are not reflected here, so those niche
|
|
86
|
+
// queries may overcount. Callers clamp total to >= page size, so it never
|
|
87
|
+
// understates the rows actually shown.
|
|
88
|
+
export function countObsFtsMatches(db, { ftsQuery, args = {}, epochFrom = null, epochTo = null, includeNoise = false }) {
|
|
89
|
+
if (!ftsQuery) return 0;
|
|
90
|
+
const lowSignalClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
|
|
91
|
+
try {
|
|
92
|
+
const row = db.prepare(`
|
|
93
|
+
SELECT COUNT(*) as c
|
|
94
|
+
FROM observations_fts
|
|
95
|
+
JOIN observations o ON observations_fts.rowid = o.id
|
|
96
|
+
WHERE observations_fts MATCH ?
|
|
97
|
+
AND COALESCE(o.compressed_into, 0) = 0
|
|
98
|
+
AND o.superseded_at IS NULL
|
|
99
|
+
AND (? IS NULL OR o.project = ?)
|
|
100
|
+
AND (? IS NULL OR o.type = ?)
|
|
101
|
+
AND (? IS NULL OR o.created_at_epoch >= ?)
|
|
102
|
+
AND (? IS NULL OR o.created_at_epoch <= ?)
|
|
103
|
+
AND (? IS NULL OR COALESCE(o.importance, 1) >= ?)
|
|
104
|
+
AND (? IS NULL OR o.branch = ?)
|
|
105
|
+
${lowSignalClause}
|
|
106
|
+
`).get(
|
|
107
|
+
ftsQuery,
|
|
108
|
+
args.project ?? null, args.project ?? null,
|
|
109
|
+
args.obs_type ?? null, args.obs_type ?? null,
|
|
110
|
+
epochFrom, epochFrom,
|
|
111
|
+
epochTo, epochTo,
|
|
112
|
+
args.importance ?? null, args.importance ?? null,
|
|
113
|
+
args.branch ?? null, args.branch ?? null,
|
|
114
|
+
);
|
|
115
|
+
return row?.c ?? 0;
|
|
116
|
+
} catch { return 0; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function countSessionFtsMatches(db, { ftsQuery, project = null, epochFrom = null, epochTo = null }) {
|
|
120
|
+
if (!ftsQuery) return 0;
|
|
121
|
+
try {
|
|
122
|
+
const wheres = ['session_summaries_fts MATCH ?'];
|
|
123
|
+
const params = [ftsQuery];
|
|
124
|
+
if (project) { wheres.push('s.project = ?'); params.push(project); }
|
|
125
|
+
if (epochFrom) { wheres.push('s.created_at_epoch >= ?'); params.push(epochFrom); }
|
|
126
|
+
if (epochTo) { wheres.push('s.created_at_epoch <= ?'); params.push(epochTo); }
|
|
127
|
+
const row = db.prepare(`
|
|
128
|
+
SELECT COUNT(*) as c
|
|
129
|
+
FROM session_summaries_fts
|
|
130
|
+
JOIN session_summaries s ON session_summaries_fts.rowid = s.id
|
|
131
|
+
WHERE ${wheres.join(' AND ')}
|
|
132
|
+
`).get(...params);
|
|
133
|
+
return row?.c ?? 0;
|
|
134
|
+
} catch { return 0; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function countPromptFtsMatches(db, { ftsQuery, project = null, epochFrom = null, epochTo = null }) {
|
|
138
|
+
if (!ftsQuery) return 0;
|
|
139
|
+
try {
|
|
140
|
+
const wheres = ['user_prompts_fts MATCH ?', "p.prompt_text NOT LIKE '<task-notification>%'"];
|
|
141
|
+
const params = [ftsQuery];
|
|
142
|
+
if (project) { wheres.push('s.project = ?'); params.push(project); }
|
|
143
|
+
if (epochFrom) { wheres.push('p.created_at_epoch >= ?'); params.push(epochFrom); }
|
|
144
|
+
if (epochTo) { wheres.push('p.created_at_epoch <= ?'); params.push(epochTo); }
|
|
145
|
+
const row = db.prepare(`
|
|
146
|
+
SELECT COUNT(*) as c
|
|
147
|
+
FROM user_prompts_fts
|
|
148
|
+
JOIN user_prompts p ON user_prompts_fts.rowid = p.id
|
|
149
|
+
JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
|
|
150
|
+
WHERE ${wheres.join(' AND ')}
|
|
151
|
+
`).get(...params);
|
|
152
|
+
return row?.c ?? 0;
|
|
153
|
+
} catch { return 0; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Sum true match counts across the sources that contribute to a cross-source (or
|
|
158
|
+
* source-restricted) search. `obsFtsQuery` lets callers pass the OR-relaxed query
|
|
159
|
+
* when obs AND→OR fallback fired (the displayed obs rows came from the OR query).
|
|
160
|
+
* @returns {number} population count, limit/offset-invariant
|
|
161
|
+
*/
|
|
162
|
+
export function countSearchTotal(db, {
|
|
163
|
+
effectiveSource = null, ftsQuery, obsFtsQuery = null,
|
|
164
|
+
args = {}, project = null, epochFrom = null, epochTo = null, includeNoise = false,
|
|
165
|
+
}) {
|
|
166
|
+
let total = 0;
|
|
167
|
+
if (!effectiveSource || effectiveSource === 'observations') {
|
|
168
|
+
total += countObsFtsMatches(db, { ftsQuery: obsFtsQuery || ftsQuery, args, epochFrom, epochTo, includeNoise });
|
|
169
|
+
}
|
|
170
|
+
if (!effectiveSource || effectiveSource === 'sessions') {
|
|
171
|
+
total += countSessionFtsMatches(db, { ftsQuery, project, epochFrom, epochTo });
|
|
172
|
+
}
|
|
173
|
+
if (!effectiveSource || effectiveSource === 'prompts') {
|
|
174
|
+
total += countPromptFtsMatches(db, { ftsQuery, project, epochFrom, epochTo });
|
|
175
|
+
}
|
|
176
|
+
return total;
|
|
177
|
+
}
|
|
178
|
+
|
|
75
179
|
export function ftsRowToResult(r, { scoreMultiplier, snippet } = {}) {
|
|
76
180
|
return {
|
|
77
181
|
source: 'obs', id: r.id, type: r.type, title: r.title, subtitle: r.subtitle,
|
package/server.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { extractCjkLikePatterns, cjkPrecisionOk } from './nlp.mjs';
|
|
|
10
10
|
import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
|
|
11
11
|
import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
|
|
12
12
|
import { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './server-internals.mjs';
|
|
13
|
-
import { searchObservationsHybrid, findFtsAnchor } from './search-engine.mjs';
|
|
13
|
+
import { searchObservationsHybrid, findFtsAnchor, countSearchTotal } from './search-engine.mjs';
|
|
14
14
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
15
15
|
import {
|
|
16
16
|
cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
|
|
@@ -314,7 +314,7 @@ function searchPrompts(ctx) {
|
|
|
314
314
|
return results;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
-
function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount,
|
|
317
|
+
function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, orFallbackFired = false) {
|
|
318
318
|
if (paginatedResults.length === 0) {
|
|
319
319
|
const hint = [];
|
|
320
320
|
if (args.query && !ftsQuery) {
|
|
@@ -332,7 +332,13 @@ function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, isCros
|
|
|
332
332
|
}
|
|
333
333
|
|
|
334
334
|
const lines = [];
|
|
335
|
-
|
|
335
|
+
// "N of M" whenever the population exceeds the page — NOT gated on isCrossSource.
|
|
336
|
+
// totalCount is the true limit/offset-invariant population (countSearchTotal), so
|
|
337
|
+
// single-source searches (obs_type / type / importance filters) must surface it too.
|
|
338
|
+
// The old isCrossSource gate predated countSearchTotal: back then single-source
|
|
339
|
+
// totalCount was just results.length, so suppressing "of M" hid nothing. Now it hid
|
|
340
|
+
// the real total, diverging from the CLI (mem-cli.mjs has no such gate). (#8217)
|
|
341
|
+
const countLabel = totalCount > paginatedResults.length
|
|
336
342
|
? `${paginatedResults.length} of ${totalCount}`
|
|
337
343
|
: `${paginatedResults.length}`;
|
|
338
344
|
const hasMixed = paginatedResults.some(r => r.source === 'session' || r.source === 'prompt');
|
|
@@ -385,9 +391,15 @@ server.registerTool(
|
|
|
385
391
|
const searchType = args.type;
|
|
386
392
|
const currentProject = inferProject();
|
|
387
393
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
394
|
+
// Over-fetch from offset 0 for EVERY mode, then apply `offset` exactly once at
|
|
395
|
+
// the merge slice below — identical to the CLI (mem-cli.mjs perSourceOffset=0).
|
|
396
|
+
// The old single-source branch (perSourceLimit=limit, perSourceOffset=offset)
|
|
397
|
+
// double-applied offset: it pushed offset into the per-source SQL AND re-sliced
|
|
398
|
+
// by offset at merge, so explicit `type=observations` paging overlapped (page 0
|
|
399
|
+
// == page 1) and gapped (oldest rows unreachable). It also fetched only `limit`
|
|
400
|
+
// rows — fewer than offset+limit — so there was nothing to page into. (#8217)
|
|
401
|
+
const perSourceLimit = Math.max(limit * 3, offset + limit + 10);
|
|
402
|
+
const perSourceOffset = 0;
|
|
391
403
|
|
|
392
404
|
// Parse date bounds to epoch (with validation)
|
|
393
405
|
// date_to with date-only format (YYYY-MM-DD) extends to end-of-day (23:59:59.999Z)
|
|
@@ -401,7 +413,7 @@ server.registerTool(
|
|
|
401
413
|
|
|
402
414
|
// Early return when query was provided but sanitized to nothing (all FTS5 keywords/special chars)
|
|
403
415
|
if (args.query && !ftsQuery && !epochFrom && !epochTo && !args.obs_type && !args.importance) {
|
|
404
|
-
return formatSearchOutput([], args, ftsQuery, 0
|
|
416
|
+
return formatSearchOutput([], args, ftsQuery, 0);
|
|
405
417
|
}
|
|
406
418
|
|
|
407
419
|
// When obs_type is specified, implicitly restrict to observations only
|
|
@@ -502,11 +514,25 @@ server.registerTool(
|
|
|
502
514
|
}
|
|
503
515
|
// else 'relevance' keeps BM25 score order (already sorted)
|
|
504
516
|
|
|
505
|
-
|
|
517
|
+
// `total` must be the TRUE population, invariant to limit/offset. In cross-source
|
|
518
|
+
// mode results is over-fetched (perSourceLimit scales with limit+offset), so
|
|
519
|
+
// results.length is NOT the population — count the real MATCH set instead. Clamp
|
|
520
|
+
// to >= results.length so vector/concept-augmented obs rows are never undercounted.
|
|
521
|
+
// (paired-path with mem-cli.mjs via shared countSearchTotal — #8217)
|
|
522
|
+
const trueTotal = countSearchTotal(db, {
|
|
523
|
+
effectiveSource: effectiveType || null,
|
|
524
|
+
ftsQuery,
|
|
525
|
+
obsFtsQuery: ctx.orFallbackFired === true ? (relaxFtsQueryToOr(ftsQuery) || ftsQuery) : ftsQuery,
|
|
526
|
+
args: { project: args.project || null, obs_type: args.obs_type || null, importance: args.importance || null, branch: args.branch || null },
|
|
527
|
+
project: args.project || null,
|
|
528
|
+
epochFrom, epochTo,
|
|
529
|
+
includeNoise: args.include_noise === true,
|
|
530
|
+
});
|
|
531
|
+
const totalBeforePagination = Math.max(trueTotal, results.length);
|
|
506
532
|
// Always apply pagination — single-source results can exceed SQL LIMIT due to expansion (concept co-occurrence, PRF, vector search)
|
|
507
533
|
const paginatedResults = (offset > 0 || results.length > limit) ? results.slice(offset, offset + limit) : results;
|
|
508
534
|
|
|
509
|
-
return formatSearchOutput(paginatedResults, args, ftsQuery, totalBeforePagination,
|
|
535
|
+
return formatSearchOutput(paginatedResults, args, ftsQuery, totalBeforePagination, ctx.orFallbackFired === true);
|
|
510
536
|
})
|
|
511
537
|
);
|
|
512
538
|
|