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.
@@ -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
- return db.prepare(`
34
- DELETE FROM observations WHERE id IN (
35
- SELECT id FROM observations
36
- WHERE COALESCE(compressed_into, 0) = 0
37
- AND (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
38
- ${projectFilter} LIMIT ${opCap}
39
- )
40
- `).run(...baseParams).changes;
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
- let merged = 0;
119
- const mergeStmt = db.prepare('UPDATE observations SET compressed_into = ? WHERE id = ? AND COALESCE(compressed_into, 0) = 0');
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
- // Skip self-merge: compressed_into=self hides the row from every
125
- // compressed_into=0 view (recent/search/browse) silent data loss from a
126
- // typo like `--merge-ids 5:5`. Shared core, so this covers CLI + MCP.
127
- if (removeId === keepId) continue;
128
- merged += mergeStmt.run(keepId, removeId).changes;
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
- return db.prepare(`
146
- DELETE FROM observations WHERE id IN (
147
- SELECT id FROM observations
148
- WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ?
149
- ${projectFilter} LIMIT ${opCap}
150
- )
151
- `).run(retainCutoff, ...baseParams).changes;
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
+ }
@@ -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, debugCatch } from '../utils.mjs';
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
- const result = db.prepare(`
103
- INSERT INTO observations (memory_session_id, project, text, type, title, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, lesson_learned, branch, created_at, created_at_epoch)
104
- VALUES (?, ?, ?, ?, ?, ?, '', '', '[]', ?, ?, ?, ?, ?, ?, ?)
105
- `).run(
106
- sessionId, project, textField, type, safeTitle, safeContent,
107
- JSON.stringify(files), importance, minhashSig, safeLesson, getCurrentBranch(),
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
- if (savedId && files.length > 0) {
113
- const insertFile = db.prepare('INSERT OR IGNORE INTO observation_files (obs_id, filename) VALUES (?, ?)');
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
- return db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...ids);
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
- out(`[mem] Deleted ${result.changes} observation(s).${missing.length > 0 ? ` Note: ID(s) ${missing.join(', ')} not found.` : ''}`);
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` (last arg, or immediately followed by another --flag)
1648
- // parses to boolean `true` (cli/common.mjs parseArgs). For string-valued fields
1649
- // that boolean would slip past the string-only empty guards below and reach the
1650
- // SQLite bind, surfacing a raw "TypeError: SQLite3 can only bind ..." stacktrace
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
- // A bare value-less flag parses to boolean `true` (parseArgs); for these string
2264
- // fields that boolean reaches the SQLite bind in upsertResource and throws a raw
2265
- // TypeError same class as the `update` guard above (#8470). Reject up front.
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
- // on the DELETE below; reject like the import branch and the `update` guard.
2292
- for (const key of ['name', 'resource-type']) {
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
- * @returns {{action: 'removed'|'absent'}}
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
- // Edge case (code review v2.32.3): when the sentinel was the first content
240
- // (e.g. two invited-memory plugins coexist and we remove the earlier one),
241
- // the tail can still start with a stranded blank line / doubled newlines.
242
- // Normalize leading whitespace and collapse any ≥3 consecutive newlines
243
- // so the remaining content looks hand-authored.
244
- next = next.replace(/^\s+/, '').replace(/\n{3,}/g, '\n\n');
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 && !CJK_STOP_WORDS.has(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 && !CJK_STOP_WORDS.has(bg) && !matched.has(bg)) expandedTokens.push(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 && !CJK_STOP_WORDS.has(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.91.0",
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.16",
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; }
@@ -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 = ?, quality_tier = ? WHERE id = ?'
342
- ).run(repoForks, repoUpdatedAt, 'community', resourceId);
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})`);
@@ -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
- * CASE COALESCE(r.quality_tier, 'community')
305
- WHEN 'installed' THEN 3.0
306
- WHEN 'verified' THEN 2.0
307
- ELSE 1.0
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