claude-mem-lite 2.91.0 → 2.93.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/adopt-cli.mjs +19 -9
- package/bash-utils.mjs +45 -5
- package/cli/activity.mjs +12 -4
- package/cli/common.mjs +23 -0
- package/format-utils.mjs +12 -1
- package/hook-handoff.mjs +20 -2
- package/hook-llm.mjs +22 -41
- package/hook-optimize.mjs +23 -8
- package/hook-update.mjs +16 -5
- package/hook.mjs +8 -1
- package/lib/citation-tracker.mjs +15 -0
- package/lib/maintain-core.mjs +82 -22
- package/lib/observation-write.mjs +67 -0
- package/lib/save-observation.mjs +12 -26
- package/mem-cli.mjs +36 -26
- package/memdir.mjs +36 -11
- package/nlp.mjs +20 -3
- package/package.json +3 -2
- package/project-utils.mjs +6 -0
- package/registry-importer.mjs +8 -3
- package/registry-retriever.mjs +10 -6
- package/registry.mjs +0 -132
- package/schema.mjs +15 -8
- package/search-engine.mjs +14 -1
- package/secret-scrub.mjs +12 -3
- package/server.mjs +9 -1
- package/source-files.mjs +5 -0
- package/tier.mjs +5 -2
- package/utils.mjs +40 -3
package/lib/maintain-core.mjs
CHANGED
|
@@ -29,15 +29,35 @@ 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 every hard-delete path:
|
|
37
|
+
// maintain (cleanupBroken/purgeStale) AND the interactive `delete` / MCP mem_delete.
|
|
38
|
+
export function recoverChildrenOf(db, ids) {
|
|
39
|
+
if (!ids.length) return 0;
|
|
40
|
+
const ph = ids.map(() => '?').join(',');
|
|
41
|
+
// `AND id NOT IN (...)`: never "recover" a row that is itself being deleted in the same
|
|
42
|
+
// call (e.g. `delete 1,2` where #2 was merged into #1). Without it, #2 is un-hidden and
|
|
43
|
+
// then immediately deleted, inflating the reported recovery count with a row that did not
|
|
44
|
+
// survive. Recovery should count only children that actually stay live.
|
|
45
|
+
return db.prepare(
|
|
46
|
+
`UPDATE observations SET compressed_into = NULL WHERE compressed_into IN (${ph}) AND id NOT IN (${ph})`
|
|
47
|
+
).run(...ids, ...ids).changes;
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP }) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
51
|
+
const doomed = db.prepare(`
|
|
52
|
+
SELECT id FROM observations
|
|
53
|
+
WHERE COALESCE(compressed_into, 0) = 0
|
|
54
|
+
AND (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
|
|
55
|
+
${projectFilter} LIMIT ${opCap}
|
|
56
|
+
`).all(...baseParams).map(r => r.id);
|
|
57
|
+
if (!doomed.length) return 0;
|
|
58
|
+
recoverChildrenOf(db, doomed); // empty-content row could still be a cluster keeper
|
|
59
|
+
const ph = doomed.map(() => '?').join(',');
|
|
60
|
+
return db.prepare(`DELETE FROM observations WHERE id IN (${ph})`).run(...doomed).changes;
|
|
41
61
|
}
|
|
42
62
|
|
|
43
63
|
/**
|
|
@@ -115,19 +135,55 @@ export function demotePinned(db, { projectFilter, baseParams, opCap = OP_CAP })
|
|
|
115
135
|
* number of rows merged. Callers parse their own input (CLI string / MCP array).
|
|
116
136
|
*/
|
|
117
137
|
export function mergeDuplicates(db, groups) {
|
|
118
|
-
|
|
119
|
-
|
|
138
|
+
// Resolve the WHOLE batch before writing so transitive merges can't orphan rows.
|
|
139
|
+
// A row is hidden from every view by `compressed_into != 0`, so pointing it at a
|
|
140
|
+
// keeper that is itself hidden buries it behind a hidden parent. Naively applying
|
|
141
|
+
// groups one update at a time loses data in three ways the old 1-line self-merge
|
|
142
|
+
// guard missed:
|
|
143
|
+
// - chain [[A,B],[B,C]] -> C.compressed_into=B, but B is now hidden into A;
|
|
144
|
+
// if B is later purgeStale-deleted, C's keeper vanishes and C is unrecoverable.
|
|
145
|
+
// - mutual [[A,B],[B,A]] -> BOTH hidden, the cluster loses its live representative.
|
|
146
|
+
// - already-compressed keeper [E,F] when E was merged in a prior call -> F buried
|
|
147
|
+
// behind hidden E.
|
|
148
|
+
// mem_maintain's "dedup" auto-suggests pairs that can form these chains (server.mjs),
|
|
149
|
+
// so this is reachable in normal use, not just typos. Fix: build the redirect map,
|
|
150
|
+
// collapse each removeId to a single live keeper (cycles -> smallest id as canonical),
|
|
151
|
+
// and only write removeId -> keeper when that keeper is currently live. Shared core,
|
|
152
|
+
// so CLI + MCP both inherit it.
|
|
153
|
+
const redirect = new Map(); // removeId -> keepId (first writer wins, deterministic)
|
|
120
154
|
for (const group of groups) {
|
|
121
155
|
if (!group || group.length < 2) continue;
|
|
122
156
|
const [keepId, ...removeIds] = group;
|
|
123
157
|
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;
|
|
158
|
+
if (removeId === keepId) continue; // self-merge typo: no-op
|
|
159
|
+
if (!redirect.has(removeId)) redirect.set(removeId, keepId);
|
|
129
160
|
}
|
|
130
161
|
}
|
|
162
|
+
if (redirect.size === 0) return 0;
|
|
163
|
+
|
|
164
|
+
// Follow the redirect chain to the ultimate keeper. A cycle (mutual merge) collapses
|
|
165
|
+
// to the smallest id among the cycle members so every member agrees on one survivor.
|
|
166
|
+
const resolveKeeper = (start) => {
|
|
167
|
+
const seen = [];
|
|
168
|
+
let cur = start;
|
|
169
|
+
while (redirect.has(cur)) {
|
|
170
|
+
const at = seen.indexOf(cur);
|
|
171
|
+
if (at !== -1) return Math.min(...seen.slice(at)); // cycle -> canonical = min member
|
|
172
|
+
seen.push(cur);
|
|
173
|
+
cur = redirect.get(cur);
|
|
174
|
+
}
|
|
175
|
+
return cur; // an id with no outgoing redirect is a keeper
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const isLive = db.prepare('SELECT 1 FROM observations WHERE id = ? AND COALESCE(compressed_into, 0) = 0');
|
|
179
|
+
const mergeStmt = db.prepare('UPDATE observations SET compressed_into = ? WHERE id = ? AND COALESCE(compressed_into, 0) = 0');
|
|
180
|
+
let merged = 0;
|
|
181
|
+
for (const removeId of redirect.keys()) {
|
|
182
|
+
const keeper = resolveKeeper(removeId);
|
|
183
|
+
if (keeper === removeId) continue; // cycle canonical: this row survives
|
|
184
|
+
if (!isLive.get(keeper)) continue; // keeper not live -> skip, never orphan
|
|
185
|
+
merged += mergeStmt.run(keeper, removeId).changes;
|
|
186
|
+
}
|
|
131
187
|
return merged;
|
|
132
188
|
}
|
|
133
189
|
|
|
@@ -142,13 +198,17 @@ export function purgeStalePreview(db, { projectFilter, baseParams }, retainCutof
|
|
|
142
198
|
|
|
143
199
|
/** Delete pending-purge observations older than the retain cutoff. Returns rows deleted. */
|
|
144
200
|
export function purgeStale(db, { projectFilter, baseParams, opCap = OP_CAP }, retainCutoff) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
201
|
+
const doomed = db.prepare(`
|
|
202
|
+
SELECT id FROM observations
|
|
203
|
+
WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ?
|
|
204
|
+
${projectFilter} LIMIT ${opCap}
|
|
205
|
+
`).all(retainCutoff, ...baseParams).map(r => r.id);
|
|
206
|
+
if (!doomed.length) return 0;
|
|
207
|
+
// A keeper that absorbed dups can later be marked idle (compressed_into=PENDING_PURGE)
|
|
208
|
+
// and reach here; deleting it would orphan its children. Recover them first.
|
|
209
|
+
recoverChildrenOf(db, doomed);
|
|
210
|
+
const ph = doomed.map(() => '?').join(',');
|
|
211
|
+
return db.prepare(`DELETE FROM observations WHERE id IN (${ph})`).run(...doomed).changes;
|
|
152
212
|
}
|
|
153
213
|
|
|
154
214
|
/**
|
|
@@ -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/mem-cli.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from '
|
|
|
18
18
|
import {
|
|
19
19
|
cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
|
|
20
20
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
21
|
+
recoverChildrenOf,
|
|
21
22
|
OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
|
|
22
23
|
} from './lib/maintain-core.mjs';
|
|
23
24
|
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
|
|
@@ -32,7 +33,7 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
|
32
33
|
// v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
|
|
33
34
|
// router + remaining-command bodies during the incremental split. Future work:
|
|
34
35
|
// move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
|
|
35
|
-
import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints } from './cli/common.mjs';
|
|
36
|
+
import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags } from './cli/common.mjs';
|
|
36
37
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
37
38
|
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
38
39
|
import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
|
|
@@ -667,6 +668,7 @@ function cmdGet(db, args) {
|
|
|
667
668
|
}
|
|
668
669
|
|
|
669
670
|
// Validate --fields against obs schema (only meaningful for obs rows).
|
|
671
|
+
if (rejectBareStringFlags(flags, ['fields', 'source'])) return;
|
|
670
672
|
let requestedFields = null;
|
|
671
673
|
if (flags.fields) {
|
|
672
674
|
const allRequested = flags.fields.split(',').map(s => s.trim());
|
|
@@ -713,6 +715,10 @@ function cmdGet(db, args) {
|
|
|
713
715
|
|
|
714
716
|
function cmdTimeline(db, args) {
|
|
715
717
|
const { positional, flags } = parseArgs(args);
|
|
718
|
+
// Bare `--query` parses to boolean true and crashed downstream in sanitizeFtsQuery
|
|
719
|
+
// (nlp.mjs string ops on a boolean). No sensible default for a search anchor — reject
|
|
720
|
+
// cleanly (#8470). (`--project` bare is absorbed by resolveProject's non-string guard.)
|
|
721
|
+
if (rejectBareStringFlags(flags, ['query'])) return;
|
|
716
722
|
// parseInt('-5') === -5 is truthy, so `|| 5` doesn't rescue negative input.
|
|
717
723
|
// Match cmdSearch's warn-then-default pattern for consistency across CLI flags.
|
|
718
724
|
const parseWindow = (label, raw) => {
|
|
@@ -944,6 +950,10 @@ function cmdSave(db, args) {
|
|
|
944
950
|
return;
|
|
945
951
|
}
|
|
946
952
|
|
|
953
|
+
// Reject value-less string flags before they reach .split()/saveObservation as a
|
|
954
|
+
// boolean `true` (#8470): bare --files/--title/--lesson crashed with a raw stacktrace.
|
|
955
|
+
if (rejectBareStringFlags(flags, ['title', 'files', 'lesson', 'lesson-learned', 'project', 'type'])) return;
|
|
956
|
+
|
|
947
957
|
const type = flags.type || 'discovery';
|
|
948
958
|
const validTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
|
|
949
959
|
if (!validTypes.has(type)) {
|
|
@@ -1070,6 +1080,8 @@ function cmdDeferAdd(db, args) {
|
|
|
1070
1080
|
fail(`[mem] defer add: title too long (${title.length} chars, max 200). Move detail to --detail "<text>".`);
|
|
1071
1081
|
return;
|
|
1072
1082
|
}
|
|
1083
|
+
// Reject bare --files/--detail/--project before .split()/bind sees a boolean true (#8470).
|
|
1084
|
+
if (rejectBareStringFlags(flags, ['files', 'detail', 'project'])) return;
|
|
1073
1085
|
const priority = flags.priority !== undefined ? parseInt(flags.priority, 10) : 2;
|
|
1074
1086
|
// isNumericToken first: bare parseInt would coerce "3xyz"→3 and silently escalate a
|
|
1075
1087
|
// deferred item's urgency. Float literals still truncate (#8277).
|
|
@@ -1614,11 +1626,19 @@ function cmdDelete(db, args) {
|
|
|
1614
1626
|
db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(filtered), r.id);
|
|
1615
1627
|
}
|
|
1616
1628
|
}
|
|
1617
|
-
|
|
1629
|
+
// Resurface any rows merged/compressed INTO the doomed keepers before deleting,
|
|
1630
|
+
// else they dangle behind a missing parent (compressed_into has no FK) — invisible
|
|
1631
|
+
// to every COALESCE(compressed_into,0)=0 view and unrecoverable. Same guard the
|
|
1632
|
+
// maintain hard-delete paths use (recoverChildrenOf); the interactive delete path
|
|
1633
|
+
// was missing it. Returned in the result so the user sees the recovery count.
|
|
1634
|
+
const recovered = recoverChildrenOf(db, ids);
|
|
1635
|
+
const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...ids);
|
|
1636
|
+
return { changes: deleted.changes, recovered };
|
|
1618
1637
|
});
|
|
1619
1638
|
const result = deleteTx();
|
|
1620
1639
|
const missing = ids.filter(id => !rows.some(r => r.id === id));
|
|
1621
|
-
|
|
1640
|
+
const recoveredNote = result.recovered > 0 ? ` Recovered ${result.recovered} merged/compressed child observation(s) to live.` : '';
|
|
1641
|
+
out(`[mem] Deleted ${result.changes} observation(s).${recoveredNote}${missing.length > 0 ? ` Note: ID(s) ${missing.join(', ')} not found.` : ''}`);
|
|
1622
1642
|
}
|
|
1623
1643
|
|
|
1624
1644
|
// ─── Update ──────────────────────────────────────────────────────────────────
|
|
@@ -1644,18 +1664,10 @@ function cmdUpdate(db, args) {
|
|
|
1644
1664
|
return;
|
|
1645
1665
|
}
|
|
1646
1666
|
|
|
1647
|
-
// A value-less `--flag`
|
|
1648
|
-
//
|
|
1649
|
-
//
|
|
1650
|
-
|
|
1651
|
-
// — the same accidental shell-strip class the empty-title guard (#8470) catches.
|
|
1652
|
-
// Reject it cleanly for every string-valued update flag.
|
|
1653
|
-
for (const key of ['title', 'narrative', 'lesson', 'lesson-learned', 'concepts']) {
|
|
1654
|
-
if (flags[key] === true) {
|
|
1655
|
-
fail(`[mem] --${key} requires a value (received a bare flag with no value).`);
|
|
1656
|
-
return;
|
|
1657
|
-
}
|
|
1658
|
-
}
|
|
1667
|
+
// A value-less `--flag` parses to boolean `true` (cli/common.mjs parseArgs); for string
|
|
1668
|
+
// fields that would reach the SQLite bind as a raw "TypeError: SQLite3 can only bind ..."
|
|
1669
|
+
// (#8470). Reject cleanly via the shared guard — single source with the other commands.
|
|
1670
|
+
if (rejectBareStringFlags(flags, ['title', 'narrative', 'lesson', 'lesson-learned', 'concepts'])) return;
|
|
1659
1671
|
|
|
1660
1672
|
const updates = [];
|
|
1661
1673
|
const params = [];
|
|
@@ -2172,6 +2184,9 @@ function cmdRegistry(_memDb, args) {
|
|
|
2172
2184
|
|
|
2173
2185
|
try {
|
|
2174
2186
|
if (action === 'search') {
|
|
2187
|
+
// Bare `--query` parses to boolean true; `true || ...` would search for the literal
|
|
2188
|
+
// string "true". Reject it cleanly (#8470) before it becomes a confusing no-match.
|
|
2189
|
+
if (rejectBareStringFlags(flags, ['query', 'category', 'quality'])) return;
|
|
2175
2190
|
const query = flags.query || positional.slice(1).join(' ');
|
|
2176
2191
|
if (!query) { fail('[mem] Usage: claude-mem-lite registry search <query> [--type skill|agent] [--category C] [--quality Q]'); return; }
|
|
2177
2192
|
let results = searchResources(rdb, query, {
|
|
@@ -2260,12 +2275,9 @@ function cmdRegistry(_memDb, args) {
|
|
|
2260
2275
|
}
|
|
2261
2276
|
|
|
2262
2277
|
if (action === 'import') {
|
|
2263
|
-
//
|
|
2264
|
-
//
|
|
2265
|
-
|
|
2266
|
-
for (const key of ['name', 'resource-type', 'invocation-name', 'source', 'repo-url', 'local-path', 'intent-tags', 'domain-tags', 'trigger-patterns', 'capability-summary', 'keywords', 'tech-stack', 'use-cases']) {
|
|
2267
|
-
if (flags[key] === true) { fail(`[mem] --${key} requires a value (received a bare flag with no value).`); return; }
|
|
2268
|
-
}
|
|
2278
|
+
// Bare value-less flags → boolean true → SQLite-bind crash in upsertResource (#8470).
|
|
2279
|
+
// Shared guard — single source with update/remove/the other commands.
|
|
2280
|
+
if (rejectBareStringFlags(flags, ['name', 'resource-type', 'invocation-name', 'source', 'repo-url', 'local-path', 'intent-tags', 'domain-tags', 'trigger-patterns', 'capability-summary', 'keywords', 'tech-stack', 'use-cases'])) return;
|
|
2269
2281
|
const name = flags.name;
|
|
2270
2282
|
const resourceType = flags['resource-type'];
|
|
2271
2283
|
if (!name || !resourceType) { fail('[mem] Usage: claude-mem-lite registry import --name N --resource-type skill|agent [--invocation-name I] [--capability-summary S]'); return; }
|
|
@@ -2287,11 +2299,9 @@ function cmdRegistry(_memDb, args) {
|
|
|
2287
2299
|
}
|
|
2288
2300
|
|
|
2289
2301
|
if (action === 'remove') {
|
|
2290
|
-
// Bare value-less --name / --resource-type → boolean true → SQLite-bind crash
|
|
2291
|
-
//
|
|
2292
|
-
|
|
2293
|
-
if (flags[key] === true) { fail(`[mem] --${key} requires a value (received a bare flag with no value).`); return; }
|
|
2294
|
-
}
|
|
2302
|
+
// Bare value-less --name / --resource-type → boolean true → SQLite-bind crash on
|
|
2303
|
+
// the DELETE below; shared guard, single source with import/update.
|
|
2304
|
+
if (rejectBareStringFlags(flags, ['name', 'resource-type'])) return;
|
|
2295
2305
|
const name = flags.name;
|
|
2296
2306
|
const resourceType = flags['resource-type'];
|
|
2297
2307
|
if (!name || !resourceType) { fail('[mem] Usage: claude-mem-lite registry remove --name N --resource-type skill|agent'); return; }
|
package/memdir.mjs
CHANGED
|
@@ -219,33 +219,58 @@ export function writePluginSection(memdir, { slug, version, contentLine, force =
|
|
|
219
219
|
/**
|
|
220
220
|
* Remove the plugin's sentinel block plus its state sidecar. External content
|
|
221
221
|
* in MEMORY.md is preserved.
|
|
222
|
-
*
|
|
222
|
+
*
|
|
223
|
+
* Foreign-content guard (symmetric with writePluginSection): a sentinel block with
|
|
224
|
+
* NO state sidecar is content we cannot prove the plugin authored — the user may have
|
|
225
|
+
* pasted plugin docs or quoted a sentinel example. Without `force`, such a block is
|
|
226
|
+
* LEFT IN PLACE (action 'skipped-foreign') instead of being silently deleted. The
|
|
227
|
+
* adopt side already throws UserEditedError on the same condition; unadopt lacked the
|
|
228
|
+
* mirror, so it could delete user-authored text that merely resembled the sentinel.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} memdir
|
|
231
|
+
* @param {string} slug
|
|
232
|
+
* @param {{force?: boolean}} [opts] force=true removes even a no-state (foreign) block.
|
|
233
|
+
* @returns {{action: 'removed'|'absent'|'skipped-foreign'}}
|
|
223
234
|
*/
|
|
224
|
-
export function removePluginSection(memdir, slug) {
|
|
225
|
-
clearState(memdir, slug);
|
|
235
|
+
export function removePluginSection(memdir, slug, { force = false } = {}) {
|
|
226
236
|
const path = memoryFile(memdir);
|
|
227
|
-
if (!existsSync(path)) return { action: 'absent' };
|
|
237
|
+
if (!existsSync(path)) { clearState(memdir, slug); return { action: 'absent' }; }
|
|
228
238
|
const raw = readFileSync(path, 'utf8');
|
|
229
239
|
const match = raw.match(sentinelRegex(slug));
|
|
230
|
-
if (!match) return { action: 'absent' };
|
|
240
|
+
if (!match) { clearState(memdir, slug); return { action: 'absent' }; }
|
|
241
|
+
|
|
242
|
+
// Only remove a block we have a state sidecar for (proof we wrote it), unless forced.
|
|
243
|
+
if (!readState(memdir, slug) && !force) {
|
|
244
|
+
return { action: 'skipped-foreign' };
|
|
245
|
+
}
|
|
246
|
+
clearState(memdir, slug);
|
|
231
247
|
|
|
232
248
|
// Delete the match plus a trailing newline + a preceding blank line so we
|
|
233
249
|
// don't leave a stranded paragraph gap.
|
|
250
|
+
const blockAtStart = match.index === 0;
|
|
234
251
|
let start = match.index;
|
|
235
252
|
let end = match.index + match[0].length;
|
|
236
253
|
if (raw[end] === '\n') end++;
|
|
237
254
|
if (start > 0 && raw.slice(0, start).endsWith('\n\n')) start--;
|
|
238
255
|
let next = raw.slice(0, start) + raw.slice(end);
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
next = next.replace(/^\s+/, '')
|
|
256
|
+
// Collapse any ≥3 consecutive newlines left at the removal seam so the remaining
|
|
257
|
+
// content looks hand-authored. Only strip leading whitespace when OUR block was the
|
|
258
|
+
// file's first content — otherwise an unconditional `/^\s+/` deleted user-authored
|
|
259
|
+
// leading blank lines / structure that sat far above our (end-of-file) block.
|
|
260
|
+
next = next.replace(/\n{3,}/g, '\n\n');
|
|
261
|
+
if (blockAtStart) next = next.replace(/^\s+/, '');
|
|
245
262
|
atomicWrite(path, next);
|
|
246
263
|
return { action: 'removed' };
|
|
247
264
|
}
|
|
248
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Whether a plugin state sidecar exists for this memdir — i.e. the plugin can prove it
|
|
268
|
+
* wrote the sentinel. Used by unadopt's dry-run to predict the foreign-content skip.
|
|
269
|
+
*/
|
|
270
|
+
export function hasPluginState(memdir, slug) {
|
|
271
|
+
return readState(memdir, slug) !== null;
|
|
272
|
+
}
|
|
273
|
+
|
|
249
274
|
/**
|
|
250
275
|
* Whether this memdir has our sentinel. Body edits don't demote the adoption —
|
|
251
276
|
* users who hand-tweak the contract line still count as adopted.
|
package/nlp.mjs
CHANGED
|
@@ -11,6 +11,23 @@ export { SYNONYM_MAP, CJK_COMPOUNDS };
|
|
|
11
11
|
|
|
12
12
|
const FTS5_KEYWORDS = new Set(['AND', 'OR', 'NOT', 'NEAR']);
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* True if a CJK bigram is pure grammatical noise that should not enter an FTS query
|
|
16
|
+
* or the precision gate's `required` set. CJK_STOP_WORDS holds single-char particles
|
|
17
|
+
* (的/了/是…) plus a few whole multi-char fillers (什么/怎么…); callers used to test a
|
|
18
|
+
* 2-char bigram with a bare `CJK_STOP_WORDS.has(bg)`, which only caught the whole-filler
|
|
19
|
+
* case — so a particle-pair bigram like `的了` / `了是` slipped through and (a) forced an
|
|
20
|
+
* unsatisfiable AND term and (b) made an all-particle query's `required` set non-empty,
|
|
21
|
+
* wrongly rejecting every candidate. We reject a bigram when it IS a known filler OR when
|
|
22
|
+
* BOTH characters are single-char stop words. A bigram with only ONE stop char (有效, 目的)
|
|
23
|
+
* is deliberately kept — those are real compounds, and distinguishing a boundary-straddle
|
|
24
|
+
* (的全) from a genuine compound needs a dictionary/recall benchmark (deferred).
|
|
25
|
+
*/
|
|
26
|
+
function isCjkNoiseBigram(bg) {
|
|
27
|
+
if (CJK_STOP_WORDS.has(bg)) return true;
|
|
28
|
+
return bg.length === 2 && CJK_STOP_WORDS.has(bg[0]) && CJK_STOP_WORDS.has(bg[1]);
|
|
29
|
+
}
|
|
30
|
+
|
|
14
31
|
// Sort by length descending for greedy matching
|
|
15
32
|
const CJK_SORTED = [...CJK_COMPOUNDS].sort((a, b) => b.length - a.length);
|
|
16
33
|
|
|
@@ -177,7 +194,7 @@ export function cjkPrecisionOk(query, text, threshold) {
|
|
|
177
194
|
const keywords = extractCjkKeywords(query);
|
|
178
195
|
const required = keywords.length > 0
|
|
179
196
|
? keywords
|
|
180
|
-
: cjkBigrams(query).split(' ').filter(b => b && !
|
|
197
|
+
: cjkBigrams(query).split(' ').filter(b => b && !isCjkNoiseBigram(b));
|
|
181
198
|
if (required.length === 0) return true;
|
|
182
199
|
const hit = required.filter(w => text.includes(w)).length;
|
|
183
200
|
return (hit / required.length) >= threshold;
|
|
@@ -254,7 +271,7 @@ export function sanitizeFtsQuery(query) {
|
|
|
254
271
|
const gapBigrams = cjkBigrams(remainder);
|
|
255
272
|
if (gapBigrams) {
|
|
256
273
|
for (const bg of gapBigrams.split(' ')) {
|
|
257
|
-
if (bg && !
|
|
274
|
+
if (bg && !isCjkNoiseBigram(bg) && !matched.has(bg)) expandedTokens.push(bg);
|
|
258
275
|
}
|
|
259
276
|
}
|
|
260
277
|
continue;
|
|
@@ -278,7 +295,7 @@ export function sanitizeFtsQuery(query) {
|
|
|
278
295
|
);
|
|
279
296
|
if (pureCjkTokens.length > 0) bigrams = cjkBigrams(pureCjkTokens.join(' '));
|
|
280
297
|
}
|
|
281
|
-
const bigramSet = new Set(bigrams ? bigrams.split(' ').filter(b => b && !
|
|
298
|
+
const bigramSet = new Set(bigrams ? bigrams.split(' ').filter(b => b && !isCjkNoiseBigram(b)) : []);
|
|
282
299
|
const hasBigrams = bigramSet.size > 0;
|
|
283
300
|
const finalTokens = [];
|
|
284
301
|
const seen = new Set();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.93.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/project-utils.mjs
CHANGED
|
@@ -14,6 +14,12 @@ const _cache = new Map();
|
|
|
14
14
|
*/
|
|
15
15
|
export function resolveProject(db, name) {
|
|
16
16
|
if (!name) return name;
|
|
17
|
+
// Defense-in-depth: a bare `--project` CLI flag parses to boolean `true` (and a
|
|
18
|
+
// malformed MCP/hook caller could pass any non-string). `true.includes('--')` below
|
|
19
|
+
// throws a raw TypeError that crashed search/recent/timeline/stats/export/defer-list.
|
|
20
|
+
// Treat any non-string as "no project filter" (null) — the degradation every caller
|
|
21
|
+
// already handles for an absent --project — instead of crashing at the root helper.
|
|
22
|
+
if (typeof name !== 'string') return null;
|
|
17
23
|
if (_cache.has(name)) return _cache.get(name);
|
|
18
24
|
// Already a canonical name (contains "--")? Use as-is.
|
|
19
25
|
if (name.includes('--')) { _cache.set(name, name); return name; }
|
package/registry-importer.mjs
CHANGED
|
@@ -336,10 +336,15 @@ export async function importFromGitHub(db, url, opts = {}) {
|
|
|
336
336
|
indexed_at: new Date().toISOString(),
|
|
337
337
|
});
|
|
338
338
|
|
|
339
|
-
// 5g. Update repo_forks and repo_updated_at (not in upsert SQL)
|
|
339
|
+
// 5g. Update repo_forks and repo_updated_at (not in upsert SQL).
|
|
340
|
+
// Do NOT touch quality_tier here: UPSERT_SQL never writes it, so a first insert
|
|
341
|
+
// gets the column DEFAULT 'community' and a re-import preserves whatever tier the
|
|
342
|
+
// row reached. Re-stamping 'community' downgraded enrichment-promoted tiers
|
|
343
|
+
// (verified/installed → community) on every content re-import, silently lowering
|
|
344
|
+
// the resource's BM25 composite rank (tier is a 1.0/2.0/3.0 multiplier).
|
|
340
345
|
db.prepare(
|
|
341
|
-
'UPDATE resources SET repo_forks = ?, repo_updated_at =
|
|
342
|
-
).run(repoForks, repoUpdatedAt,
|
|
346
|
+
'UPDATE resources SET repo_forks = ?, repo_updated_at = ? WHERE id = ?'
|
|
347
|
+
).run(repoForks, repoUpdatedAt, resourceId);
|
|
343
348
|
|
|
344
349
|
results.push({ name, type: item.type, id: resourceId });
|
|
345
350
|
debugLog('INFO', 'importer', `Imported ${item.type}:${name} (id=${resourceId})`);
|
package/registry-retriever.mjs
CHANGED
|
@@ -284,6 +284,10 @@ export function filterByProjectDomain(results, projectDomains) {
|
|
|
284
284
|
//
|
|
285
285
|
// Composite ranking formula:
|
|
286
286
|
// 40% BM25 text relevance
|
|
287
|
+
// Quality-tier bonus: bounded additive (installed -0.15, verified -0.075). Was a
|
|
288
|
+
// MULTIPLIER on the BM25 term, which scaled the magnitude of a variable, unbounded,
|
|
289
|
+
// NEGATIVE signal — letting a weakly-matching installed resource (×3) outrank a
|
|
290
|
+
// strongly-matching community one. Additive keeps tier a promotion, not an override.
|
|
287
291
|
// 15% Star popularity (saturation normalization — diminishing returns after ~500 stars)
|
|
288
292
|
// 15% Success rate (Laplace smoothing — Beta prior α=1, β=1 for small-sample robustness)
|
|
289
293
|
// 10% Adoption rate (Laplace smoothing)
|
|
@@ -301,10 +305,10 @@ export function filterByProjectDomain(results, projectDomains) {
|
|
|
301
305
|
// Sign convention: more negative = better. BM25 is negative, behavioral signals are subtracted.
|
|
302
306
|
const COMPOSITE_EXPR = `(
|
|
303
307
|
bm25(resources_fts, 3.0, 3.0, 3.0, 2.0, 2.0, 1.0, 1.0, 1.0) * 0.4
|
|
304
|
-
|
|
305
|
-
WHEN 'installed' THEN
|
|
306
|
-
WHEN 'verified' THEN
|
|
307
|
-
ELSE
|
|
308
|
+
- CASE COALESCE(r.quality_tier, 'community')
|
|
309
|
+
WHEN 'installed' THEN 0.15
|
|
310
|
+
WHEN 'verified' THEN 0.075
|
|
311
|
+
ELSE 0
|
|
308
312
|
END
|
|
309
313
|
- COALESCE(r.repo_stars * 1.0 / (r.repo_stars + 100.0), 0) * 0.15
|
|
310
314
|
- (
|
|
@@ -347,7 +351,7 @@ const SEARCH_SQL = `
|
|
|
347
351
|
WHERE resources_fts MATCH ?
|
|
348
352
|
AND r.status = 'active'
|
|
349
353
|
) sub
|
|
350
|
-
ORDER BY composite_score ASC
|
|
354
|
+
ORDER BY composite_score ASC, id ASC
|
|
351
355
|
LIMIT ?
|
|
352
356
|
`;
|
|
353
357
|
|
|
@@ -362,7 +366,7 @@ const SEARCH_BY_TYPE_SQL = `
|
|
|
362
366
|
AND r.status = 'active'
|
|
363
367
|
AND r.type = ?
|
|
364
368
|
) sub
|
|
365
|
-
ORDER BY composite_score ASC
|
|
369
|
+
ORDER BY composite_score ASC, id ASC
|
|
366
370
|
LIMIT ?
|
|
367
371
|
`;
|
|
368
372
|
|