claude-mem-lite 3.39.1 → 3.40.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.39.1",
13
+ "version": "3.40.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": "3.39.1",
3
+ "version": "3.40.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/claudemd.mjs CHANGED
@@ -143,7 +143,10 @@ export function writeManaged(cwd, { slug, version, block, doc }) {
143
143
  else next = raw + '\n\n' + section + '\n';
144
144
  action = 'created';
145
145
  } else {
146
- next = raw.replace(m[0], section);
146
+ // Function replacer (not a string): a `$`-sequence in `section` (a future template with
147
+ // a shell example / regex / `$1`) would otherwise be interpreted as a replacement
148
+ // back-reference and corrupt the block on every SessionStart refresh. Matches line 153.
149
+ next = raw.replace(m[0], () => section);
147
150
  action = next !== raw ? 'updated' : 'unchanged';
148
151
  }
149
152
  // H2: collapse any DUPLICATE same-slug blocks (keep the first, drop the rest).
package/commands/bug.md CHANGED
@@ -43,13 +43,16 @@ Map severity to importance:
43
43
  Build the body as `<description>\n\nRepro:\n<repro-steps>` (or just
44
44
  `<description>` if `--repro` is absent).
45
45
 
46
- Run via Bash — the body is the positional content; the description goes in
47
- `--lesson` so it lands in the high-weight `lesson_learned` field:
46
+ Run via Bash — the full body is the positional content; a distilled description
47
+ goes in `--lesson` so it lands in the high-weight `lesson_learned` field.
48
+ `--lesson` is capped at 500 chars (longer values are rejected), so keep the full
49
+ body in the positional content and trim `--lesson` to the core description if
50
+ longer:
48
51
 
49
52
  node ${CLAUDE_PLUGIN_ROOT}/cli.mjs save "<body>" \
50
53
  --type bugfix \
51
54
  --title "<first 60 chars of description>" \
52
- --lesson "<description>" \
55
+ --lesson "<description, ≤500 chars>" \
53
56
  [--files f1,f2,...] \
54
57
  --importance <1|2|3>
55
58
 
@@ -36,13 +36,16 @@ Don't use for trivial fixes (typos, renames), for rules that belong in memdir
36
36
 
37
37
  ## Execution
38
38
 
39
- Run via Bash — pass the lesson text as the positional content and repeat it in
40
- `--lesson` so it lands in the high-weight `lesson_learned` field:
39
+ Run via Bash — pass the full lesson text as the positional content, and a
40
+ distilled version in `--lesson` so it lands in the high-weight `lesson_learned`
41
+ field. `--lesson` is capped at 500 chars (longer values are rejected), so for a
42
+ long lesson keep the full text in the positional content and trim `--lesson` to
43
+ the core insight:
41
44
 
42
45
  node ${CLAUDE_PLUGIN_ROOT}/cli.mjs save "<full text>" \
43
46
  --type discovery \
44
47
  --title "<first 60 chars of text>" \
45
- --lesson "<full text>" \
48
+ --lesson "<core insight, ≤500 chars>" \
46
49
  [--files f1,f2,...] \
47
50
  --importance <1|2|3>
48
51
 
