claude-mem-lite 2.90.1 → 2.92.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-llm.mjs +16 -40
- package/lib/citation-tracker.mjs +15 -0
- package/lib/maintain-core.mjs +77 -18
- package/lib/observation-write.mjs +67 -0
- package/lib/save-observation.mjs +19 -26
- package/mem-cli.mjs +20 -5
- package/package.json +3 -2
- package/registry.mjs +0 -132
- package/search-engine.mjs +113 -0
- package/server.mjs +35 -9
- package/source-files.mjs +5 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "2.
|
|
13
|
+
"version": "2.92.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.92.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/hook-llm.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
13
13
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
14
14
|
import { getVocabulary, computeVector } from './tfidf.mjs';
|
|
15
|
+
import { insertObservationRow, insertObservationFiles, insertObservationVector } from './lib/observation-write.mjs';
|
|
15
16
|
import { DEDUP_JACCARD_THRESHOLD, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
16
17
|
import {
|
|
17
18
|
RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
|
|
@@ -209,48 +210,23 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
209
210
|
search_aliases: obs.searchAliases || null,
|
|
210
211
|
});
|
|
211
212
|
|
|
212
|
-
// Atomic: observation INSERT + observation_files + vector in one transaction
|
|
213
|
+
// Atomic: observation INSERT + observation_files + vector in one transaction.
|
|
214
|
+
// Column list single-sourced in lib/observation-write (shared with manual mem_save).
|
|
213
215
|
const savedId = db.transaction(() => {
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
safe.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
JSON.stringify(obs.files || []),
|
|
225
|
-
obs.importance ?? 1,
|
|
226
|
-
minhashSig,
|
|
227
|
-
safe.lesson_learned,
|
|
228
|
-
safe.search_aliases,
|
|
229
|
-
getCurrentBranch(),
|
|
230
|
-
now.toISOString(), now.getTime()
|
|
231
|
-
);
|
|
232
|
-
const id = Number(result.lastInsertRowid);
|
|
233
|
-
|
|
234
|
-
// Populate observation_files junction table
|
|
235
|
-
if (id && obs.files && obs.files.length > 0) {
|
|
236
|
-
const insertFile = db.prepare('INSERT OR IGNORE INTO observation_files (obs_id, filename) VALUES (?, ?)');
|
|
237
|
-
for (const f of obs.files) {
|
|
238
|
-
if (typeof f === 'string' && f.length > 0) insertFile.run(id, f);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
216
|
+
const id = insertObservationRow(db, {
|
|
217
|
+
memory_session_id: sessionId, project, text: safe.text, type: obs.type,
|
|
218
|
+
title: safe.title, subtitle: safe.subtitle, narrative: safe.narrative,
|
|
219
|
+
concepts: safe.concepts, facts: safe.facts,
|
|
220
|
+
files_read: JSON.stringify(obs.filesRead || []),
|
|
221
|
+
files_modified: JSON.stringify(obs.files || []),
|
|
222
|
+
importance: obs.importance ?? 1, minhash_sig: minhashSig,
|
|
223
|
+
lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases,
|
|
224
|
+
branch: getCurrentBranch(), created_at: now.toISOString(), created_at_epoch: now.getTime(),
|
|
225
|
+
});
|
|
241
226
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (vocab) {
|
|
246
|
-
const vecText = [obs.title || '', obs.narrative || '', (Array.isArray(obs.concepts) ? obs.concepts.join(' ') : '')].filter(Boolean).join(' ');
|
|
247
|
-
const vec = computeVector(vecText, vocab);
|
|
248
|
-
if (vec) {
|
|
249
|
-
db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
|
|
250
|
-
.run(id, Buffer.from(vec.buffer), vocab.version, Date.now());
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
} catch (e) { debugCatch(e, 'saveObservation-vector'); }
|
|
227
|
+
insertObservationFiles(db, id, obs.files);
|
|
228
|
+
const vecText = [obs.title || '', obs.narrative || '', (Array.isArray(obs.concepts) ? obs.concepts.join(' ') : '')].filter(Boolean).join(' ');
|
|
229
|
+
insertObservationVector(db, id, vecText);
|
|
254
230
|
|
|
255
231
|
return id;
|
|
256
232
|
})();
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -492,6 +492,18 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
492
492
|
decay_seen_count = decay_seen_count + 1
|
|
493
493
|
WHERE id = ?
|
|
494
494
|
`);
|
|
495
|
+
// Suppressed (non-adopting) projects never demote, so uncited_streak would grow
|
|
496
|
+
// UNBOUNDED — and citeFactorClause penalizes -0.25*streak (floor 0.4), pinning every
|
|
497
|
+
// memory at the ranking floor with no recovery path. Cap at UNCITED_STREAK_THRESHOLD-1
|
|
498
|
+
// to hold the [0, threshold-1] steady state the scoring header asserts (in an adopting
|
|
499
|
+
// project the streak resets to 0 on demote, so the STORED value never exceeds 2).
|
|
500
|
+
const updateStreakCapped = db.prepare(`
|
|
501
|
+
UPDATE observations
|
|
502
|
+
SET uncited_streak = MIN(uncited_streak + 1, ?),
|
|
503
|
+
last_decided_session_id = ?,
|
|
504
|
+
decay_seen_count = decay_seen_count + 1
|
|
505
|
+
WHERE id = ?
|
|
506
|
+
`);
|
|
495
507
|
const updateDemote = db.prepare(`
|
|
496
508
|
UPDATE observations
|
|
497
509
|
SET importance = MAX(?, importance - 1),
|
|
@@ -520,6 +532,9 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
520
532
|
if (nextStreak >= UNCITED_STREAK_THRESHOLD && !suppressDemotion) {
|
|
521
533
|
updateDemote.run(IMPORTANCE_FLOOR, sessionId, Date.now(), id);
|
|
522
534
|
demoted++;
|
|
535
|
+
} else if (suppressDemotion) {
|
|
536
|
+
// Never-demoting project: cap the streak so cite_factor can't sink to floor.
|
|
537
|
+
updateStreakCapped.run(UNCITED_STREAK_THRESHOLD - 1, sessionId, id);
|
|
523
538
|
} else {
|
|
524
539
|
updateStreakOnly.run(sessionId, id);
|
|
525
540
|
}
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -29,15 +29,28 @@ export const MINHASH_PRE_THRESHOLD = MINHASH_PRE_THRESHOLD_SRC;
|
|
|
29
29
|
export const PINNED_INJ_THRESHOLD = 8;
|
|
30
30
|
|
|
31
31
|
/** Delete broken observations (no title AND no narrative). Returns rows deleted. */
|
|
32
|
+
// Before hard-deleting observations, un-hide any rows merged INTO them. A child has
|
|
33
|
+
// compressed_into = <keeperId>; deleting that keeper (compressed_into has no FK) would
|
|
34
|
+
// leave the child dangling behind a now-missing parent — hidden from every
|
|
35
|
+
// COALESCE(compressed_into,0)=0 view and unrecoverable. Recovery = resurface the child
|
|
36
|
+
// as live (NULL) rather than lose it silently. Shared by both hard-delete paths.
|
|
37
|
+
function recoverChildrenOf(db, ids) {
|
|
38
|
+
if (!ids.length) return 0;
|
|
39
|
+
const ph = ids.map(() => '?').join(',');
|
|
40
|
+
return db.prepare(`UPDATE observations SET compressed_into = NULL WHERE compressed_into IN (${ph})`).run(...ids).changes;
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP }) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
const doomed = db.prepare(`
|
|
45
|
+
SELECT id FROM observations
|
|
46
|
+
WHERE COALESCE(compressed_into, 0) = 0
|
|
47
|
+
AND (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
|
|
48
|
+
${projectFilter} LIMIT ${opCap}
|
|
49
|
+
`).all(...baseParams).map(r => r.id);
|
|
50
|
+
if (!doomed.length) return 0;
|
|
51
|
+
recoverChildrenOf(db, doomed); // empty-content row could still be a cluster keeper
|
|
52
|
+
const ph = doomed.map(() => '?').join(',');
|
|
53
|
+
return db.prepare(`DELETE FROM observations WHERE id IN (${ph})`).run(...doomed).changes;
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
/**
|
|
@@ -115,12 +128,54 @@ export function demotePinned(db, { projectFilter, baseParams, opCap = OP_CAP })
|
|
|
115
128
|
* number of rows merged. Callers parse their own input (CLI string / MCP array).
|
|
116
129
|
*/
|
|
117
130
|
export function mergeDuplicates(db, groups) {
|
|
118
|
-
|
|
119
|
-
|
|
131
|
+
// Resolve the WHOLE batch before writing so transitive merges can't orphan rows.
|
|
132
|
+
// A row is hidden from every view by `compressed_into != 0`, so pointing it at a
|
|
133
|
+
// keeper that is itself hidden buries it behind a hidden parent. Naively applying
|
|
134
|
+
// groups one update at a time loses data in three ways the old 1-line self-merge
|
|
135
|
+
// guard missed:
|
|
136
|
+
// - chain [[A,B],[B,C]] -> C.compressed_into=B, but B is now hidden into A;
|
|
137
|
+
// if B is later purgeStale-deleted, C's keeper vanishes and C is unrecoverable.
|
|
138
|
+
// - mutual [[A,B],[B,A]] -> BOTH hidden, the cluster loses its live representative.
|
|
139
|
+
// - already-compressed keeper [E,F] when E was merged in a prior call -> F buried
|
|
140
|
+
// behind hidden E.
|
|
141
|
+
// mem_maintain's "dedup" auto-suggests pairs that can form these chains (server.mjs),
|
|
142
|
+
// so this is reachable in normal use, not just typos. Fix: build the redirect map,
|
|
143
|
+
// collapse each removeId to a single live keeper (cycles -> smallest id as canonical),
|
|
144
|
+
// and only write removeId -> keeper when that keeper is currently live. Shared core,
|
|
145
|
+
// so CLI + MCP both inherit it.
|
|
146
|
+
const redirect = new Map(); // removeId -> keepId (first writer wins, deterministic)
|
|
120
147
|
for (const group of groups) {
|
|
121
148
|
if (!group || group.length < 2) continue;
|
|
122
149
|
const [keepId, ...removeIds] = group;
|
|
123
|
-
for (const removeId of removeIds)
|
|
150
|
+
for (const removeId of removeIds) {
|
|
151
|
+
if (removeId === keepId) continue; // self-merge typo: no-op
|
|
152
|
+
if (!redirect.has(removeId)) redirect.set(removeId, keepId);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (redirect.size === 0) return 0;
|
|
156
|
+
|
|
157
|
+
// Follow the redirect chain to the ultimate keeper. A cycle (mutual merge) collapses
|
|
158
|
+
// to the smallest id among the cycle members so every member agrees on one survivor.
|
|
159
|
+
const resolveKeeper = (start) => {
|
|
160
|
+
const seen = [];
|
|
161
|
+
let cur = start;
|
|
162
|
+
while (redirect.has(cur)) {
|
|
163
|
+
const at = seen.indexOf(cur);
|
|
164
|
+
if (at !== -1) return Math.min(...seen.slice(at)); // cycle -> canonical = min member
|
|
165
|
+
seen.push(cur);
|
|
166
|
+
cur = redirect.get(cur);
|
|
167
|
+
}
|
|
168
|
+
return cur; // an id with no outgoing redirect is a keeper
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const isLive = db.prepare('SELECT 1 FROM observations WHERE id = ? AND COALESCE(compressed_into, 0) = 0');
|
|
172
|
+
const mergeStmt = db.prepare('UPDATE observations SET compressed_into = ? WHERE id = ? AND COALESCE(compressed_into, 0) = 0');
|
|
173
|
+
let merged = 0;
|
|
174
|
+
for (const removeId of redirect.keys()) {
|
|
175
|
+
const keeper = resolveKeeper(removeId);
|
|
176
|
+
if (keeper === removeId) continue; // cycle canonical: this row survives
|
|
177
|
+
if (!isLive.get(keeper)) continue; // keeper not live -> skip, never orphan
|
|
178
|
+
merged += mergeStmt.run(keeper, removeId).changes;
|
|
124
179
|
}
|
|
125
180
|
return merged;
|
|
126
181
|
}
|
|
@@ -136,13 +191,17 @@ export function purgeStalePreview(db, { projectFilter, baseParams }, retainCutof
|
|
|
136
191
|
|
|
137
192
|
/** Delete pending-purge observations older than the retain cutoff. Returns rows deleted. */
|
|
138
193
|
export function purgeStale(db, { projectFilter, baseParams, opCap = OP_CAP }, retainCutoff) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
194
|
+
const doomed = db.prepare(`
|
|
195
|
+
SELECT id FROM observations
|
|
196
|
+
WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ?
|
|
197
|
+
${projectFilter} LIMIT ${opCap}
|
|
198
|
+
`).all(retainCutoff, ...baseParams).map(r => r.id);
|
|
199
|
+
if (!doomed.length) return 0;
|
|
200
|
+
// A keeper that absorbed dups can later be marked idle (compressed_into=PENDING_PURGE)
|
|
201
|
+
// and reach here; deleting it would orphan its children. Recover them first.
|
|
202
|
+
recoverChildrenOf(db, doomed);
|
|
203
|
+
const ph = doomed.map(() => '?').join(',');
|
|
204
|
+
return db.prepare(`DELETE FROM observations WHERE id IN (${ph})`).run(...doomed).changes;
|
|
146
205
|
}
|
|
147
206
|
|
|
148
207
|
/**
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Single source of truth for the observations-table write surface. Two ingest
|
|
2
|
+
// paths previously hand-wrote divergent INSERTs — lib/save-observation.mjs (manual
|
|
3
|
+
// mem_save, 16 cols, omitted subtitle/search_aliases) and hook-llm.mjs (LLM
|
|
4
|
+
// auto-ingest, 18 cols) — the exact column-drift hazard the compress/maintain
|
|
5
|
+
// single-source cores were extracted to eliminate (see #8614). Add a column HERE
|
|
6
|
+
// and both ingest paths pick it up; neither can silently fall out of sync again.
|
|
7
|
+
//
|
|
8
|
+
// Statement-only: callers own the transaction boundary (both wrap the row + files
|
|
9
|
+
// + vector writes in one db.transaction so a failure can't leave a partial row).
|
|
10
|
+
|
|
11
|
+
import { getVocabulary, computeVector } from '../tfidf.mjs';
|
|
12
|
+
import { debugCatch } from '../utils.mjs';
|
|
13
|
+
|
|
14
|
+
// Canonical column order — must mirror the observations schema (schema.mjs).
|
|
15
|
+
const OBS_COLUMNS = [
|
|
16
|
+
'memory_session_id', 'project', 'text', 'type', 'title', 'subtitle',
|
|
17
|
+
'narrative', 'concepts', 'facts', 'files_read', 'files_modified',
|
|
18
|
+
'importance', 'minhash_sig', 'lesson_learned', 'search_aliases', 'branch',
|
|
19
|
+
'created_at', 'created_at_epoch',
|
|
20
|
+
];
|
|
21
|
+
// Defaults for columns a caller omits. NULL-default columns (subtitle,
|
|
22
|
+
// search_aliases) match the schema DEFAULT, so omitting == the old short INSERT.
|
|
23
|
+
// concepts/facts/files_read default to the empty literals the manual path used.
|
|
24
|
+
const OBS_DEFAULTS = {
|
|
25
|
+
subtitle: null, narrative: '', concepts: '', facts: '',
|
|
26
|
+
files_read: '[]', files_modified: '[]', search_aliases: null, importance: 1,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Insert one observations row from a {column: value} map and return its id.
|
|
31
|
+
* Omitted columns fall back to OBS_DEFAULTS (or NULL). The column list lives only
|
|
32
|
+
* here, so a schema column can never drift between the two ingest paths again.
|
|
33
|
+
*/
|
|
34
|
+
export function insertObservationRow(db, fields) {
|
|
35
|
+
const values = OBS_COLUMNS.map(c =>
|
|
36
|
+
Object.prototype.hasOwnProperty.call(fields, c) ? fields[c]
|
|
37
|
+
: (c in OBS_DEFAULTS ? OBS_DEFAULTS[c] : null)
|
|
38
|
+
);
|
|
39
|
+
const placeholders = OBS_COLUMNS.map(() => '?').join(', ');
|
|
40
|
+
const result = db
|
|
41
|
+
.prepare(`INSERT INTO observations (${OBS_COLUMNS.join(', ')}) VALUES (${placeholders})`)
|
|
42
|
+
.run(...values);
|
|
43
|
+
return Number(result.lastInsertRowid);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Populate the observation_files junction (skips non-string / empty entries). */
|
|
47
|
+
export function insertObservationFiles(db, obsId, files) {
|
|
48
|
+
if (!obsId || !Array.isArray(files) || files.length === 0) return;
|
|
49
|
+
const stmt = db.prepare('INSERT OR IGNORE INTO observation_files (obs_id, filename) VALUES (?, ?)');
|
|
50
|
+
for (const f of files) if (typeof f === 'string' && f.length > 0) stmt.run(obsId, f);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Best-effort TF-IDF vector write. Non-critical: vocab may be uninitialized on a
|
|
55
|
+
* fresh DB, so failures are swallowed (caller's transaction must NOT roll back the
|
|
56
|
+
* observation over a missing vector).
|
|
57
|
+
*/
|
|
58
|
+
export function insertObservationVector(db, obsId, vecText) {
|
|
59
|
+
try {
|
|
60
|
+
const vocab = getVocabulary(db);
|
|
61
|
+
if (!vocab) return;
|
|
62
|
+
const vec = computeVector(vecText, vocab);
|
|
63
|
+
if (!vec) return;
|
|
64
|
+
db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
|
|
65
|
+
.run(obsId, Buffer.from(vec.buffer), vocab.version, Date.now());
|
|
66
|
+
} catch (e) { debugCatch(e, 'insertObservationVector'); }
|
|
67
|
+
}
|
package/lib/save-observation.mjs
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
// - argument parsing (CLI flags vs MCP Zod schema)
|
|
12
12
|
// - result rendering (CLI stdout vs MCP content array)
|
|
13
13
|
|
|
14
|
-
import { jaccardSimilarity, scrubSecrets, computeMinHash, cjkBigrams, getCurrentBranch
|
|
15
|
-
import { getVocabulary, computeVector } from '../tfidf.mjs';
|
|
14
|
+
import { jaccardSimilarity, scrubSecrets, computeMinHash, cjkBigrams, getCurrentBranch } from '../utils.mjs';
|
|
16
15
|
import { DEDUP_JACCARD_THRESHOLD } from './dedup-constants.mjs';
|
|
16
|
+
import { insertObservationRow, insertObservationFiles, insertObservationVector } from './observation-write.mjs';
|
|
17
17
|
|
|
18
18
|
const DEDUP_WINDOW_MS = 5 * 60 * 1000;
|
|
19
19
|
const DEDUP_RECENT_LIMIT = 50;
|
|
@@ -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)
|
|
@@ -92,31 +99,17 @@ export function saveObservation(db, params) {
|
|
|
92
99
|
// (TF-IDF). Vector write is best-effort — vocab may be uninitialized on a
|
|
93
100
|
// fresh DB; failure must not roll back the observation.
|
|
94
101
|
const saveTx = db.transaction(() => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
now.toISOString(), now.getTime()
|
|
102
|
-
);
|
|
103
|
-
const savedId = Number(result.lastInsertRowid);
|
|
104
|
-
|
|
105
|
-
if (savedId && files.length > 0) {
|
|
106
|
-
const insertFile = db.prepare('INSERT OR IGNORE INTO observation_files (obs_id, filename) VALUES (?, ?)');
|
|
107
|
-
for (const f of files) insertFile.run(savedId, f);
|
|
108
|
-
}
|
|
102
|
+
// Manual-save shape: narrative=content, concepts/facts/files_read empty, no
|
|
103
|
+
// subtitle/search_aliases (defaults). Column list single-sourced in lib/observation-write.
|
|
104
|
+
const savedId = insertObservationRow(db, {
|
|
105
|
+
memory_session_id: sessionId, project, text: textField, type, title: safeTitle,
|
|
106
|
+
narrative: safeContent, files_modified: JSON.stringify(files), importance,
|
|
107
|
+
minhash_sig: minhashSig, lesson_learned: safeLesson, branch: getCurrentBranch(),
|
|
108
|
+
created_at: now.toISOString(), created_at_epoch: now.getTime(),
|
|
109
|
+
});
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (vocab) {
|
|
113
|
-
const vec = computeVector(safeTitle + ' ' + safeContent, vocab);
|
|
114
|
-
if (vec) {
|
|
115
|
-
db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
|
|
116
|
-
.run(savedId, Buffer.from(vec.buffer), vocab.version, Date.now());
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
} catch (e) { debugCatch(e, 'save-observation-vector'); }
|
|
111
|
+
insertObservationFiles(db, savedId, files);
|
|
112
|
+
insertObservationVector(db, savedId, safeTitle + ' ' + safeContent);
|
|
120
113
|
|
|
121
114
|
return savedId;
|
|
122
115
|
});
|
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.92.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",
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"lib/binding-probe.mjs",
|
|
68
68
|
"lib/mem-override.mjs",
|
|
69
69
|
"lib/save-observation.mjs",
|
|
70
|
+
"lib/observation-write.mjs",
|
|
70
71
|
"lib/compress-core.mjs",
|
|
71
72
|
"lib/maintain-core.mjs",
|
|
72
73
|
"lib/dedup-constants.mjs",
|
|
@@ -136,7 +137,7 @@
|
|
|
136
137
|
"zod": "^4.3.6"
|
|
137
138
|
},
|
|
138
139
|
"overrides": {
|
|
139
|
-
"hono": ">=4.12.
|
|
140
|
+
"hono": ">=4.12.21",
|
|
140
141
|
"fast-uri": ">=3.1.2",
|
|
141
142
|
"ip-address": ">=10.1.1"
|
|
142
143
|
},
|
package/registry.mjs
CHANGED
|
@@ -339,135 +339,3 @@ export function upsertResource(db, r) {
|
|
|
339
339
|
return row?.id || 0;
|
|
340
340
|
})();
|
|
341
341
|
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Get all active resources.
|
|
345
|
-
* @param {Database} db Registry database
|
|
346
|
-
* @returns {object[]} Array of resource objects
|
|
347
|
-
*/
|
|
348
|
-
export function getActiveResources(db) {
|
|
349
|
-
return db.prepare('SELECT * FROM resources WHERE status = ? ORDER BY name').all('active');
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* Get a resource by type and name.
|
|
354
|
-
* @param {Database} db Registry database
|
|
355
|
-
* @param {string} type 'skill' or 'agent'
|
|
356
|
-
* @param {string} name Resource name
|
|
357
|
-
* @returns {object|undefined} Resource or undefined
|
|
358
|
-
*/
|
|
359
|
-
export function getResourceByName(db, type, name) {
|
|
360
|
-
return db.prepare('SELECT * FROM resources WHERE type = ? AND name = ?').get(type, name);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Get a resource by ID.
|
|
365
|
-
* @param {Database} db Registry database
|
|
366
|
-
* @param {number} id Resource ID
|
|
367
|
-
* @returns {object|undefined} Resource or undefined
|
|
368
|
-
*/
|
|
369
|
-
export function getResourceById(db, id) {
|
|
370
|
-
return db.prepare('SELECT * FROM resources WHERE id = ?').get(id);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* Atomically increment a stats field on a resource.
|
|
375
|
-
* @param {Database} db Registry database
|
|
376
|
-
* @param {number} id Resource ID
|
|
377
|
-
* @param {'recommend_count'|'adopt_count'|'success_count'} field Stats field
|
|
378
|
-
*/
|
|
379
|
-
export function updateResourceStats(db, id, field) {
|
|
380
|
-
const allowed = new Set(['recommend_count', 'adopt_count', 'success_count']);
|
|
381
|
-
if (!allowed.has(field)) throw new Error(`Invalid stats field: ${field}`);
|
|
382
|
-
// String interpolation required: SQLite cannot parameterize column names.
|
|
383
|
-
// Safety: field is validated against allowlist above.
|
|
384
|
-
db.prepare(`UPDATE resources SET ${field} = ${field} + 1, updated_at = datetime('now') WHERE id = ?`).run(id);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Atomically increment weighted_adopt_sum by a continuous score value.
|
|
389
|
-
* @param {Database} db Registry database
|
|
390
|
-
* @param {number} id Resource ID
|
|
391
|
-
* @param {number} score Adoption confidence score (0.0-1.0)
|
|
392
|
-
*/
|
|
393
|
-
export function incrementWeightedAdopt(db, id, score) {
|
|
394
|
-
db.prepare(`UPDATE resources SET weighted_adopt_sum = COALESCE(weighted_adopt_sum, 0) + ?, updated_at = datetime('now') WHERE id = ?`).run(score, id);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Record a dispatch invocation.
|
|
399
|
-
* @param {Database} db Registry database
|
|
400
|
-
* @param {object} record Invocation record
|
|
401
|
-
* @returns {number} Invocation ID
|
|
402
|
-
*/
|
|
403
|
-
export function recordInvocation(db, record) {
|
|
404
|
-
const result = db.prepare(`
|
|
405
|
-
INSERT INTO invocations (resource_id, session_id, trigger, tier, recommended, adopted, outcome, score)
|
|
406
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
407
|
-
`).run(
|
|
408
|
-
record.resource_id, record.session_id || null,
|
|
409
|
-
record.trigger || null, record.tier || null,
|
|
410
|
-
record.recommended ?? 1, record.adopted ?? 0,
|
|
411
|
-
record.outcome || null, record.score ?? null
|
|
412
|
-
);
|
|
413
|
-
return Number(result.lastInsertRowid);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Get success rates for resources over a time period.
|
|
418
|
-
* @param {Database} db Registry database
|
|
419
|
-
* @param {number} [days=30] Lookback period
|
|
420
|
-
* @returns {object[]} Array of {resource_id, total, adopted, avg_score}
|
|
421
|
-
*/
|
|
422
|
-
export function getResourceSuccessRates(db, days = 30) {
|
|
423
|
-
const since = new Date(Date.now() - days * 86400000).toISOString();
|
|
424
|
-
return db.prepare(`
|
|
425
|
-
SELECT resource_id,
|
|
426
|
-
COUNT(*) as total,
|
|
427
|
-
SUM(adopted) as adopted,
|
|
428
|
-
AVG(CASE WHEN score IS NOT NULL THEN score END) as avg_score
|
|
429
|
-
FROM invocations
|
|
430
|
-
WHERE created_at > ?
|
|
431
|
-
GROUP BY resource_id
|
|
432
|
-
`).all(since);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* Get invocations for a specific session (for feedback collection).
|
|
437
|
-
* @param {Database} db Registry database
|
|
438
|
-
* @param {string} sessionId Session identifier
|
|
439
|
-
* @returns {object[]} Array of invocation records
|
|
440
|
-
*/
|
|
441
|
-
export function getSessionInvocations(db, sessionId) {
|
|
442
|
-
return db.prepare(`
|
|
443
|
-
SELECT i.*, r.name as resource_name, r.type as resource_type,
|
|
444
|
-
r.invocation_name as invocation_name
|
|
445
|
-
FROM invocations i
|
|
446
|
-
JOIN resources r ON r.id = i.resource_id
|
|
447
|
-
WHERE i.session_id = ?
|
|
448
|
-
ORDER BY i.created_at
|
|
449
|
-
`).all(sessionId);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* Update invocation outcome (for feedback).
|
|
454
|
-
* @param {Database} db Registry database
|
|
455
|
-
* @param {number} id Invocation ID
|
|
456
|
-
* @param {object} update Fields to update
|
|
457
|
-
*/
|
|
458
|
-
export function updateInvocation(db, id, update) {
|
|
459
|
-
const allowed = new Set(['adopted', 'outcome', 'score', 'rejection_reason']);
|
|
460
|
-
const sets = [];
|
|
461
|
-
const vals = [];
|
|
462
|
-
for (const [key, val] of Object.entries(update)) {
|
|
463
|
-
if (val === undefined) continue;
|
|
464
|
-
if (!allowed.has(key)) throw new Error(`Invalid invocation field: ${key}`);
|
|
465
|
-
sets.push(`${key} = ?`);
|
|
466
|
-
vals.push(val);
|
|
467
|
-
}
|
|
468
|
-
if (sets.length === 0) return;
|
|
469
|
-
vals.push(id);
|
|
470
|
-
// String interpolation required: SQLite cannot parameterize column names.
|
|
471
|
-
// Safety: column names are validated against allowlist above.
|
|
472
|
-
db.prepare(`UPDATE invocations SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
|
|
473
|
-
}
|
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,
|
|
@@ -262,6 +366,15 @@ export function searchObservationsHybrid(db, ctx) {
|
|
|
262
366
|
if (vecResults.length === 0) return results;
|
|
263
367
|
|
|
264
368
|
if (results.length > 0) {
|
|
369
|
+
// RRF fuses by RANK (array index), so the BM25 side must already be in
|
|
370
|
+
// composite-score order. `results` here is [full-FTS sorted, …concept ×0.7,
|
|
371
|
+
// …PRF ×0.6] with augmentation rows APPENDED, so its index order is only
|
|
372
|
+
// BM25-rank for the first block — a downweighted PRF row at the tail would be
|
|
373
|
+
// handed to RRF as a worse rank than its score warrants, and a strong one as
|
|
374
|
+
// better. Sort by the calibrated composite score (negative = more relevant)
|
|
375
|
+
// first so index == composite rank and the type-quality/decay/cite multipliers
|
|
376
|
+
// actually shape the fused ranking instead of being discarded by insertion order.
|
|
377
|
+
results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
|
|
265
378
|
const rrfRanking = rrfMerge(results, vecResults, ctx.rrfK); // undefined → RRF_K
|
|
266
379
|
const resultMap = new Map(results.map(r => [r.id, r]));
|
|
267
380
|
for (const vr of vecResults) {
|
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
|
|
package/source-files.mjs
CHANGED
|
@@ -77,6 +77,11 @@ 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
|
+
// Single-source observations-table write primitives (insertObservationRow/Files/
|
|
81
|
+
// Vector). Statically imported by lib/save-observation.mjs and hook-llm.mjs (both
|
|
82
|
+
// entry-point-reachable); missing it from the manifest would break ALL saves on
|
|
83
|
+
// auto-update. Same single-source-of-truth pattern (see #8217).
|
|
84
|
+
'lib/observation-write.mjs',
|
|
80
85
|
// Shared "compress old low-value observations into weekly summaries" core.
|
|
81
86
|
// Statically imported by mem-cli.mjs (cmdCompress), server.mjs (mem_compress),
|
|
82
87
|
// and hook.mjs (handleAutoCompress) — same single-source-of-truth pattern as
|