claude-mem-lite 3.13.0 → 3.14.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.13.0",
13
+ "version": "3.14.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.13.0",
3
+ "version": "3.14.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/bash-utils.mjs CHANGED
@@ -76,7 +76,14 @@ export function detectBashSignificance(input, response) {
76
76
  // Allow intervening global git options (`-C <path>`, `-c k=v`, `--no-pager`, …) between
77
77
  // `git` and the subcommand — `git -C /repo push` is the standard multi-repo/scripted form.
78
78
  const isGit = /\bgit\s+(?:(?:-[cC]\s+\S+|--?[\w-]+(?:=\S+)?)\s+)*(commit|merge|rebase|cherry-pick|push)\b/i.test(cmd);
79
- const isDeploy = /\b(deploy|docker|kubectl|terraform)\b/i.test(cmd);
79
+ // Deploy + publish/release: the actual "ship". Package publish and GitHub
80
+ // release are rare, high-value events; without them a release session records
81
+ // the git push but not the publish that defines it. `npm run publish-*` is
82
+ // excluded (custom script, ambiguous) — only the direct publish verb counts.
83
+ const isDeploy = /\b(deploy|docker|kubectl|terraform)\b/i.test(cmd)
84
+ || /\b(?:npm|pnpm|yarn|bun|cargo)\s+publish\b/i.test(cmd)
85
+ || /\bgh\s+release\s+(?:create|edit|upload|delete)\b/i.test(cmd)
86
+ || /\btwine\s+upload\b/i.test(cmd);
80
87
  return {
81
88
  isError, isTest, isBuild, isGit, isDeploy,
82
89
  isSignificant: isError || isTest || isBuild || isGit || isDeploy,
package/hook-memory.mjs CHANGED
@@ -131,7 +131,23 @@ function hasFilePaths(filesModified) {
131
131
  * @returns {object[]} Top memories (max 3) with {id, type, title, lesson_learned}
132
132
  */
133
133
  export function searchRelevantMemories(db, userPrompt, project, excludeIds = []) {
134
- if (!db || !userPrompt || userPrompt.length < 5) return [];
134
+ // Min-length guard is English-centric: 5 chars one short English word. A CJK
135
+ // query is meaningful at 2 chars (状态/架构) and most real Chinese queries are
136
+ // 2-4 chars (状态管理, 召回率, 熔断降级) — the bare `.length < 5` silently
137
+ // rejected ALL of them, so a Chinese-primary user got zero memory injection.
138
+ // Apply the 5-char floor only to non-CJK queries; CJK needs ≥2.
139
+ if (!db || !userPrompt) return [];
140
+ const queryHasCjk = /[一-鿿㐀-䶿]/.test(userPrompt);
141
+ if (userPrompt.length < (queryHasCjk ? 2 : 5)) return [];
142
+ // CJK-DOMINANT (not merely CJK-containing) gates the OR-fallback bypass below.
143
+ // A substring test would let one incidental CJK char — an IME-leaked particle,
144
+ // a 中文 noun in an otherwise-English prompt — flip OR-fallback on and inject
145
+ // off-topic noise (a real precision regression for bilingual users). Require
146
+ // CJK chars to be at least as many as ASCII letters so only genuinely-Chinese
147
+ // queries (优化召回率) get the bigram-inflation rescue, not "fix the bug 啊".
148
+ const _cjkChars = (userPrompt.match(/[一-鿿㐀-䶿]/g) || []).length;
149
+ const _asciiLetters = (userPrompt.match(/[A-Za-z]/g) || []).length;
150
+ const queryIsCjkDominant = _cjkChars > 0 && _cjkChars >= _asciiLetters;
135
151
 
136
152
  // v2.41 metrics: record timing + candidate/filter/return counts per call.
137
153
  // Gated by CLAUDE_MEM_METRICS=1 — no-op when disabled (zero hot-path cost).
@@ -192,9 +208,16 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
192
208
  const queryTokenCount = ftsQuery.includes(' AND ')
193
209
  ? ftsQuery.split(' AND ').length
194
210
  : ftsQuery.split(/\s+/).filter(t => t && !t.startsWith('(') || !t.endsWith(')')).length;
211
+ // CJK-dominant queries bypass the token-count gate: a single CJK word becomes
212
+ // 2-N overlapping bigrams (优化召回率 → 优化/召回/回率), inflating
213
+ // queryTokenCount past the gate, so the AND-too-strict query never gets the OR
214
+ // rescue that CJK retrieval relies on. The CLI/hybrid path relaxes CJK to OR
215
+ // unconditionally; mirror that here. Noise is contained downstream (0.4x OR
216
+ // penalty + BM25 threshold + term-coverage filter). queryIsCjkDominant (not
217
+ // mere CJK presence) is the gate — see its definition at the function top.
195
218
  if (rows.length === 0) {
196
219
  const orQuery = relaxFtsQueryToOr(ftsQuery);
197
- if (orQuery && queryTokenCount <= OR_FALLBACK_MAX_TOKENS) {
220
+ if (orQuery && (queryIsCjkDominant || queryTokenCount <= OR_FALLBACK_MAX_TOKENS)) {
198
221
  try { rows = selectStmt.all(orQuery, project, cutoff); usedOrFallback = true; } catch {}
199
222
  }
200
223
  }
@@ -225,7 +248,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
225
248
  crossRows = crossStmt.all(ftsQuery, project, cutoff);
226
249
  if (crossRows.length === 0) {
227
250
  const orQuery = relaxFtsQueryToOr(ftsQuery);
228
- if (orQuery && queryTokenCount <= OR_FALLBACK_MAX_TOKENS) {
251
+ if (orQuery && (queryIsCjkDominant || queryTokenCount <= OR_FALLBACK_MAX_TOKENS)) {
229
252
  try { crossRows = crossStmt.all(orQuery, project, cutoff); crossUsedOr = true; } catch {}
230
253
  }
231
254
  }
package/hook.mjs CHANGED
@@ -1188,8 +1188,13 @@ async function handleSessionStart() {
1188
1188
  // project that the `### Deferred Work` block now reads from the
1189
1189
  // deferred_work table (was: high-importance observations in v2.69.x).
1190
1190
  // Idempotent via marker file; subsequent SessionStarts are silent.
1191
- try { emitV270UpgradeBanner({ project, runtimeDir: RUNTIME_DIR }); }
1192
- catch (e) { debugCatch(e, 'session-start-v270-banner'); }
1191
+ try {
1192
+ // Gate on prior data: a brand-new install never had v2.69.x deferred-block
1193
+ // semantics, so the migration notice is wrong noise (it fired for every
1194
+ // fresh install since v2.70). Only genuine upgraders with observations see it.
1195
+ const obsCount = db.prepare('SELECT COUNT(*) AS c FROM observations WHERE project = ?').get(project)?.c || 0;
1196
+ emitV270UpgradeBanner({ project, runtimeDir: RUNTIME_DIR, hasPriorData: obsCount > 0 });
1197
+ } catch (e) { debugCatch(e, 'session-start-v270-banner'); }
1193
1198
 
1194
1199
  // Pre-load TF-IDF vocabulary cache for this session (from DB, ~1ms)
1195
1200
  try { getVocabulary(db); } catch (e) { debugCatch(e, 'session-start-vocab'); }
@@ -17,15 +17,26 @@ import { join } from 'path';
17
17
  * @param {object} args
18
18
  * @param {string} args.project Project name (used in banner + marker filename).
19
19
  * @param {string} args.runtimeDir RUNTIME_DIR (test override; production passes hook-shared.RUNTIME_DIR).
20
+ * @param {boolean} [args.hasPriorData=true] Whether the project has pre-existing
21
+ * observations. A fresh install never had the v2.69.x deferred-block semantics,
22
+ * so the migration notice (and its "pin to 2.69.x" advice, nonsensical 40+
23
+ * versions on) is wrong noise — suppress it but still write the marker so it
24
+ * stays suppressed once the new user starts saving memories. Defaults to true
25
+ * for backward-compatibility with callers that don't probe.
20
26
  */
21
- export function emitV270UpgradeBanner({ project, runtimeDir }) {
27
+ export function emitV270UpgradeBanner({ project, runtimeDir, hasPriorData = true }) {
22
28
  const marker = join(runtimeDir, `.deferred-block-migrated-${project}`);
23
29
  if (existsSync(marker)) return;
24
- process.stderr.write(
25
- `[mem] v2.70.0 upgrade notice (project "${project}"): Deferred Work block now ` +
26
- `backed by deferred_work table. To keep an obs visible there, run ` +
27
- `\`claude-mem-lite defer add "<title>" --priority 3\`. ` +
28
- `Pin to 2.69.x to revert.\n`
29
- );
30
+ // The banner only matters to someone who had high-importance observations
31
+ // under the old deferred-block semantics. No prior data nothing migrated →
32
+ // suppress (but mark, so a brand-new user never sees it later either).
33
+ if (hasPriorData) {
34
+ process.stderr.write(
35
+ `[mem] v2.70.0 upgrade notice (project "${project}"): Deferred Work block now ` +
36
+ `backed by deferred_work table. To keep an obs visible there, run ` +
37
+ `\`claude-mem-lite defer add "<title>" --priority 3\`. ` +
38
+ `Pin to 2.69.x to revert.\n`
39
+ );
40
+ }
30
41
  try { writeFileSync(marker, String(Date.now())); } catch { /* best-effort marker */ }
31
42
  }
package/nlp.mjs CHANGED
@@ -222,7 +222,19 @@ export function expandToken(token) {
222
222
 
223
223
  // ─── Stop Words ──────────────────────────────────────────────────────────────
224
224
 
225
- export const FTS_STOP_WORDS = new Set([...BASE_STOP_WORDS]);
225
+ // Orphan stems left when apostrophe-splitting contractions ("I've"→"ve",
226
+ // "wouldn't"→"wouldn"). Non-words only — they'd otherwise survive as required
227
+ // AND terms and silently shrink recall. Ambiguous real words are intentionally
228
+ // excluded so legitimate queries for them still work: don/won/haven/can, and
229
+ // 're' ("re:"/regarding) — even though that leaves the they're/we're artifact,
230
+ // dropping a real word is the worse failure.
231
+ const CONTRACTION_FRAGMENTS = [
232
+ 've', 'll',
233
+ 'doesn', 'didn', 'isn', 'wasn', 'aren', 'weren',
234
+ 'wouldn', 'couldn', 'shouldn', 'hasn', 'hadn', 'mustn', 'needn', 'mightn',
235
+ ];
236
+
237
+ export const FTS_STOP_WORDS = new Set([...BASE_STOP_WORDS, ...CONTRACTION_FRAGMENTS]);
226
238
 
227
239
  // ─── FTS5 Query Sanitization ─────────────────────────────────────────────────
228
240
 
@@ -241,15 +253,30 @@ export function sanitizeFtsQuery(query) {
241
253
  // "never throws on MATCH" invariant. The metachar class below doesn't cover them.
242
254
  // eslint-disable-next-line no-control-regex -- intentional: stripping control chars IS the fix
243
255
  .replace(/[\x00-\x1f\x7f]/g, ' ')
256
+ // Apostrophe variants → space, matching FTS5 unicode61's own tokenization
257
+ // (it splits on apostrophe: "doesn't" is indexed as "doesn"+"t"). Without
258
+ // this, a possessive/contraction ("sister's", "What's") would phrase-quote
259
+ // to "sister s" (adjacency) and miss the bare-word mention ("my sister") in
260
+ // the doc, and contraction stems never reach the stopword filter. Straight
261
+ // ('), curly (' '), and modifier (ʼ) apostrophes all normalized.
262
+ .replace(/['‘’ʼ]/g, ' ')
244
263
  .replace(/[{}()[\]^~*:"\\]/g, ' ')
245
264
  .replace(/(^|\s)-/g, '$1')
246
265
  .trim();
247
266
  if (!cleaned) return null;
248
- let tokens = cleaned.split(/\s+/).filter(t =>
249
- t && !/^-+$/.test(t) && !FTS5_KEYWORDS.has(t.toUpperCase()) && !/^NEAR(\/\d*)?$/i.test(t)
250
- // Skip single ASCII-letter tokens too noisy for FTS5 (CJK single chars handled separately below)
251
- && !(t.length === 1 && /^[a-zA-Z]$/.test(t))
252
- );
267
+ let tokens = cleaned.split(/\s+/)
268
+ // Trim leading/trailing sentence punctuation (. , ? ! ; …) from each token.
269
+ // Natural-language queries end in "?" and clauses in ",": left on, the final
270
+ // (most salient) token rides as "bug?"/"month," which (a) misses the synonym
271
+ // map → no OR-expansion, and (b) gets phrase-quoted as a literal by
272
+ // expandToken. Edges only — internal dots/hyphens (cli.mjs, gardening-related)
273
+ // are preserved so filenames/compounds still phrase-match.
274
+ .map(t => t.replace(/^[.,;:!?]+|[.,;:!?]+$/g, ''))
275
+ .filter(t =>
276
+ t && !/^-+$/.test(t) && !FTS5_KEYWORDS.has(t.toUpperCase()) && !/^NEAR(\/\d*)?$/i.test(t)
277
+ // Skip single ASCII-letter tokens — too noisy for FTS5 (CJK single chars handled separately below)
278
+ && !(t.length === 1 && /^[a-zA-Z]$/.test(t))
279
+ );
253
280
  // Filter stop words (but keep all if filtering would empty the query)
254
281
  const filtered = tokens.filter(t => !FTS_STOP_WORDS.has(t.toLowerCase()));
255
282
  if (filtered.length > 0) tokens = filtered;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.13.0",
3
+ "version": "3.14.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",
package/tier.mjs CHANGED
@@ -110,7 +110,12 @@ export function tierSqlParams(ctx) {
110
110
  * @returns {string}
111
111
  */
112
112
  export function relativeTime(epoch, now) {
113
- const diff = now - epoch;
113
+ // Clamp negative diffs: a future / clock-skewed epoch must not render as a
114
+ // negative duration ("-7200s ago"). The first branch below (diff < 60000)
115
+ // would otherwise fire on any negative diff and print Math.floor(diff/1000),
116
+ // i.e. "-7200s ago" for a 2h-future timestamp. (cli/common.mjs's sibling
117
+ // relativeTime handles this via an early `diff < 0` → "just now".)
118
+ const diff = Math.max(0, now - epoch);
114
119
  if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
115
120
  if (diff < 3600000) return `${Math.floor(diff / 60000)}min ago`;
116
121
  if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;