package/hook-memory.mjs CHANGED
@@ -255,6 +255,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
255
255
  try {
256
256
  const crossStmt = db.prepare(`
257
257
  SELECT o.id, o.type, o.title, o.subtitle, o.narrative, o.importance, o.lesson_learned, o.project,
258
+ o.created_at_epoch, o.files_modified,
258
259
  o.cited_count, o.uncited_streak,
259
260
  ${OBS_BM25} as relevance,
260
261
  ${noisePenaltyClause('o')} as noise_penalty
package/hook-optimize.mjs CHANGED
@@ -171,6 +171,10 @@ Give 3-6 aliases: words a user might search for the SAME concept but that are NO
171
171
  const safe = scrubRecord('observations', { text: appendedText, search_aliases: searchAliases });
172
172
  db.prepare(`UPDATE observations SET search_aliases = ?, text = ? WHERE id = ?`)
173
173
  .run(safe.search_aliases, safe.text, cand.id);
174
+ // Refresh the TF-IDF vector from the just-updated FTS text so the new
175
+ // aliases reach the vector arm too — the narrow/wide branch rebuilds, this
176
+ // one must as well. No-ops when the vector arm is off / vocab unbuilt.
177
+ rebuildVector(db, cand.id, [safe.text]);
174
178
  processed++;
175
179
  continue;
176
180
  }
@@ -278,7 +282,12 @@ export function _normalizeGateOpen(last, now) {
278
282
  return now - epoch >= NORMALIZE_INTERVAL_MS;
279
283
  }
280
284
 
281
- export function shouldRunNormalize() {
285
+ export function shouldRunNormalize(project = null) {
286
+ // The 7-day gate rate-limits the UNSCOPED whole-store normalize. An explicit --project is
287
+ // targeted work: it must not be blocked by a prior global (or other-project) run, and it
288
+ // does not advance the shared timer (see executeNormalize). Without this, `optimize --run
289
+ // --task normalize --project B` returned skipped(gate) for 7 days if ANY project had run.
290
+ if (project) return true;
282
291
  try {
283
292
  const last = JSON.parse(readFileSync(NORMALIZE_GATE_FILE, 'utf8'));
284
293
  return _normalizeGateOpen(last, Date.now());
@@ -395,7 +404,7 @@ export function applyNormalization(db, groups, { project = null } = {}) {
395
404
  }
396
405
 
397
406
  export async function executeNormalize(db, force = false, { project } = {}) {
398
- if (!force && !shouldRunNormalize()) return { skipped: true, reason: 'gate' };
407
+ if (!force && !shouldRunNormalize(project)) return { skipped: true, reason: 'gate' };
399
408
 
400
409
  const concepts = extractUniqueConcepts(db, 500, { project });
401
410
  if (concepts.length < 5) return { skipped: true, reason: 'too few concepts' };
@@ -405,7 +414,10 @@ export async function executeNormalize(db, force = false, { project } = {}) {
405
414
 
406
415
  const result = applyNormalization(db, groups, { project });
407
416
 
408
- try { writeFileSync(NORMALIZE_GATE_FILE, JSON.stringify({ epoch: Date.now() })); } catch {}
417
+ // Only the UNSCOPED (whole-store) run advances the shared 7-day gate. A project-scoped run
418
+ // must not reset the global timer (it never consulted it — shouldRunNormalize(project) is
419
+ // always open), or one `--project X` run would silently block the next global normalize.
420
+ if (!project) { try { writeFileSync(NORMALIZE_GATE_FILE, JSON.stringify({ epoch: Date.now() })); } catch { /* best-effort */ } }
409
421
 
410
422
  return { processed: result.updated, groups: groups.length };
411
423
  }
@@ -833,7 +845,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
833
845
  const reenrichAliases = findReenrichCandidates(db, 5000, { scope: 'aliases', project }).length;
834
846
 
835
847
  const concepts = extractUniqueConcepts(db, 500, { project });
836
- const normalizeReady = shouldRunNormalize() && concepts.length >= 5;
848
+ const normalizeReady = shouldRunNormalize(project) && concepts.length >= 5;
837
849
 
838
850
  const mergeClusters = findMergeCandidates(db, 50, { project });
839
851
  const clusterMerge = mergeClusters.length;
@@ -847,7 +859,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
847
859
  reenrichWide,
848
860
  reenrichAliases,
849
861
  normalize: normalizeReady ? concepts.length : 0,
850
- normalizeGateOpen: shouldRunNormalize(),
862
+ normalizeGateOpen: shouldRunNormalize(project),
851
863
  clusterMerge,
852
864
  smartCompress,
853
865
  total: reenrich + (normalizeReady ? 1 : 0) + clusterMerge + smartCompress,
package/lib/activity.mjs CHANGED
@@ -131,10 +131,12 @@ export function recentEvents(db, { project, type = null, limit = 20 } = {}) {
131
131
  * importance>=minImportance) into searchable observations. mem_search / passive
132
132
  * injection never read the events table, so explicit /bug /lesson history and
133
133
  * high-value auto-captured events were unfindable. Each promoted event is marked
134
- * (superseded_at_epoch + superseded_by_id the new obs id) so re-runs are
135
- * idempotent. The source event row is kept (activity log intact), just retired.
134
+ * with superseded_at_epoch ONLY (superseded_by_id is a self-FK events(id), so an
135
+ * observation id there fails the FK on the real DB the marker alone gives
136
+ * idempotency) so re-runs skip it. The source event row is kept (activity log
137
+ * intact), just retired.
136
138
  *
137
- * @returns {{eligible:number, promoted:number, deduped:number}}
139
+ * @returns {{eligible:number, promoted:number, deduped:number, skipped:number}}
138
140
  */
139
141
  export function promoteInsightEvents(db, { project = null, minImportance = 2, execute = false, limit = 5000 } = {}) {
140
142
  const projClause = project ? 'AND project = ?' : '';
@@ -128,8 +128,11 @@ const ACTIVITY_SAVE_LESSON_RE = /activity\s+save\s+--type\s+(lesson|bugfix)\b/;
128
128
  // P2(a): /bug and /lesson were redirected from `activity save` (events) to
129
129
  // `cli.mjs save … --lesson` (searchable observations). Recognize that shape too,
130
130
  // or the unsaved-bugfix nudge over-fires after an explicit save. Anchored on the
131
- // mem CLI + the --lesson flag both skills always pass.
132
- const OBS_INSIGHT_SAVE_RE = /(?:cli\.mjs|claude-mem-lite)['"]?\s+save\b[^\n]*--lesson(?:-learned)?\b/;
131
+ // mem CLI + the --lesson flag both skills always pass. Uses `[\s\S]*?` (not
132
+ // `[^\n]*`) because the skill templates document a multi-line, backslash-
133
+ // continued command — after JSON.parse the command carries real newlines, so the
134
+ // gap between `save` and `--lesson` must be allowed to span them (lazy → nearest).
135
+ const OBS_INSIGHT_SAVE_RE = /(?:cli\.mjs|claude-mem-lite)['"]?\s+save\b[\s\S]*?--lesson(?:-learned)?\b/;
133
136
  const MEM_SAVE_TOOL_NAMES = new Set([
134
137
  'mem_save',
135
138
  'mcp__claude_mem_lite__mem_save',
@@ -397,10 +397,15 @@ export function maintenanceStats(db, { projectFilter, baseParams, staleAge }) {
397
397
  -- the scan stat previews what decay will mark idle, and decay protects
398
398
  -- injected rows. Omitting it over-counted "stale" by the injected-but-decayed
399
399
  -- rows decay never touches (e.g. demote_pinned's output: imp=1 but inj>0).
400
+ -- lesson_learned guard mirrors decayAndMarkIdle (:188) / cleanupBroken (:153): those
401
+ -- ops NEVER touch a lesson-bearing row ("lessons never auto-GC"), so the scan preview
402
+ -- must exclude them too or it over-forecasts "Stale"/"Broken" vs what execute does.
400
403
  COALESCE(SUM(CASE WHEN COALESCE(importance, 1) = 1 AND COALESCE(access_count, 0) = 0
401
404
  AND COALESCE(injection_count, 0) = 0
405
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
402
406
  AND created_at_epoch < ? THEN 1 ELSE 0 END), 0) as stale,
403
407
  COALESCE(SUM(CASE WHEN (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
408
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
404
409
  THEN 1 ELSE 0 END), 0) as broken,
405
410
  COALESCE(SUM(CASE WHEN COALESCE(access_count, 0) > 3 AND COALESCE(importance, 1) < 3
406
411
  THEN 1 ELSE 0 END), 0) as boostable,
@@ -113,7 +113,10 @@ export function saveObservation(db, params) {
113
113
  });
114
114
 
115
115
  insertObservationFiles(db, savedId, files);
116
- insertObservationVector(db, savedId, safeTitle + ' ' + safeContent);
116
+ // Vector text mirrors the FTS-indexed content (title + content + lesson) so a
117
+ // lesson-only term isn't invisible to the vector arm (finding #8). Reuses the
118
+ // same indexText the FTS `text` field is built from.
119
+ insertObservationVector(db, savedId, indexText);
117
120
 
118
121
  return savedId;
119
122
  });
@@ -54,6 +54,27 @@ export function parseDuration(raw) {
54
54
  return { ok: true, ms: n * DURATION_UNIT_MS[m[2].toLowerCase()] };
55
55
  }
56
56
 
57
+ /**
58
+ * Parse one from/to bound to epoch ms. A bare `YYYY-MM-DD` is the user's LOCAL
59
+ * calendar day (created_at_epoch is Date.now() = local wall-clock), so it must be
60
+ * built in local time — `new Date('YYYY-MM-DD')` parses as UTC midnight and shifts the
61
+ * boundary by the tz offset (8h for the UTC+8 base), silently dropping early-morning
62
+ * rows and leaking the next day's early hours. `endOfDay` extends a date-only bound to
63
+ * 23:59:59.999 local (the inclusive `--to` day). Full ISO 8601 (with time/offset) is
64
+ * parsed as-is, honoring any explicit zone. Out-of-range date-only strings return NaN so
65
+ * the caller's isNaN guard rejects them (matches the old UTC parse's strict Invalid Date).
66
+ */
67
+ function parseCalendarBound(raw, endOfDay) {
68
+ if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
69
+ const [y, m, d] = raw.split('-').map(Number);
70
+ const dt = endOfDay ? new Date(y, m - 1, d, 23, 59, 59, 999) : new Date(y, m - 1, d, 0, 0, 0, 0);
71
+ // new Date(2026, 12, 45) silently rolls over; reject so 2026-13-45 fails like before.
72
+ if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return NaN;
73
+ return dt.getTime();
74
+ }
75
+ return new Date(raw).getTime();
76
+ }
77
+
57
78
  /**
58
79
  * Parse from/to date bounds to epoch ms. Date-only `to` (YYYY-MM-DD) extends
59
80
  * to end-of-day so "to 2026-06-12" includes that day's rows. An optional
@@ -63,11 +84,8 @@ export function parseDuration(raw) {
63
84
  * | { ok: false, bad: 'from'|'to'|'since', value: string }}
64
85
  */
65
86
  export function parseDateBounds(fromRaw, toRaw, sinceRaw = null, now = Date.now()) {
66
- let epochFrom = fromRaw ? new Date(fromRaw).getTime() : null;
67
- let epochTo = toRaw ? new Date(toRaw).getTime() : null;
68
- if (epochTo !== null && toRaw && /^\d{4}-\d{2}-\d{2}$/.test(toRaw)) {
69
- epochTo += 86400000 - 1; // extend to 23:59:59.999
70
- }
87
+ let epochFrom = fromRaw ? parseCalendarBound(fromRaw, false) : null;
88
+ const epochTo = toRaw ? parseCalendarBound(toRaw, true) : null;
71
89
  if (epochFrom !== null && isNaN(epochFrom)) return { ok: false, bad: 'from', value: fromRaw };
72
90
  if (epochTo !== null && isNaN(epochTo)) return { ok: false, bad: 'to', value: toRaw };
73
91
  if (sinceRaw) {
@@ -17,11 +17,11 @@ import { sanitizeFtsQuery } from '../utils.mjs';
17
17
 
18
18
  const TIMELINE_COLS = 'id, type, title, subtitle, project, created_at, created_at_epoch';
19
19
 
20
- /** Nearest non-compressed observation to `epoch` (optionally project-scoped). */
20
+ /** Nearest live (non-compressed, non-superseded) observation to `epoch`. */
21
21
  function nearestObservation(db, epoch, project) {
22
22
  return db.prepare(`
23
23
  SELECT id FROM observations
24
- WHERE COALESCE(compressed_into, 0) = 0 ${project ? 'AND project = ?' : ''}
24
+ WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${project ? 'AND project = ?' : ''}
25
25
  ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
26
26
  `).get(...(project ? [project, epoch] : [epoch]));
27
27
  }
@@ -56,7 +56,7 @@ export function resolveAnchorToken(db, rawAnchor, { project = null } = {}) {
56
56
  // Bare "#N" or "N" — observation first. Route compressed obs to its live
57
57
  // parent so the window (which filters compressed) isn't shown around a dead
58
58
  // record; negative sentinels (-1 dropped, -2 pending purge) have no parent.
59
- const obsRow = db.prepare('SELECT compressed_into FROM observations WHERE id = ?').get(parsed.id);
59
+ const obsRow = db.prepare('SELECT compressed_into, superseded_at, superseded_by FROM observations WHERE id = ?').get(parsed.id);
60
60
  if (obsRow) {
61
61
  const ci = obsRow.compressed_into;
62
62
  if (ci && ci > 0) {
@@ -65,6 +65,16 @@ export function resolveAnchorToken(db, rawAnchor, { project = null } = {}) {
65
65
  if (ci && ci < 0) {
66
66
  return { ok: false, error: { code: 'compressed-pruned', id: parsed.id } };
67
67
  }
68
+ // Superseded obs → hop to its live successor, mirroring compressed→parent. A
69
+ // superseded row is dropped from every other read path (and from the before/after
70
+ // window legs below), so anchoring ON it would surface a dead record. superseded_by
71
+ // is polymorphic: a numeric obs id for explicit supersession (save-observation), or a
72
+ // string marker ('auto-dedup'/'auto-dedup-fuzzy') for hook auto-dedup — only the
73
+ // numeric case has a successor to redirect to, so guard on `typeof … number` (not
74
+ // `> 0`, which a string sentinel would silently pass as NaN→false anyway but reads wrong).
75
+ if (obsRow.superseded_at !== null && typeof obsRow.superseded_by === 'number' && obsRow.superseded_by > 0) {
76
+ return { ok: true, anchorId: obsRow.superseded_by, anchorNote: `(anchored to #${obsRow.superseded_by}, #${parsed.id} was superseded by it)` };
77
+ }
68
78
  return { ok: true, anchorId: parsed.id, anchorNote: null };
69
79
  }
70
80
 
@@ -138,10 +148,12 @@ export function resolveQueryAnchor(db, queryStr, { project = null } = {}) {
138
148
  };
139
149
  }
140
150
 
141
- /** No-anchor fallback: most recent non-compressed observations, newest first. */
151
+ /** No-anchor fallback: most recent live (non-compressed, non-superseded) obs, newest first. */
142
152
  export function fetchRecentTimeline(db, { project = null, limit }) {
143
- const compressedFilter = 'COALESCE(compressed_into, 0) = 0';
144
- const where = project ? `WHERE ${compressedFilter} AND project = ?` : `WHERE ${compressedFilter}`;
153
+ // superseded_at IS NULL mirrors the before/after window legs (fetchTimelineWindow) and
154
+ // every other read path a superseded row must not lead the "most recent" timeline.
155
+ const liveFilter = 'COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL';
156
+ const where = project ? `WHERE ${liveFilter} AND project = ?` : `WHERE ${liveFilter}`;
145
157
  const params = project ? [project, limit] : [limit];
146
158
  return db.prepare(`
147
159
  SELECT ${TIMELINE_COLS}
package/mem-cli.mjs CHANGED
@@ -1569,7 +1569,10 @@ function cmdUpdate(db, args) {
1569
1569
  updates.push('lesson_learned = ?');
1570
1570
  params.push(scrubSecrets(rawLesson));
1571
1571
  }
1572
- if (flags.concepts !== undefined) { updates.push('concepts = ?'); params.push(flags.concepts); }
1572
+ // Scrub like the sibling text fields above (title/narrative/lesson) and the MCP twin
1573
+ // mem_update — concepts is a scrub-target + FTS-indexed column, so a raw secret here
1574
+ // lands searchable + exportable (rebuildObservationDerived folds it into `text`).
1575
+ if (flags.concepts !== undefined) { updates.push('concepts = ?'); params.push(scrubSecrets(flags.concepts)); }
1573
1576
 
1574
1577
  if (updates.length === 0) {
1575
1578
  fail('[mem] No fields to update. Use --title, --type, --importance, --lesson/--lesson-learned, --narrative, --concepts');
@@ -1769,8 +1772,16 @@ function cmdRestore(db, argv) {
1769
1772
  // Re-apply the fields saveObservation zeros/derives so the backup is faithful.
1770
1773
  // search_aliases is its own FTS5 column, so this UPDATE re-syncs the index
1771
1774
  // (via the observations FTS triggers) and restored aliases stay searchable.
1775
+ // Scrub the FTS-indexed text fields on the way in — the sibling ingest paths
1776
+ // (import-jsonl, compress-core) scrub as defense-in-depth, and restore is the only
1777
+ // rewrite path that skipped it. A backup made before a SECRET_PATTERNS entry existed
1778
+ // would otherwise re-index an old secret in facts/concepts even though narrative
1779
+ // (routed through saveObservation) gets re-scrubbed. files_read/branch are
1780
+ // paths/identifiers, not scrub-target text — left as-is.
1772
1781
  signalUpdate.run(
1773
- r.subtitle || '', r.concepts || '', r.facts || '', r.search_aliases ?? null, r.files_read || '[]', r.branch ?? null,
1782
+ scrubSecrets(r.subtitle || ''), scrubSecrets(r.concepts || ''), scrubSecrets(r.facts || ''),
1783
+ r.search_aliases === null || r.search_aliases === undefined ? null : scrubSecrets(r.search_aliases),
1784
+ r.files_read || '[]', r.branch ?? null,
1774
1785
  num(r.access_count), num(r.cited_count), num(r.uncited_streak), num(r.injection_count),
1775
1786
  num(r.decay_seen_count), r.last_accessed_at ?? null,
1776
1787
  res.id,
@@ -2239,7 +2250,22 @@ function cmdRegistry(_memDb, args) {
2239
2250
  const name = flags.name;
2240
2251
  const resourceType = flags['resource-type'];
2241
2252
  if (!name || !resourceType) { fail('[mem] Usage: claude-mem-lite registry import --name N --resource-type skill|agent [--invocation-name I] [--capability-summary S]'); return; }
2242
- const fields = { name, type: resourceType, status: 'active', source: flags.source || 'user' };
2253
+ // Validate --source against its CHECK enum (parity with memRegistrySchema.source on MCP):
2254
+ // an invalid value otherwise reaches the INSERT and throws a raw SqliteError stacktrace
2255
+ // (the dispatcher's catch only special-cases SQLITE_BUSY/LOCKED).
2256
+ if (flags.source && !new Set(['preinstalled', 'user', 'github']).has(flags.source)) {
2257
+ fail(`[mem] Invalid --source "${flags.source}". Valid: preinstalled, user, github`);
2258
+ return;
2259
+ }
2260
+ // Preserve provenance on a metadata-only re-import: default source to 'user' only for
2261
+ // a genuinely NEW resource. Re-importing an existing github/preinstalled row without
2262
+ // --source must not flip it to 'user' (which also mis-grants the user-source rank boost).
2263
+ let source = flags.source;
2264
+ if (!source) {
2265
+ const existing = rdb.prepare('SELECT source FROM resources WHERE type = ? AND name = ?').get(resourceType, name);
2266
+ source = existing ? existing.source : 'user';
2267
+ }
2268
+ const fields = { name, type: resourceType, status: 'active', source };
2243
2269
  for (const f of ['repo-url', 'local-path', 'invocation-name', 'intent-tags', 'domain-tags', 'trigger-patterns', 'capability-summary', 'keywords', 'tech-stack', 'use-cases']) {
2244
2270
  const camel = f.replace(/-([a-z])/g, (_, c) => '_' + c);
2245
2271
  fields[camel] = flags[f] || '';
package/nlp.mjs CHANGED
@@ -314,6 +314,16 @@ export function sanitizeFtsQuery(query) {
314
314
  if (bg && !isCjkNoiseBigram(bg) && !matched.has(bg)) expandedTokens.push(bg);
315
315
  }
316
316
  }
317
+ // Preserve embedded Latin/English identifiers glued into the CJK token — e.g. the
318
+ // "redis" in "redis缓存问题". extractCjkKeywords + cjkBigrams only see CJK runs, so
319
+ // without this the Latin anchor (often the single most precise term — a proper noun
320
+ // like redis/grafana/oauth with no CJK synonym) was dropped, zeroing recall on
321
+ // whitespace-free mixed-script prompts. Mirrors registry-retriever.mjs's embedded-
322
+ // English extraction. Lowercased for parity with the write-path unicode61 folding.
323
+ for (const en of (remainder.match(/[a-zA-Z]{2,}/g) || [])) {
324
+ const low = en.toLowerCase();
325
+ if (!FTS_STOP_WORDS.has(low) && !expandedTokens.includes(low)) expandedTokens.push(low);
326
+ }
317
327
  continue;
318
328
  }
319
329
  // No dictionary word matched. For a PURE-CJK run, pushing the whole
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.39.1",
3
+ "version": "3.40.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",
@@ -233,7 +233,7 @@ export async function importFromGitHub(db, url, opts = {}) {
233
233
  // 1. Parse GitHub URL
234
234
  const parsed = parseGitHubUrl(url);
235
235
  if (!parsed) throw new Error('Invalid GitHub URL');
236
- const { owner, repo, branch, path: pathFilter } = parsed;
236
+ const { owner, repo, branch: parsedBranch, path: pathFilter } = parsed;
237
237
 
238
238
  // 2. Fetch repo metadata (stars, forks, updated_at)
239
239
  const repoResp = await fetchFn(buildRepoUrl(owner, repo), { headers });
@@ -247,6 +247,13 @@ export async function importFromGitHub(db, url, opts = {}) {
247
247
  const repoForks = repoMeta.forks_count || 0;
248
248
  const repoUpdatedAt = repoMeta.updated_at || null;
249
249
 
250
+ // parseGitHubUrl defaults branch to 'main' when the URL omits `/tree/<branch>`. Prefer the
251
+ // repo's ACTUAL default branch in that case: a repo defaulting to master/develop/trunk
252
+ // otherwise 404s on a non-existent 'main' (GitHub does not redirect a missing ref), failing
253
+ // a URL that opens fine in the browser. An explicit `/tree/<branch>` in the URL still wins.
254
+ const branchExplicit = /\/tree\//.test(url.split(/[?#]/)[0]);
255
+ const branch = branchExplicit ? parsedBranch : (repoMeta.default_branch || parsedBranch);
256
+
250
257
  // 3. Fetch file tree via GitHub API (recursive)
251
258
  const treeResp = await fetchFn(buildTreeUrl(owner, repo, branch), { headers });
252
259
  if (!treeResp.ok) {
package/registry.mjs CHANGED
@@ -368,9 +368,15 @@ const UPSERT_SQL = `
368
368
  indexed_at, updated_at)
369
369
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
370
370
  ON CONFLICT(type, name) DO UPDATE SET
371
- status=excluded.status, source=excluded.source, repo_url=excluded.repo_url,
371
+ status=excluded.status, source=excluded.source,
372
+ -- Preserve-on-empty (mirror the FTS text columns below): a PARTIAL re-upsert defaults
373
+ -- repo_url/local_path to null/'' in the caller (import is the only edit path). Clobbering
374
+ -- them ORPHANS the resource — mem_use/enrich read local_path (NOT NULL, so '' passes the
375
+ -- constraint and silently breaks reads), and the scanner needs local_path to disable it.
376
+ repo_url=CASE WHEN excluded.repo_url != '' THEN excluded.repo_url ELSE repo_url END,
372
377
  repo_stars=CASE WHEN excluded.repo_stars > 0 THEN excluded.repo_stars ELSE repo_stars END,
373
- local_path=excluded.local_path, file_hash=excluded.file_hash,
378
+ local_path=CASE WHEN excluded.local_path != '' THEN excluded.local_path ELSE local_path END,
379
+ file_hash=excluded.file_hash,
374
380
  invocation_name=CASE WHEN excluded.invocation_name != '' THEN excluded.invocation_name ELSE invocation_name END,
375
381
  -- Preserve-on-empty (mirror repo_stars/invocation_name above): a PARTIAL re-upsert --
376
382
  -- e.g. "registry import --name X --capability-summary ...", where mem-cli defaults every
@@ -164,8 +164,14 @@ export function shouldSkipByDedup(newIds, injectedFile) {
164
164
  if (count >= MAX_SESSION_INJECTIONS) return true;
165
165
  if (!ts || Date.now() - ts > DEDUP_STALE_MS) return false;
166
166
  if (!Array.isArray(prevIds) || prevIds.length === 0) return false;
167
- const prevSet = new Set(prevIds);
168
- const overlapCount = newIds.filter(id => prevSet.has(id)).length;
167
+ // Normalize both sides to strings before comparing: UPS writes obs ids as numbers
168
+ // (rows.map(r => r.id)) while pre-tool-recall's mergeCrossHookInjected writes them as
169
+ // strings (.map(String)), and both hooks SHARE this file. Without normalization
170
+ // Set.has(8829) misses "8829" → cross-hook dedup never fires and the same lesson
171
+ // double-injects within the window. (pre-tool-recall's readCrossHookInjected already
172
+ // String-normalizes; this brings the UPS-side reader in line.)
173
+ const prevSet = new Set(prevIds.map(String));
174
+ const overlapCount = newIds.filter(id => prevSet.has(String(id))).length;
169
175
  return overlapCount / newIds.length >= 0.8;
170
176
  } catch { return false; }
171
177
  }
package/server.mjs CHANGED
@@ -286,9 +286,15 @@ async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
286
286
  return { ...formatSearchOutput([], args, ftsQuery, 0), escalated: false, results: [], total: 0, variants: null };
287
287
  }
288
288
 
289
- // obs_type ⇒ observations-only; deep is observations-only too (deepSearch fuses
290
- // hybrid-obs lists). args.type is the source filter (observations|sessions|prompts).
291
- const effectiveType = deepMode === 'deep' ? 'observations' : (args.type || (args.obs_type ? 'observations' : undefined));
289
+ // obs_type/importance/branch/tier ⇒ observations-only; deep is observations-only too
290
+ // (deepSearch fuses hybrid-obs lists). args.type is the source filter
291
+ // (observations|sessions|prompts). Forcing obs-only for the obs-exclusive fields
292
+ // matches the CLI (mem-cli.mjs:177): session/prompt tables have no importance/branch/tier
293
+ // column, so without the force those legs return UNFILTERED and leak rows that can't be
294
+ // scoped to the filter (branch/importance/tier previously leaked cross-source on MCP).
295
+ const effectiveType = deepMode === 'deep'
296
+ ? 'observations'
297
+ : (args.type || ((args.obs_type || args.importance || args.branch || args.tier) ? 'observations' : undefined));
292
298
 
293
299
  const r = await coreRunSearchPipeline(
294
300
  {
@@ -1402,7 +1408,15 @@ server.registerTool(
1402
1408
  }
1403
1409
  const IMPORT_STRING_FIELDS = ['repo_url', 'local_path', 'invocation_name', 'intent_tags',
1404
1410
  'domain_tags', 'trigger_patterns', 'capability_summary', 'keywords', 'tech_stack', 'use_cases'];
1405
- const fields = { name: args.name, type: args.resource_type, status: 'active', source: args.source || 'user' };
1411
+ // Preserve provenance on a metadata-only re-import (parity with cmdRegistry): default
1412
+ // source to 'user' only for a NEW resource — a partial re-upsert of an existing
1413
+ // github/preinstalled row without `source` must not flip it to 'user'.
1414
+ let source = args.source;
1415
+ if (!source) {
1416
+ const existing = rdb.prepare('SELECT source FROM resources WHERE type = ? AND name = ?').get(args.resource_type, args.name);
1417
+ source = existing ? existing.source : 'user';
1418
+ }
1419
+ const fields = { name: args.name, type: args.resource_type, status: 'active', source };
1406
1420
  for (const f of IMPORT_STRING_FIELDS) fields[f] = args[f] || '';
1407
1421
  const id = upsertResource(rdb, fields);
1408
1422
  return { content: [{ type: 'text', text: `Imported: ${args.resource_type}:${args.name} (id=${id})` }] };
package/source-files.mjs CHANGED
@@ -198,3 +198,16 @@ export const HOOK_SCRIPT_FILES = [
198
198
  // through this wrapper so any partial-install drift heals automatically.
199
199
  'hook-launcher.mjs',
200
200
  ];
201
+
202
+ // The complete set of files the release signature MUST cover: every runtime .mjs
203
+ // (SOURCE_FILES) PLUS the executable hook scripts (copyReleaseIntoStaging installs these
204
+ // into the live dir and they run on every hook fire). HOOK_SCRIPT_FILES were historically
205
+ // NOT in the signed manifest, so an attacker able to PUBLISH a release — but without the
206
+ // signing key — could swap a hook script (e.g. post-tool-use.sh / hook-launcher.mjs) while
207
+ // every SOURCE_FILES hash still matched, and fail-closed verification would still pass →
208
+ // RCE on the next hook fire. Keys are ROOT-relative, matching the extracted-tarball layout
209
+ // that verifyReleaseFiles hashes against.
210
+ export const RELEASE_SIGNED_FILES = [
211
+ ...SOURCE_FILES,
212
+ ...HOOK_SCRIPT_FILES.map(name => `scripts/${name}`),
213
+ ];