claude-mem-lite 3.13.0 → 3.15.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.15.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.15.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/README.md CHANGED
@@ -299,10 +299,15 @@ surface — reach them through the CLI column in the second table.
299
299
 
300
300
  ### Invited Memory (v2.32+)
301
301
 
302
- Opt-in mechanism that installs a single sentinel-wrapped line into the project's
303
- memdir (`~/.claude/projects/<encoded>/memory/MEMORY.md`) so Claude Code loads
304
- the plugin's MCP-tool triggers as **user-memory** — a higher instruction-following
305
- authority than MCP server instructions (which are framed as tool metadata).
302
+ Opt-in mechanism that installs a slug-scoped managed block into the project's
303
+ own `CLAUDE.md` (plus an on-demand detail doc under `.claude/`) so Claude Code
304
+ loads the plugin's MCP-tool triggers as **project instructions** — a higher
305
+ instruction-following authority than MCP server instructions (which are framed
306
+ as tool metadata). Claude Code loads `CLAUDE.md` and the memdir `MEMORY.md` at
307
+ equal weight, so steering lives in `CLAUDE.md` (the canonical home for project
308
+ instructions) rather than polluting `MEMORY.md`, which is reserved for the user's
309
+ own memories. The pre-v3.13 scheme wrote into `MEMORY.md`; it is migrated away
310
+ automatically on the next SessionStart.
306
311
 
307
312
  ```bash
308
313
  claude-mem-lite adopt # install for current project
@@ -317,30 +322,38 @@ claude-mem-lite unadopt # remove sentinel + doc (runtime marker stays
317
322
  Slash commands `/adopt` and `/unadopt` wrap the same CLI.
318
323
 
319
324
  **What adoption changes:**
320
- - A `<!-- claude-mem-lite:begin v1 -->…<!-- claude-mem-lite:end -->` block is
321
- added to `MEMORY.md` under a `## 插件契约` header, containing one ≤150-char
322
- line pointing at `mem_recall` / `mem_save` with their key arguments.
323
- - A `plugin_claude_mem_lite.md` detail file is written (not auto-loaded; read
324
- on demand when the MEMORY.md pointer surfaces in context).
325
+ - A `<!-- claude-mem-lite:begin v1 -->…<!-- claude-mem-lite:end -->` managed
326
+ block is added to `<cwd>/CLAUDE.md` under its own
327
+ `## claude-mem-lite persistent memory` header, containing a compact trigger
328
+ table pointing at `mem_recall` / `mem_save` / `mem_defer` with their key
329
+ arguments. The block is slug-scoped: only this region is managed; the rest of
330
+ your `CLAUDE.md` is preserved verbatim, and it coexists with other plugins'
331
+ blocks (e.g. `code-graph-mcp`) in the same file.
332
+ - A `<cwd>/.claude/plugin_claude_mem_lite.md` detail file is written (not
333
+ auto-loaded; read on demand when the `CLAUDE.md` block points to it). The
334
+ block auto-refreshes when the shipped content drifts (version bump or template
335
+ change), unless `CLAUDE_MEM_NO_TEMPLATE_REFRESH=1`.
325
336
  - Post-adopt the conservative hook layer auto-trims: MCP server instructions
326
337
  drop the `WHEN TO USE` section, SessionStart injection drops the `File Lessons`
327
338
  / `Key Context` sections. `#ID` references and the `Recent` table still fire
328
339
  so `mem_get` remains reachable.
329
340
 
330
341
  **When does it take effect?**
331
- - The MEMORY.md sentinel and the hook-layer trim (`File Lessons` / `Key Context` /
332
- lesson suffix) apply on the **next SessionStart** (any new Claude Code session
333
- in the adopted project).
342
+ - The `CLAUDE.md` managed block and the hook-layer trim (`File Lessons` /
343
+ `Key Context` / lesson suffix) apply on the **next SessionStart** (any new
344
+ Claude Code session in the adopted project).
334
345
  - The MCP server instructions are built once at server boot and MCP has no
335
346
  "push" protocol — the `WHEN TO USE` / `Decision rules` trim only applies
336
347
  after Claude Code restarts and re-spawns the mem-lite MCP server. A single
337
348
  `/exit` + fresh session is enough. Same caveat applies to `unadopt`.
338
349
 
