claude-mem-lite 2.91.0 → 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 +75 -22
- package/lib/observation-write.mjs +67 -0
- package/lib/save-observation.mjs +12 -26
- package/package.json +3 -2
- package/registry.mjs +0 -132
- package/search-engine.mjs +9 -0
- 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,19 +128,55 @@ 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
150
|
for (const removeId of removeIds) {
|
|
124
|
-
//
|
|
125
|
-
|
|
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;
|
|
151
|
+
if (removeId === keepId) continue; // self-merge typo: no-op
|
|
152
|
+
if (!redirect.has(removeId)) redirect.set(removeId, keepId);
|
|
129
153
|
}
|
|
130
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;
|
|
179
|
+
}
|
|
131
180
|
return merged;
|
|
132
181
|
}
|
|
133
182
|
|
|
@@ -142,13 +191,17 @@ export function purgeStalePreview(db, { projectFilter, baseParams }, retainCutof
|
|
|
142
191
|
|
|
143
192
|
/** Delete pending-purge observations older than the retain cutoff. Returns rows deleted. */
|
|
144
193
|
export function purgeStale(db, { projectFilter, baseParams, opCap = OP_CAP }, retainCutoff) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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;
|
|
152
205
|
}
|
|
153
206
|
|
|
154
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;
|
|
@@ -99,31 +99,17 @@ export function saveObservation(db, params) {
|
|
|
99
99
|
// (TF-IDF). Vector write is best-effort — vocab may be uninitialized on a
|
|
100
100
|
// fresh DB; failure must not roll back the observation.
|
|
101
101
|
const saveTx = db.transaction(() => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
now.toISOString(), now.getTime()
|
|
109
|
-
);
|
|
110
|
-
const savedId = Number(result.lastInsertRowid);
|
|
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
|
+
});
|
|
111
110
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
for (const f of files) insertFile.run(savedId, f);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
try {
|
|
118
|
-
const vocab = getVocabulary(db);
|
|
119
|
-
if (vocab) {
|
|
120
|
-
const vec = computeVector(safeTitle + ' ' + safeContent, vocab);
|
|
121
|
-
if (vec) {
|
|
122
|
-
db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
|
|
123
|
-
.run(savedId, Buffer.from(vec.buffer), vocab.version, Date.now());
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
} catch (e) { debugCatch(e, 'save-observation-vector'); }
|
|
111
|
+
insertObservationFiles(db, savedId, files);
|
|
112
|
+
insertObservationVector(db, savedId, safeTitle + ' ' + safeContent);
|
|
127
113
|
|
|
128
114
|
return savedId;
|
|
129
115
|
});
|
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
|
@@ -366,6 +366,15 @@ export function searchObservationsHybrid(db, ctx) {
|
|
|
366
366
|
if (vecResults.length === 0) return results;
|
|
367
367
|
|
|
368
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));
|
|
369
378
|
const rrfRanking = rrfMerge(results, vecResults, ctx.rrfK); // undefined → RRF_K
|
|
370
379
|
const resultMap = new Map(results.map(r => [r.id, r]));
|
|
371
380
|
for (const vr of vecResults) {
|
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
|