339
350
  **Safety:**
340
- - Hash-guarded: editing the sentinel body yourself blocks automatic rewrites
341
- unless you pass `--force`.
342
- - Budget-gated: refuses to insert when MEMORY.md is already >180 lines, so
343
- Claude Code's 200-line MEMORY.md cap won't truncate the block.
351
+ - Hash-guarded: editing the managed-block body yourself blocks automatic
352
+ rewrites unless you pass `--force`.
353
+ - Slug-scoped & dedup-guarded: only the `claude-mem-lite:begin…end` region is
354
+ ever rewritten, and duplicate / CRLF-orphaned copies are collapsed to one.
355
+ Unlike the legacy `MEMORY.md` scheme there is no line-budget gate — `CLAUDE.md`
356
+ has no truncation cap.
344
357
  - **Auto-adopt fires on the first SessionStart per project for any install
345
358
  path (v2.82.1+).** Per-project opt-out: `claude-mem-lite adopt --disable`
346
359
  (writes a durable `<memdir>/.mem-no-auto-adopt` sentinel that survives marker
package/adopt-cli.mjs CHANGED
@@ -13,9 +13,9 @@
13
13
  // it is redefined as a legacy-cleanup sweep (strip old memory-dir sentinels across
14
14
  // every memdir). New-scheme adoption happens per-project on SessionStart (cwd known).
15
15
 
16
- import { existsSync, readdirSync, statSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
16
+ import { existsSync, readdirSync, statSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
17
17
  import { homedir } from 'os';
18
- import { join } from 'path';
18
+ import { join, isAbsolute } from 'path';
19
19
  import {
20
20
  memdirPath, removePluginSection, removePluginDoc,
21
21
  isAdopted as memdirIsAdopted, hasPluginState,
@@ -54,6 +54,25 @@ function listAllMemdirs() {
54
54
  return out;
55
55
  }
56
56
 
57
+ function claudeConfigPath() { return join(homedir(), '.claude.json'); }
58
+
59
+ // Real adopted-project paths come from Claude Code's own ~/.claude.json `projects`
60
+ // map (keys are absolute cwds Claude Code has opened). The memdir slug under
61
+ // ~/.claude/projects/ is a LOSSY encoding that can't be decoded back to a path,
62
+ // so this is the only source that lets `unadopt --all` reach scattered CLAUDE.md
63
+ // managed blocks. Filtered to absolute, still-existing dirs; claudeMdIsAdopted()
64
+ // then gates which actually carry our block. Caveat: a project Claude Code never
65
+ // recorded is invisible here and needs a per-project `unadopt`.
66
+ function listKnownProjectDirs() {
67
+ const p = claudeConfigPath();
68
+ if (!existsSync(p)) return [];
69
+ try {
70
+ const cfg = JSON.parse(readFileSync(p, 'utf8'));
71
+ const projects = cfg && cfg.projects && typeof cfg.projects === 'object' ? Object.keys(cfg.projects) : [];
72
+ return projects.filter((d) => typeof d === 'string' && isAbsolute(d) && existsSync(d));
73
+ } catch { return []; }
74
+ }
75
+
57
76
  function hasFlag(args, flag) { return Array.isArray(args) && args.includes(flag); }
58
77
 
59
78
  // ─── Per-project auto-adopt opt-out sentinel ─────────────────────────────────
@@ -290,6 +309,12 @@ function statusAll() {
290
309
  log(`[adopt --status] scanned ${dirs.length} memdir(s): ${legacy} with legacy sentinel (await migration), ${disabled} auto-adopt-disabled.`);
291
310
  if (legacy > 0) log('[adopt --status] run `claude-mem-lite adopt --all` to sweep legacy memory-dir sentinels now.');
292
311
 
312
+ const known = listKnownProjectDirs();
313
+ let adoptedCount = 0;
314
+ for (const dir of known) if (claudeMdIsAdopted(dir, PLUGIN_SLUG)) adoptedCount++;
315
+ log(`[adopt --status] known projects (~/.claude.json): ${known.length} scanned, ${adoptedCount} with a CLAUDE.md managed block.`);
316
+ if (adoptedCount > 0) log('[adopt --status] run `claude-mem-lite unadopt --all` to remove every CLAUDE.md block.');
317
+
293
318
  const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ? 'set' : 'unset';
294
319
  const noAutoAdopt = process.env.MEM_NO_AUTO_ADOPT === '1' ? '1 (opt-out)' : 'unset';
295
320
  log('');
@@ -305,6 +330,54 @@ function statusAll() {
305
330
  * memory-dir cleanup across every memdir (CLAUDE.md blocks for other projects
306
331
  * can't be located from the lossy slug). Idempotent: exit code stays 0.
307
332
  */
333
+ /**
334
+ * unadoptAll — `claude-mem-lite unadopt --all`. Removes the CLAUDE.md managed
335
+ * block + detail doc from EVERY adopted project Claude Code knows about (real
336
+ * paths from ~/.claude.json `projects`), then sweeps the legacy memory-dir
337
+ * residue across all memdirs. removeManaged is slug-scoped, so user content and
338
+ * other plugins' blocks are never touched. Honors --dry-run / --force.
339
+ *
340
+ * Unlike `adopt --all` (still a legacy-only sweep — adopting arbitrary projects
341
+ * is unsafe), unadopt is purely subtractive, so reaching every known project is
342
+ * both safe and what the uninstall hint promises.
343
+ */
344
+ function unadoptAll(args) {
345
+ const force = hasFlag(args, '--force');
346
+ const dryRun = hasFlag(args, '--dry-run');
347
+
348
+ // 1. New scheme: scrub CLAUDE.md managed blocks across known project paths.
349
+ const projectDirs = listKnownProjectDirs();
350
+ let blocks = 0;
351
+ for (const dir of projectDirs) {
352
+ if (!claudeMdIsAdopted(dir, PLUGIN_SLUG)) continue;
353
+ if (dryRun) {
354
+ log(`[unadopt --all --dry-run] ${dir} → would-remove CLAUDE.md block + detail doc`);
355
+ blocks++;
356
+ continue;
357
+ }
358
+ const r = removeManaged(dir, PLUGIN_SLUG);
359
+ if (r.action === 'removed') { log(`[unadopt --all] ${dir} → removed`); blocks++; }
360
+ }
361
+
362
+ // 2. Legacy memory-dir cleanup across every memdir (foreign-content guarded).
363
+ const dirs = listAllMemdirs();
364
+ let legacy = 0;
365
+ for (const { memdir } of dirs) {
366
+ if (dryRun) {
367
+ if (memdirIsAdopted(memdir, PLUGIN_SLUG) && (hasPluginState(memdir, PLUGIN_SLUG) || force)) legacy++;
368
+ continue;
369
+ }
370
+ const r = removePluginSection(memdir, PLUGIN_SLUG, { force });
371
+ if (r.action === 'removed') { removePluginDoc(memdir, PLUGIN_SLUG); legacy++; }
372
+ }
373
+
374
+ log('');
375
+ log(`[unadopt --all] ${dryRun ? 'would remove' : 'removed'} ${blocks} CLAUDE.md block(s) across ${projectDirs.length} known project(s); ${legacy} legacy memory-dir sentinel(s) ${dryRun ? 'pending' : 'cleaned'}.`);
376
+ if (projectDirs.length === 0) {
377
+ log('[unadopt --all] no known projects found in ~/.claude.json — if a project was adopted but never opened in Claude Code, run `claude-mem-lite unadopt` from inside it.');
378
+ }
379
+ }
380
+
308
381
  export function cmdUnadopt(args = []) {
309
382
  if (hasFlag(args, '--status')) return statusAll();
310
383
 
@@ -312,7 +385,7 @@ export function cmdUnadopt(args = []) {
312
385
  const dryRun = hasFlag(args, '--dry-run');
313
386
  const force = hasFlag(args, '--force');
314
387
 
315
- if (all) return migrateAll(['--all', ...(force ? ['--force'] : []), ...(dryRun ? ['--dry-run'] : [])]);
388
+ if (all) return unadoptAll(args);
316
389
 
317
390
  const cwd = detectCwd();
318
391
  if (dryRun) {
package/adopt-content.mjs CHANGED
@@ -3,16 +3,21 @@
3
3
  // <cwd>/.claude/plugin_claude_mem_lite.md detail doc. Kept separate from the
4
4
  // claudemd.mjs primitives so the strings are testable without side effects.
5
5
  //
6
- // Bumping CURRENT_SENTINEL_VERSION: pick the next vN. On the next SessionStart
7
- // needsRefresh() sees the version change and refreshes the block in place
8
- // (version bump = intended content change, so it overwrites rather than treating
9
- // the drift as a user edit). v1 = legacy memory-dir sentinel (now migrated away);
10
- // v2 = project-tree CLAUDE.md managed block.
6
+ // CURRENT_SENTINEL_VERSION tags the managed block as `<!-- claude-mem-lite:begin
7
+ // vN -->`. needsRefresh() only checks for *inequality* against the installed tag,
8
+ // so any change triggers an in-place refresh (version change = intended content
9
+ // change, overwritten rather than treated as a user edit) the value need not be
10
+ // monotonic. We deliberately use `v1` (not `v2`) so the version digit differs from
11
+ // the sibling code-graph-mcp plugin's `<!-- code-graph-mcp:begin v2 -->` block in
12
+ // the same CLAUDE.md; the slug already scopes the two independently (claudemd.mjs),
13
+ // so this is a cosmetic distinguisher, not a functional one. The pre-v3.13 legacy
14
+ // memory-dir MEMORY.md sentinel also carried `v1`, but it lives in a different file
15
+ // and is migrated away (claudemd.migrateLegacyMemoryDir), so there is no collision.
11
16
 
12
17
  import { CLI_INVOKE } from './cli-path.mjs';
13
18
 
14
19
  export const PLUGIN_SLUG = 'claude-mem-lite';
15
- export const CURRENT_SENTINEL_VERSION = 'v2';
20
+ export const CURRENT_SENTINEL_VERSION = 'v1';
16
21
 
17
22
  /**
18
23
  * The concise managed block injected into <cwd>/CLAUDE.md (between the
@@ -26,19 +31,19 @@ export function buildClaudeMdBlock() {
26
31
  // resolves to an absolute path that differs per install — it would make this
27
32
  // committed/refreshed block churn across machines). The robust CLI table lives
28
33
  // in the detail doc (.claude/, gitignored).
29
- return `## claude-mem-lite — 持久记忆 (persistent memory)
34
+ return `## claude-mem-lite — persistent memory
30
35
 
31
- PreToolUse hooks Read/Edit/Write 前已自动 \`mem_recall\` 过往教训。下面是值得你主动发起的调用:
36
+ PreToolUse hooks already run \`mem_recall\` for past lessons before Read/Edit/Write. The calls worth making proactively:
32
37
 
33
- | 时机 | 调用 |
38
+ | When | Call |
34
39
  |------|------|
35
- | Edit/Write | hook 已自动 recall;若注入了 \`#NN\` 教训,下次产出用户可见文字时引用 \`#NN\`(引用=采纳反馈,未引用会衰减) |
36
- | 解决非平凡 bug | \`mem_save(type="bugfix", lesson_learned="<根因+修法>", importance=2)\` |
37
- | 非显然架构决策后 | \`mem_save(type="decision", lesson_learned="<约束+取舍>")\` |
38
- | 推迟到下个会话 | \`mem_defer({title, priority:1|2|3, detail})\`;修好时给 \`mem_save\` \`closes_deferred=[N]\` |
39
- | 查过往工作 / 历史 | \`mem_search "关键词"\` · \`mem_recent\` · \`mem_timeline\` |
40
+ | Before Edit/Write | hook already recalled; if a \`#NN\` lesson was injected, cite \`#NN\` next time you produce user-visible text (citing = adopting the feedback; uncited lessons decay) |
41
+ | After fixing a non-trivial bug | \`mem_save(type="bugfix", lesson_learned="<root cause + fix>", importance=2)\` |
42
+ | After a non-obvious architecture decision | \`mem_save(type="decision", lesson_learned="<constraint + tradeoff>")\` |
43
+ | Deferring to a future session | \`mem_defer({title, priority:1|2|3, detail})\`; when fixed, add \`closes_deferred=[N]\` to \`mem_save\` |
44
+ | Looking up past work / history | \`mem_search "keywords"\` · \`mem_recent\` · \`mem_timeline\` |
40
45
 
41
- 完整工具+CLI 表、citation/decay 规则、save 纪律见 → \`.claude/plugin_claude_mem_lite.md\``;
46
+ Full tool + CLI tables, citation/decay rules, and save discipline → \`.claude/plugin_claude_mem_lite.md\``;
42
47
  }
43
48
 
44
49
  /**
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/commands/adopt.md CHANGED
@@ -16,7 +16,7 @@ force it immediately (e.g. after editing the block out by hand).
16
16
 
17
17
  ## What it writes
18
18
 
19
- 1. **`<cwd>/CLAUDE.md`** — a concise `<!-- claude-mem-lite:begin v2 -->…<!-- :end -->`
19
+ 1. **`<cwd>/CLAUDE.md`** — a concise `<!-- claude-mem-lite:begin v1 -->…<!-- :end -->`
20
20
  managed block (trigger table → `mem_recall` / `mem_save` / `mem_defer`).
21
21
  Slug-scoped: only this block is managed; the rest of your `CLAUDE.md` is
22
22
  preserved verbatim. Auto-refreshes when the shipped content drifts.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: unadopt
3
- description: "Use when: user wants to remove the claude-mem-lite steering block from the current project (or sweep legacy memory-dir sentinels everywhere with --all). Removes the <cwd>/CLAUDE.md managed block + <cwd>/.claude/plugin_claude_mem_lite.md and cleans any legacy memory-dir residue. User content outside the sentinel is preserved. Benign no-op when not adopted."
3
+ description: "Use when: user wants to remove the claude-mem-lite steering block from the current project (or from every project Claude Code knows about with --all). Removes the <cwd>/CLAUDE.md managed block + <cwd>/.claude/plugin_claude_mem_lite.md and cleans any legacy memory-dir residue. User content outside the sentinel is preserved. Benign no-op when not adopted."
4
4
  ---
5
5
 
6
6
  # /unadopt
@@ -24,9 +24,11 @@ project (slug-scoped — other plugins' blocks survive).
24
24
 
25
25
  - `--force` — also remove a legacy memory-dir block lacking a state sidecar
26
26
  - `--dry-run` — preview what would be removed; no writes
27
- - `--all` — legacy-cleanup sweep: strip the old memory-dir sentinel across every
28
- project (CLAUDE.md blocks for other projects can't be located from the encoded
29
- slug `cd` into a project and run `/unadopt` there to remove its block).
27
+ - `--all` — remove the CLAUDE.md managed block from every project in Claude
28
+ Code's known-project list (`~/.claude.json` `projects`), plus sweep any legacy
29
+ memory-dir sentinels. Slug-scoped, so user content and other plugins' blocks
30
+ survive. A project Claude Code never opened isn't listed — `cd` into it and run
31
+ `/unadopt` there. Pair with `--dry-run` to preview.
30
32
  - `--status` — read-only adoption probe (mirrors `/adopt --status`)
31
33
 
32
34
  ## Note: this does not stop auto-adopt
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'); }
package/install.mjs CHANGED
@@ -1116,8 +1116,15 @@ async function uninstall() {
1116
1116
  cleanupMemHooksFromSettings(settings);
1117
1117
 
1118
1118
  // 2b. Uninstall does NOT auto-unadopt — an adopted project may be in active use
1119
- // by the user in other Claude Code sessions. Tell them the command instead.
1120
- log('Invited-memory: adopt state preserved. Run `claude-mem-lite unadopt --all` to remove sentinel sections.');
1119
+ // in other Claude Code sessions, and adoption lives in EACH project's own
1120
+ // CLAUDE.md. `unadopt --all` now strips every block across the projects Claude
1121
+ // Code knows about (~/.claude.json), so point at it — but note the timing: a
1122
+ // --purge run removes the CLI symlink, so this is best done BEFORE uninstall.
1123
+ log('Invited-memory: project adoption left in place (each adopted project keeps its');
1124
+ log(' CLAUDE.md managed block + .claude/plugin_claude_mem_lite.md). To remove it from');
1125
+ log(' every known project, run `claude-mem-lite unadopt --all` — best done BEFORE');
1126
+ log(' uninstall, while the CLI is still on PATH. A project Claude Code never opened');
1127
+ log(' is not in the known list — run `claude-mem-lite unadopt` from inside it.');
1121
1128
 
1122
1129
  // 3. Clean plugin registry entries conservatively (avoid deleting other plugins
1123
1130
  // from the same marketplace publisher)
@@ -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.15.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`;