claude-mem-lite 2.81.0 → 2.83.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": "2.81.0",
13
+ "version": "2.83.0",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.81.0",
3
+ "version": "2.83.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -138,7 +138,7 @@ The original sends **everything to the LLM and hopes it filters well**. claude-m
138
138
 
139
139
  Plugin mode manages its own hooks/runtime. On session start it only **checks and reports** new claude-mem-lite versions; it does **not** self-overwrite plugin files in place. Update plugin-mode installs through Claude's plugin workflow.
140
140
 
141
- > **First session per project: run `/adopt` once.** Plugin install gives you the MCP server + hooks + slash commands, but the **invited-memory sentinel** (a system-authority pointer that boosts Claude's proactive use of `mem_recall` / `mem_save`) is opt-in and project-scoped. Without it the hooks still record observations and inject context, but Claude is far less likely to call the MCP tools on its own. One-time per project: `/adopt`. Remove with `/unadopt`.
141
+ > **Auto-adopt fires on the first SessionStart per project (v2.82.1+).** The plugin automatically writes the **invited-memory sentinel** (a system-authority pointer that boosts Claude's proactive use of `mem_recall` / `mem_save`) into the project's memdir **no manual `/adopt` needed**, regardless of install path (npm, npx, `/plugin`, manual). Per-project opt-out: `claude-mem-lite adopt --disable` (`--enable` to re-arm). Global opt-out: `export MEM_NO_AUTO_ADOPT=1`. Manual `/adopt` remains available for re-applying after edits and for the `--all` batch path.
142
142
 
143
143
  ### Method 2: npx (one-liner)
144
144
 
@@ -281,9 +281,11 @@ authority than MCP server instructions (which are framed as tool metadata).
281
281
  ```bash
282
282
  claude-mem-lite adopt # install for current project
283
283
  claude-mem-lite adopt --all # install for every project under ~/.claude/projects/
284
- claude-mem-lite adopt --status # list adopted projects + sentinel versions
284
+ claude-mem-lite adopt --status # list adopted/disabled projects + current gating snapshot
285
285
  claude-mem-lite adopt --dry-run # preview without writing
286
- claude-mem-lite unadopt # remove cleanly
286
+ claude-mem-lite adopt --disable # opt out of auto-adopt for current project (writes .mem-no-auto-adopt sentinel)
287
+ claude-mem-lite adopt --enable # re-arm auto-adopt for current project (deletes the sentinel)
288
+ claude-mem-lite unadopt # remove sentinel + doc (runtime marker stays to honor the explicit removal)
287
289
  ```
288
290
 
289
291
  Slash commands `/adopt` and `/unadopt` wrap the same CLI.
@@ -313,8 +315,12 @@ Slash commands `/adopt` and `/unadopt` wrap the same CLI.
313
315
  unless you pass `--force`.
314
316
  - Budget-gated: refuses to insert when MEMORY.md is already >180 lines, so
315
317
  Claude Code's 200-line MEMORY.md cap won't truncate the block.
316
- - `install` only auto-adopts when run from the claude-mem-lite source repo
317
- itself (detected via git remote match); other users must opt in explicitly.
318
+ - **Auto-adopt fires on the first SessionStart per project for any install
319
+ path (v2.82.1+).** Per-project opt-out: `claude-mem-lite adopt --disable`
320
+ (writes a durable `<memdir>/.mem-no-auto-adopt` sentinel that survives marker
321
+ deletion / plugin reinstalls). Global opt-out: `MEM_NO_AUTO_ADOPT=1`.
322
+ Pre-v2.82.1 the `CLAUDE_PLUGIN_ROOT` gate left auto-adopt unreachable for
323
+ every `install.mjs`-written hook (the common path) — see CHANGELOG v2.82.1.
318
324
  - The fallback hook layer is never removed from source — conditional trim is
319
325
  runtime-gated on sentinel presence, so projects without adoption get the
320
326
  full verbose output.
@@ -614,8 +620,9 @@ npm run benchmark:gate # CI gate: fails if metrics regress beyond 5% toleranc
614
620
  | `CLAUDE_MEM_DIR` | Custom data directory. All databases, runtime files, and managed resources are stored here. | `~/.claude-mem-lite/` |
615
621
  | `CLAUDE_MEM_MODEL` | LLM model for background calls (episode extraction, session summaries). Accepts `haiku` or `sonnet`. | `haiku` |
616
622
  | `CLAUDE_MEM_DEBUG` | Enable debug logging (`1` to enable). | _(disabled)_ |
617
- | `MEM_QUIET_HOOKS` | Low-noise hooks. `1` drops the `File Lessons` / `Key Context` sections from SessionStart injection, the lesson suffix from `[mem] Related memories`, and the `WHEN TO USE` / `Decision rules` blocks from MCP server instructions. IDs and the `Recent` table still surface so `mem_get(ids=[…])` remains reachable. Intended for users running the invited-memory adopt path or who otherwise want minimal auto-injection. | _(disabled)_ |
618
- | `MEM_NO_ADOPT_HINT` | Silences the one-line "Invited-memory 未启用:`claude-mem-lite adopt`…" hint that SessionStart appends when the current project hasn't been adopted. The hint self-clears once `adopt` runs; set this env to suppress it without adopting. | _(disabled)_ |
623
+ | `MEM_QUIET_HOOKS` | Low-noise hooks. `1` drops the `File Lessons` / `Key Context` sections from SessionStart injection, the lesson suffix from `[mem] Related memories`, and the `WHEN TO USE` / `Decision rules` blocks from MCP server instructions. IDs and the `Recent` table still surface so `mem_get(ids=[…])` remains reachable. Intended for users running the invited-memory adopt path or who otherwise want minimal auto-injection. **Since v2.82.0 this env no longer gates auto-adopt — use `MEM_NO_AUTO_ADOPT=1` for that.** | _(disabled)_ |
624
+ | `MEM_NO_AUTO_ADOPT` | Global opt-out for auto-adopt (v2.82.0+). `1` prevents the first-SessionStart auto-write of the invited-memory sentinel across **all** projects. For per-project opt-out use `claude-mem-lite adopt --disable` instead (writes a durable `<memdir>/.mem-no-auto-adopt` sentinel that survives marker deletion). | _(disabled)_ |
625
+ | `MEM_NO_ADOPT_HINT` | Silences the one-line "Invited-memory 未启用:`claude-mem-lite adopt`…" hint that SessionStart appends when the current project hasn't been adopted. Since v2.82.1 auto-adopt fires on first SessionStart for any install path, so this hint typically surfaces only when you've explicitly opted out (`MEM_NO_AUTO_ADOPT=1` or `claude-mem-lite adopt --disable`). | _(disabled)_ |
619
626
 
620
627
  ## License
621
628
 
package/README.zh-CN.md CHANGED
@@ -148,7 +148,7 @@ node install.mjs install
148
148
  1. **安装依赖** -- `npm install --omit=dev`(编译原生 `better-sqlite3`)
149
149
  2. **注册 MCP 服务器** -- `mem-lite` 服务器,包含 20 个工具(9 个核心通过 `tools/list` 暴露 + 11 个隐藏但可调;完整表见 Usage 段)。v2.78 前服务器名为通用的 `mem`,现已改名为 `mem-lite` 避免与用户其它 `.mcp.json` 冲突;工具名(`mem_search`/`mem_recall` 等)保持不变。
150
150
 
151
- > **每个项目第一次使用,跑一次 `/adopt`。** Plugin 安装给你 MCP server + hooks + slash commands,但**邀请式 memory 哨兵**(一条提升 Claude 主动调用 `mem_recall` / `mem_save` 的 system-authority 指针)是按项目 opt-in 的。不跑 `/adopt` hooks 仍记录观察、注入上下文,但 Claude 不太会主动调 MCP 工具。一次性、按项目:`/adopt`;撤销 `/unadopt`。
151
+ > **首次 SessionStart 自动 adopt(v2.82.1+)。** 插件会自动向该项目 memdir 写入**邀请式 memory 哨兵**(一条提升 Claude 主动调用 `mem_recall` / `mem_save` 的 system-authority 指针),**任何安装路径都生效**(npm、npx、`/plugin`、手动),**无需再手动跑 `/adopt`**。项目级关闭:`claude-mem-lite adopt --disable`(重新启用用 `--enable`)。全局关闭:`export MEM_NO_AUTO_ADOPT=1`。手动 `/adopt` 仍保留用于编辑后重写或 `--all` 批量场景。
152
152
  3. **配置钩子** -- `PostToolUse`、`PreToolUse`、`SessionStart`、`Stop`、`UserPromptSubmit` 生命周期钩子
153
153
  4. **创建数据目录** -- `~/.claude-mem-lite/`(隐藏目录),存放数据库、运行时和托管资源文件
154
154
  5. **自动迁移** -- 自动检测 `~/.claude-mem/`(原版 claude-mem)或 `~/claude-mem-lite/`(v0.5 前的非隐藏目录),将数据库和运行时文件迁移到 `~/.claude-mem-lite/`,原目录保持不变
@@ -263,9 +263,11 @@ instruction-following 权威。
263
263
  ```bash
264
264
  claude-mem-lite adopt # 当前项目注入
265
265
  claude-mem-lite adopt --all # 扫描 ~/.claude/projects/* 全部注入
266
- claude-mem-lite adopt --status # 列出所有已 adopt 的项目 + 版本号
266
+ claude-mem-lite adopt --status # 列出已 adopt / 已禁用项目 + 当前 gate 快照
267
267
  claude-mem-lite adopt --dry-run # 只打印不写入
268
- claude-mem-lite unadopt # 精确移除
268
+ claude-mem-lite adopt --disable # 当前项目关闭 auto-adopt(写 .mem-no-auto-adopt 哨兵)
269
+ claude-mem-lite adopt --enable # 当前项目重新启用 auto-adopt(删哨兵)
270
+ claude-mem-lite unadopt # 精确移除 sentinel + 详情文档(runtime marker 保留以尊重显式撤销)
269
271
  ```
270
272
 
271
273
  Slash 命令 `/adopt` 和 `/unadopt` 是上述 CLI 的包装。
@@ -291,8 +293,11 @@ Slash 命令 `/adopt` 和 `/unadopt` 是上述 CLI 的包装。
291
293
  - Hash 守护:你手动改了 sentinel 段 → 下一次 adopt 报 `UserEditedError`,
292
294
  除非显式 `--force`。
293
295
  - 预算门:MEMORY.md 已 >180 行时拒绝新增(避开 Claude Code 200 行截断)。
294
- - `install` 只在从 claude-mem-lite 源码仓库运行时 auto-adopt(靠 git remote 判别);
295
- 其它用户需显式调用。
296
+ - **任何安装路径首次 SessionStart 自动 adopt(v2.82.1+)。** 项目级关闭:
297
+ `claude-mem-lite adopt --disable`(写 `<memdir>/.mem-no-auto-adopt` 哨兵,
298
+ 存活于 marker 删除 / 插件重装)。全局关闭:`export MEM_NO_AUTO_ADOPT=1`。
299
+ v2.82.1 前因 `CLAUDE_PLUGIN_ROOT` gate 与 `install.mjs` 写出的 hook 命令
300
+ 不匹配,auto-adopt 实质 5 周零触发——见 CHANGELOG v2.82.1。
296
301
  - 保守 hook 层源码永不删——条件瘦身仅基于 sentinel 存在性做 runtime 判断,
297
302
  未 adopt 的项目仍看完整 verbose 输出。
298
303
 
@@ -575,8 +580,9 @@ npm run benchmark:gate # CI 门控:指标回退超过 5% 容差时失败
575
580
  | `CLAUDE_MEM_DIR` | 自定义数据目录。所有数据库、运行时文件和托管资源均存储在此。 | `~/.claude-mem-lite/` |
576
581
  | `CLAUDE_MEM_MODEL` | 后台 LLM 调用模型(Episode 提取、会话总结、调度)。可选 `haiku` 或 `sonnet`。 | `haiku` |
577
582
  | `CLAUDE_MEM_DEBUG` | 启用调试日志(设为 `1` 启用)。 | _(禁用)_ |
578
- | `MEM_QUIET_HOOKS` | 低噪声 hook。设为 `1` 时,SessionStart 注入去掉 `File Lessons` / `Key Context` 两节,`[mem] Related memories` 去掉 lesson 后缀,MCP server instructions 去掉 `WHEN TO USE` / `Decision rules` 两段。ID 与 `Recent` 表仍保留,`mem_get(ids=[…])` 可继续展开细节。适用于启用了 invited-memory adopt 流程或偏好最小化自动注入的用户。 | _(禁用)_ |
579
- | `MEM_NO_ADOPT_HINT` | 静音当前项目未 adopt SessionStart 追加的那一行 "Invited-memory 未启用…" 提示。一旦运行 `adopt` 该提示自动消失;此 env 让偏好保守层的用户在不 adopt 的情况下也能静音。 | _(禁用)_ |
583
+ | `MEM_QUIET_HOOKS` | 低噪声 hook。设为 `1` 时,SessionStart 注入去掉 `File Lessons` / `Key Context` 两节,`[mem] Related memories` 去掉 lesson 后缀,MCP server instructions 去掉 `WHEN TO USE` / `Decision rules` 两段。ID 与 `Recent` 表仍保留,`mem_get(ids=[…])` 可继续展开细节。适用于启用了 invited-memory adopt 流程或偏好最小化自动注入的用户。**v2.82.0 起此 env 不再阻挡 auto-adopt——如需关闭 auto-adopt 用 `MEM_NO_AUTO_ADOPT=1`。** | _(禁用)_ |
584
+ | `MEM_NO_AUTO_ADOPT` | auto-adopt 全局关闭开关(v2.82.0+)。设为 `1` 阻止首次 SessionStart 在**所有**项目自动写入邀请式 memory 哨兵。项目级关闭走 `claude-mem-lite adopt --disable`(写 `<memdir>/.mem-no-auto-adopt` 哨兵,存活于 marker 删除)。 | _(禁用)_ |
585
+ | `MEM_NO_ADOPT_HINT` | 静音当前项目未 adopt 时 SessionStart 追加的那一行 "Invited-memory 未启用…" 提示。v2.82.1 起任何安装路径首次 SessionStart 都自动 adopt,所以该提示一般只在你显式 opt out(`MEM_NO_AUTO_ADOPT=1` 或 `claude-mem-lite adopt --disable`)的项目才会出现。 | _(禁用)_ |
580
586
 
581
587
  ## 许可证
582
588
 
package/adopt-cli.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  // --dry-run = print intent without writing
10
10
  // --status = list all adopted projects + versions
11
11
 
12
- import { existsSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'fs';
12
+ import { existsSync, readdirSync, statSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
13
13
  import { homedir } from 'os';
14
14
  import { join } from 'path';
15
15
  import {
@@ -49,6 +49,24 @@ function listAllMemdirs() {
49
49
 
50
50
  function hasFlag(args, flag) { return Array.isArray(args) && args.includes(flag); }
51
51
 
52
+ // ─── Per-project auto-adopt opt-out sentinel ─────────────────────────────────
53
+ // `<memdir>/.mem-no-auto-adopt` is the durable, project-scoped escape hatch.
54
+ // Survives marker deletion, sentinel removal, and plugin reinstalls — that's
55
+ // the point: "user said no for this project" should not be reversible by
56
+ // `rm ~/.claude-mem-lite/runtime/.auto-adopt-*`. Managed via
57
+ // `claude-mem-lite adopt --disable` / `--enable`. silentAutoAdopt checks it
58
+ // at entry and skips WITHOUT writing the runtime marker, so toggling
59
+ // `--enable` re-arms auto-adopt on the next SessionStart.
60
+ const DISABLE_SENTINEL_BASENAME = '.mem-no-auto-adopt';
61
+
62
+ export function disableSentinelPath(memdir) {
63
+ return join(memdir, DISABLE_SENTINEL_BASENAME);
64
+ }
65
+
66
+ export function isAutoAdoptDisabled(memdir) {
67
+ return existsSync(disableSentinelPath(memdir));
68
+ }
69
+
52
70
  /**
53
71
  * cmdAdopt — write sentinel section + plugin doc to memdir.
54
72
  * Exit code 1 on any hard failure; skipped (--all + UserEditedError) doesn't
@@ -56,6 +74,8 @@ function hasFlag(args, flag) { return Array.isArray(args) && args.includes(flag)
56
74
  */
57
75
  export function cmdAdopt(args = []) {
58
76
  if (hasFlag(args, '--status')) return statusAll();
77
+ if (hasFlag(args, '--disable')) return cmdDisable(args);
78
+ if (hasFlag(args, '--enable')) return cmdEnable(args);
59
79
 
60
80
  const all = hasFlag(args, '--all');
61
81
  const force = hasFlag(args, '--force');
@@ -122,14 +142,18 @@ function adoptOne(memdir, { force, dryRun, all }) {
122
142
  }
123
143
 
124
144
  /**
125
- * silentAutoAdopt — plugin-mode first-run auto-adopt helper (v2.33.0).
145
+ * silentAutoAdopt — plugin-mode first-run auto-adopt helper (v2.33.0+).
126
146
  *
127
147
  * Preconditions (caller must gate): CLAUDE_PLUGIN_ROOT set, MEM_NO_AUTO_ADOPT!=1,
128
- * MEM_QUIET_HOOKS!=1, first-attempt marker absent. This helper does NOT re-check
129
- * those — it only does the write + marker persistence.
148
+ * first-attempt marker absent. This helper does NOT re-check those — it only
149
+ * does the write + marker persistence. (v2.82.0: dropped MEM_QUIET_HOOKS gate;
150
+ * quiet is a stdout control, not a side-effect control.)
130
151
  *
131
152
  * Behavior:
132
- * - Writes plugin sentinel + detail doc to the memdir for `cwd`.
153
+ * - If `<memdir>/.mem-no-auto-adopt` exists: skip silently, do NOT write the
154
+ * runtime marker. This keeps `--enable` re-armable: deleting the disable
155
+ * sentinel lets the next SessionStart try again.
156
+ * - Else: writes plugin sentinel + detail doc to the memdir for `cwd`.
133
157
  * - Writes a per-project first-attempt marker under `markerDir` so a later
134
158
  * `/unadopt` is respected (no re-adopt loop).
135
159
  * - Silent: never logs, never throws. Returns structured result.
@@ -139,6 +163,9 @@ function adoptOne(memdir, { force, dryRun, all }) {
139
163
  export function silentAutoAdopt({ cwd, markerDir, markerKey }) {
140
164
  const memdir = memdirPath(cwd);
141
165
  try {
166
+ if (isAutoAdoptDisabled(memdir)) {
167
+ return { ok: true, action: 'disabled', reason: 'disabled-by-sentinel' };
168
+ }
142
169
  if (isAdopted(memdir, PLUGIN_SLUG)) {
143
170
  writeMarker(markerDir, markerKey);
144
171
  return { ok: true, action: 'already-adopted' };
@@ -173,20 +200,110 @@ export function hasAutoAdoptMarker(markerDir, markerKey) {
173
200
  return existsSync(join(markerDir, `.auto-adopt-${markerKey}`));
174
201
  }
175
202
 
203
+ /**
204
+ * cmdDisable — `claude-mem-lite adopt --disable [--all]`.
205
+ *
206
+ * Writes `<memdir>/.mem-no-auto-adopt` so SessionStart auto-adopt skips this
207
+ * project permanently. Idempotent: re-running on an already-disabled memdir is
208
+ * a no-op. Does NOT remove an existing sentinel — pair with `unadopt` if you
209
+ * want both. The two operations are deliberately separate:
210
+ * - `unadopt` = "remove the contract now"
211
+ * - `adopt --disable` = "and don't auto-write it back"
212
+ */
213
+ function cmdDisable(args) {
214
+ const all = hasFlag(args, '--all');
215
+ const targets = all
216
+ ? listAllMemdirs().map((m) => m.memdir)
217
+ : [memdirPath(detectCwd())];
218
+
219
+ if (targets.length === 0) {
220
+ log('[adopt --disable] no memdirs found');
221
+ return;
222
+ }
223
+
224
+ let disabled = 0, already = 0;
225
+ for (const memdir of targets) {
226
+ if (!existsSync(memdir)) mkdirSync(memdir, { recursive: true });
227
+ const path = disableSentinelPath(memdir);
228
+ if (existsSync(path)) {
229
+ log(`[adopt --disable] ${memdir} → already-disabled`);
230
+ already++;
231
+ continue;
232
+ }
233
+ writeFileSync(path, JSON.stringify({ disabledAt: new Date().toISOString() }) + '\n');
234
+ log(`[adopt --disable] ${memdir} → disabled`);
235
+ disabled++;
236
+ }
237
+
238
+ log('');
239
+ log(`[adopt --disable] ${targets.length} target(s): ${disabled} newly disabled, ${already} already disabled`);
240
+ }
241
+
242
+ /**
243
+ * cmdEnable — `claude-mem-lite adopt --enable [--all]`.
244
+ *
245
+ * Removes the `<memdir>/.mem-no-auto-adopt` sentinel so the next SessionStart
246
+ * can auto-adopt again. Idempotent. Does NOT trigger an immediate adoption —
247
+ * run plain `claude-mem-lite adopt` if you want that now.
248
+ */
249
+ function cmdEnable(args) {
250
+ const all = hasFlag(args, '--all');
251
+ const targets = all
252
+ ? listAllMemdirs().map((m) => m.memdir)
253
+ : [memdirPath(detectCwd())];
254
+
255
+ if (targets.length === 0) {
256
+ log('[adopt --enable] no memdirs found');
257
+ return;
258
+ }
259
+
260
+ let enabled = 0, absent = 0;
261
+ for (const memdir of targets) {
262
+ const path = disableSentinelPath(memdir);
263
+ if (!existsSync(path)) {
264
+ log(`[adopt --enable] ${memdir} → absent`);
265
+ absent++;
266
+ continue;
267
+ }
268
+ try { unlinkSync(path); } catch { /* best-effort */ }
269
+ log(`[adopt --enable] ${memdir} → enabled`);
270
+ enabled++;
271
+ }
272
+
273
+ log('');
274
+ log(`[adopt --enable] ${targets.length} target(s): ${enabled} re-enabled, ${absent} not-disabled`);
275
+ }
276
+
176
277
  function statusAll() {
177
278
  const dirs = listAllMemdirs();
178
279
  log('[adopt --status] scanning ~/.claude/projects/*/memory/');
179
280
  if (dirs.length === 0) { log(' (no memdirs found)'); return; }
180
- let adopted = 0;
281
+ let adopted = 0, disabled = 0;
181
282
  for (const { projectSlug, memdir } of dirs) {
182
- if (isAdopted(memdir, PLUGIN_SLUG)) {
283
+ const isAdoptedHere = isAdopted(memdir, PLUGIN_SLUG);
284
+ const isDisabledHere = isAutoAdoptDisabled(memdir);
285
+ if (isAdoptedHere) {
183
286
  const idx = readMemoryIndex(memdir, PLUGIN_SLUG);
184
- log(` ✓ ${projectSlug} (${idx.version})`);
287
+ const suffix = isDisabledHere ? ' [auto-adopt disabled]' : '';
288
+ log(` ✓ ${projectSlug} (${idx.version})${suffix}`);
185
289
  adopted++;
290
+ if (isDisabledHere) disabled++;
291
+ } else if (isDisabledHere) {
292
+ log(` ✗ ${projectSlug} (auto-adopt disabled, no sentinel)`);
293
+ disabled++;
186
294
  }
187
295
  }
188
296
  log('');
189
- log(`[adopt --status] ${adopted}/${dirs.length} adopted`);
297
+ log(`[adopt --status] ${adopted}/${dirs.length} adopted${disabled > 0 ? `, ${disabled} disabled` : ''}`);
298
+
299
+ // Gating snapshot — helps debug "why didn't auto-adopt fire?"
300
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ? 'set' : 'unset';
301
+ const noAutoAdopt = process.env.MEM_NO_AUTO_ADOPT === '1' ? '1 (opt-out)' : 'unset';
302
+ log('');
303
+ log('Auto-adopt gates (next SessionStart will fire only if both pass):');
304
+ log(` CLAUDE_PLUGIN_ROOT = ${pluginRoot} (plugin-mode install required; npx stays opt-in)`);
305
+ log(` MEM_NO_AUTO_ADOPT = ${noAutoAdopt} (global escape hatch)`);
306
+ log('Per-project opt-out: `claude-mem-lite adopt --disable` (run --enable to re-arm).');
190
307
  }
191
308
 
192
309
  /**
package/hook-memory.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // Search past observations for relevant memories to inject as context at user-prompt time.
3
3
 
4
4
  import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords } from './utils.mjs';
5
+ import { citeFactorJs } from './scoring-sql.mjs';
5
6
  import { recordMetric } from './lib/metrics.mjs';
6
7
  import { DB_DIR } from './schema.mjs';
7
8
 
@@ -163,6 +164,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
163
164
  const selectStmt = db.prepare(`
164
165
  SELECT o.id, o.type, o.title, o.subtitle, o.narrative, o.importance, o.lesson_learned, o.project,
165
166
  o.created_at_epoch, o.files_modified,
167
+ o.cited_count, o.uncited_streak,
166
168
  ${OBS_BM25} as relevance,
167
169
  ${noisePenaltyClause('o')} as noise_penalty
168
170
  FROM observations_fts
@@ -200,6 +202,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
200
202
  try {
201
203
  const crossStmt = db.prepare(`
202
204
  SELECT o.id, o.type, o.title, o.subtitle, o.narrative, o.importance, o.lesson_learned, o.project,
205
+ o.cited_count, o.uncited_streak,
203
206
  ${OBS_BM25} as relevance,
204
207
  ${noisePenaltyClause('o')} as noise_penalty
205
208
  FROM observations_fts
@@ -241,6 +244,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
241
244
  const crossProjectPenalty = r.project === project ? 1.0 : crossPenalty;
242
245
  const orFallbackPenalty = r._or ? 0.4 : 1.0;
243
246
  const noisePenalty = typeof r.noise_penalty === 'number' ? r.noise_penalty : 1.0;
247
+ const citeFactor = citeFactorJs(r);
244
248
  return {
245
249
  ...r,
246
250
  score: Math.abs(r.relevance)
@@ -249,7 +253,8 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
249
253
  * (r.importance >= 2 ? 1.0 : 0.6)
250
254
  * crossProjectPenalty
251
255
  * orFallbackPenalty
252
- * noisePenalty,
256
+ * noisePenalty
257
+ * citeFactor,
253
258
  };
254
259
  })
255
260
  .sort((a, b) => b.score - a.score);
package/hook.mjs CHANGED
@@ -61,7 +61,7 @@ import { checkForUpdate } from './hook-update.mjs';
61
61
  import { handleLLMOptimize } from './hook-optimize.mjs';
62
62
  import { silentAutoAdopt, hasAutoAdoptMarker } from './adopt-cli.mjs';
63
63
  import { emitV270UpgradeBanner } from './lib/upgrade-banner.mjs';
64
- import { loadCiteBackForEpisode } from './lib/cite-back-hint.mjs';
64
+ import { loadCiteBackForEpisode, buildUnsavedBugfixHint } from './lib/cite-back-hint.mjs';
65
65
  // plugin-cache-guard.mjs loaded dynamically — pre-2.31.2 installs that auto-upgraded
66
66
  // from an older hook-update.mjs SOURCE_FILES (which did not list this module) would
67
67
  // crash on static import. Degrade gracefully to no-op when the module is absent.
@@ -181,8 +181,6 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
181
181
  if (RECEIPT_EVENTS.has(hookEventName)) {
182
182
  try {
183
183
  const entries = episode.entries || [];
184
- const hasError = entries.some(e => e.isError);
185
- const hasEdit = entries.some(e => EDIT_TOOLS.has(e.tool));
186
184
  const toolCounts = {};
187
185
  for (const e of entries) toolCounts[e.tool] = (toolCounts[e.tool] || 0) + 1;
188
186
  const toolSummary = Object.entries(toolCounts)
@@ -191,16 +189,14 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
191
189
  .map(([t, n]) => `${t}×${n}`)
192
190
  .join(', ');
193
191
  const lines = [`[mem] episode flushed: ${entries.length} entries (${toolSummary})`];
194
- if (hasError && hasEdit && entries.length >= 3) {
195
- const editFiles = entries.filter(e => EDIT_TOOLS.has(e.tool)).flatMap(e => e.files || []);
196
- const uniqueFiles = [...new Set(editFiles)].slice(0, 3);
197
- const filesHint = uniqueFiles.length > 0 ? ` (${uniqueFiles.join(', ')})` : '';
198
- lines.push(`[mem] 💡 error→fix pattern${filesHint} — consider: mem_save(type="bugfix", lesson_learned="<root cause + fix>")`);
199
- }
192
+ // v2.83: error→fix nudge lifted to lib/cite-back-hint.mjs::buildUnsavedBugfixHint
193
+ // so the wording (count + "Save now" verb) stays in sync with cite-back.
194
+ const bugfixHint = buildUnsavedBugfixHint(episode);
195
+ if (bugfixHint) lines.push(bugfixHint);
200
196
  // v2.81: cite-back hint — fires when this episode edits a file that
201
197
  // PreToolUse:Read/Edit nudged earlier in the same session. Precision
202
198
  // signal (we know the file was warned about); orthogonal to the
203
- // error→fix pattern above and may co-fire.
199
+ // bugfix-shape nudge above and may co-fire.
204
200
  const citeBack = loadCiteBackForEpisode(episode, RUNTIME_DIR);
205
201
  if (citeBack) lines.push(citeBack);
206
202
  process.stdout.write(JSON.stringify({
@@ -652,22 +648,27 @@ async function handleSessionStart() {
652
648
  }
653
649
  } catch (e) { debugCatch(e, 'session-start-cache-heal'); }
654
650
 
655
- // v2.33.0: plugin-mode first-run auto-adopt. /plugin install IS consent to
656
- // integrationwriting the MEMORY.md sentinel once per project on first
657
- // SessionStart avoids the opt-in friction. Scope is narrow:
658
- // - gated by CLAUDE_PLUGIN_ROOT (npm/npx installs stay opt-in)
659
- // - gated by !MEM_NO_AUTO_ADOPT (explicit escape hatch)
660
- // - gated by !MEM_QUIET_HOOKS (quiet = no side-effects semantics)
651
+ // First-run auto-adopt (v2.33.0 plugin-mode v2.82.1 install-mode-agnostic).
652
+ // ANY install path `/plugin install`, `npm install -g`, `npx`, manual is
653
+ // consent to integration. Writing the MEMORY.md sentinel once per project on
654
+ // first SessionStart avoids the opt-in friction that left ~zero users on
655
+ // auto-adopt (runtime-marker directory was empty machine-wide despite v2.33
656
+ // shipping ~5 weeks earlier `install.mjs`-written hooks don't propagate
657
+ // ${CLAUDE_PLUGIN_ROOT}, so the v2.33.0 gate was a no-op for npm/manual
658
+ // installs, which is most of them). Scope is now:
659
+ // - gated by !MEM_NO_AUTO_ADOPT (explicit global escape hatch)
660
+ // - per-project opt-out via `<memdir>/.mem-no-auto-adopt` sentinel
661
+ // (managed by `claude-mem-lite adopt --disable / --enable`); checked
662
+ // inside silentAutoAdopt so the helper is safe to call directly too.
661
663
  // - first-attempt marker persists in RUNTIME_DIR so a subsequent /unadopt
662
664
  // is respected (no re-adopt loop).
665
+ // Note v2.82.0: removed MEM_QUIET_HOOKS gate. That env var suppresses stdout
666
+ // noise; it must NOT also disable side-effect work (PostToolUse writes the
667
+ // DB unconditionally — auto-adopt should follow the same rule).
663
668
  // Failures (user-edited sentinel, budget exceeded, FS errors) are swallowed;
664
669
  // the marker is still written so we don't retry on every SessionStart.
665
670
  try {
666
- if (
667
- process.env.CLAUDE_PLUGIN_ROOT
668
- && process.env.MEM_NO_AUTO_ADOPT !== '1'
669
- && process.env.MEM_QUIET_HOOKS !== '1'
670
- ) {
671
+ if (process.env.MEM_NO_AUTO_ADOPT !== '1') {
671
672
  const project = inferProject();
672
673
  if (!hasAutoAdoptMarker(RUNTIME_DIR, project)) {
673
674
  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -41,15 +41,60 @@ export function buildCiteBackHint(episode, cooldown) {
41
41
 
42
42
  if (matches.length === 0) return null;
43
43
 
44
- const lines = ['[mem] 💡 Cite-back: you edited file(s) that received PreToolUse lessons this session.'];
44
+ // B1 (v2.83): leader line carries explicit counts (file count + total lesson
45
+ // count) and a directive verb. Pre-v2.83 wording "if you fixed it" was
46
+ // routinely treated as advisory and ignored — cite-recall data showed the
47
+ // hint firing without follow-up `/lesson` calls. §10 Specificity binds:
48
+ // numeric framing is measurably harder to dismiss than a hedged hint.
49
+ const totalLessons = matches.reduce((sum, m) => sum + m.ids.length, 0);
50
+ const lines = [
51
+ `[mem] ⚠ Cite-back: edited ${matches.length} file(s) with ${totalLessons} prior lesson(s) this session. Save now if any was the root cause:`,
52
+ ];
45
53
  for (const m of matches) {
46
54
  const fname = basename(m.file);
47
55
  const idList = m.ids.map(id => `#${id}`).join(', ');
48
- lines.push(` • ${fname} ← ${idList} — if you fixed it: /lesson --file ${fname} "<root cause + fix>"`);
56
+ lines.push(` • ${fname} ← ${idList} — /lesson --file ${fname} "<root cause + fix>"`);
49
57
  }
50
58
  return lines.join('\n');
51
59
  }
52
60
 
61
+ // B1 (v2.83): structured per-episode "tricky fix just happened" detector. Lifts
62
+ // the inline error→fix nudge that used to live in hook.mjs flushEpisode into
63
+ // the lib so all save-prompt hints share one home + the same wording rules.
64
+ //
65
+ // Detection (mirrors pre-v2.83 hook.mjs:194 heuristic):
66
+ // • has at least one entry with isError=true
67
+ // • has at least one entry using an edit tool
68
+ // • entries.length >= 3 (rules out single-typo fixes that don't need a lesson)
69
+ // Returns null when any condition fails or when no edited files are recoverable
70
+ // from the entry list (defensive — episodes flushed mid-tool can have empties).
71
+ const MIN_BUGFIX_ENTRIES = 3;
72
+ const MAX_DISPLAY_FILES = 3;
73
+
74
+ export function buildUnsavedBugfixHint(episode) {
75
+ if (!episode) return null;
76
+ const entries = episode.entries;
77
+ if (!Array.isArray(entries) || entries.length < MIN_BUGFIX_ENTRIES) return null;
78
+
79
+ let hasError = false;
80
+ let hasEdit = false;
81
+ const editedFiles = new Set();
82
+ for (const e of entries) {
83
+ if (!e) continue;
84
+ if (e.isError) hasError = true;
85
+ if (EDIT_TOOLS.has(e.tool)) {
86
+ hasEdit = true;
87
+ for (const f of e.files || []) editedFiles.add(f);
88
+ }
89
+ }
90
+ if (!hasError || !hasEdit || editedFiles.size === 0) return null;
91
+
92
+ const files = [...editedFiles];
93
+ const displayed = files.slice(0, MAX_DISPLAY_FILES).map(f => basename(f));
94
+ const firstFname = basename(files[0]);
95
+ return `[mem] ⚠ Unsaved bugfix-shape: error+edit across ${files.length} file(s) in ${entries.length} entries (${displayed.join(', ')}). Save now if it was a real fix: /lesson --file ${firstFname} "<root cause + fix>"`;
96
+ }
97
+
53
98
  // Path scheme MUST mirror scripts/pre-tool-recall.js cooldownPathFor() — drift
54
99
  // silently zeros cite-back across the release. Pinned by tests/cite-back-hint.test.mjs
55
100
  // 'sanitizes the sessionId the same way pre-tool-recall.js does'.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.81.0",
3
+ "version": "2.83.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
package/scoring-sql.mjs CHANGED
@@ -126,3 +126,52 @@ export function noisePenaltyClause(alias = 'o') {
126
126
  export function notLowSignalTitleClause(alias = 'o') {
127
127
  return buildNotLowSignalSql(alias);
128
128
  }
129
+
130
+ // ─── Cite-history factor (A1, v2.83) ────────────────────────────────────────
131
+ //
132
+ // Closes the citation-decay → ranking loop. The Stop hook citation-decay
133
+ // already maintains cited_count (Promote on cite) and uncited_streak (bump on
134
+ // uncited; reset on cite or demote-at-3). Before A1, both columns affected
135
+ // only the importance ±1 dial — through `(0.5 + 0.5·importance)` that's a
136
+ // ≤2× swing and saturates fast. This factor lets ranking respond directly to
137
+ // observed agent behavior on the obs itself.
138
+ //
139
+ // Formula: clamp(0.4, 3.0, 1 + 0.2·cited_count − 0.25·uncited_streak)
140
+ // Distribution:
141
+ // cited=0, streak=0 → 1.0 (fresh obs, neutral)
142
+ // cited=5, streak=0 → 2.0
143
+ // cited≥10, streak=0 → 3.0 (capped — one viral obs can't dominate)
144
+ // cited=0, streak=2 → 0.5
145
+ // cited=0, streak=3+ → 0.4 (floored; citation-decay resets streak at 3
146
+ // after demoting importance, so steady-state
147
+ // streak is bounded by [0,2])
148
+ //
149
+ // Disjoint from noisePenaltyClause: noise penalty uses
150
+ // `injection_count vs access_count` (passive inject vs any access);
151
+ // cite_factor uses `cited_count vs uncited_streak` — same-source signal
152
+ // maintained only by the citation-decay loop. Both apply multiplicatively;
153
+ // order doesn't affect ORDER BY relevance ASC.
154
+ export const CITE_FACTOR_MIN = 0.4;
155
+ export const CITE_FACTOR_MAX = 3.0;
156
+ export const CITE_FACTOR_PER_CITE = 0.2;
157
+ export const CITE_FACTOR_PER_STREAK = 0.25;
158
+
159
+ export function citeFactorClause(alias = 'o') {
160
+ const a = alias ? `${alias}.` : '';
161
+ return `(
162
+ MAX(${CITE_FACTOR_MIN},
163
+ MIN(${CITE_FACTOR_MAX},
164
+ 1.0
165
+ + ${CITE_FACTOR_PER_CITE} * COALESCE(${a}cited_count, 0)
166
+ - ${CITE_FACTOR_PER_STREAK} * COALESCE(${a}uncited_streak, 0)
167
+ )
168
+ )
169
+ )`;
170
+ }
171
+
172
+ export function citeFactorJs(row) {
173
+ const cited = (row && typeof row.cited_count === 'number') ? row.cited_count : 0;
174
+ const streak = (row && typeof row.uncited_streak === 'number') ? row.uncited_streak : 0;
175
+ const raw = 1.0 + CITE_FACTOR_PER_CITE * cited - CITE_FACTOR_PER_STREAK * streak;
176
+ return Math.max(CITE_FACTOR_MIN, Math.min(CITE_FACTOR_MAX, raw));
177
+ }
@@ -16,6 +16,12 @@ import { recordHookError } from '../lib/hook-telemetry.mjs';
16
16
  const DATA_DIR = process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite');
17
17
  const DB_PATH = process.env.CLAUDE_MEM_DB_PATH || join(DATA_DIR, 'claude-mem-lite.db');
18
18
  const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
19
+ // A3 (v2.83): cross-hook dedup window — must mirror DEDUP_STALE_MS in
20
+ // scripts/prompt-search-utils.mjs. UPS writes
21
+ // `runtime/.claude-mem-injected-<project>` after each inject; we read it to
22
+ // drop IDs the agent already saw in this 5-min window. Standalone fast-path
23
+ // (#8447) so we inline the constant rather than importing the helper.
24
+ const CROSS_HOOK_DEDUP_MS = 5 * 60 * 1000;
19
25
  // v2.33.1: cooldown path is session-scoped so same-file-twice within one
20
26
  // session never re-injects (was: global file, 5-min window). Cross-session:
21
27
  // fresh file, fresh nudges — this is intended. No session_id → fall back to
@@ -58,6 +64,50 @@ function entryTimestamp(v) {
58
64
  return 0;
59
65
  }
60
66
 
67
+ // A3 (v2.83): cross-hook injected-IDs store. UPS writes
68
+ // `runtime/.claude-mem-injected-<project>` with {ids, ts, count}. We read
69
+ // inside the staleness window, filter overlaps from PreToolUse output, then
70
+ // merge back so the next UPS sees what we emitted too.
71
+ function crossHookInjectedFile(project) {
72
+ return join(RUNTIME_DIR, `.claude-mem-injected-${project}`);
73
+ }
74
+
75
+ function readCrossHookInjected(project) {
76
+ try {
77
+ const raw = readFileSync(crossHookInjectedFile(project), 'utf8');
78
+ const { ids, ts } = JSON.parse(raw);
79
+ if (!ts || Date.now() - ts > CROSS_HOOK_DEDUP_MS) return new Set();
80
+ if (!Array.isArray(ids)) return new Set();
81
+ return new Set(ids.map(String));
82
+ } catch { return new Set(); }
83
+ }
84
+
85
+ function mergeCrossHookInjected(project, newIds) {
86
+ if (!newIds || newIds.length === 0) return;
87
+ try {
88
+ mkdirSync(RUNTIME_DIR, { recursive: true });
89
+ const file = crossHookInjectedFile(project);
90
+ let prev = { ids: [], ts: 0, count: 0 };
91
+ try {
92
+ const raw = readFileSync(file, 'utf8');
93
+ const parsed = JSON.parse(raw);
94
+ // Within the staleness window: union. Outside: replace (fresh session).
95
+ if (parsed.ts && Date.now() - parsed.ts < CROSS_HOOK_DEDUP_MS) {
96
+ prev = parsed;
97
+ }
98
+ } catch { /* fresh file */ }
99
+ const ids = [...new Set([
100
+ ...(Array.isArray(prev.ids) ? prev.ids.map(String) : []),
101
+ ...newIds.map(String),
102
+ ])];
103
+ writeFileSync(file, JSON.stringify({
104
+ ids,
105
+ ts: Date.now(),
106
+ count: (prev.count || 0) + 1,
107
+ }));
108
+ } catch { /* silent — dedup is best-effort */ }
109
+ }
110
+
61
111
  function writeCooldown(cooldownPath, data, isSessionScoped) {
62
112
  try {
63
113
  mkdirSync(RUNTIME_DIR, { recursive: true });
@@ -228,10 +278,20 @@ try {
228
278
  `).all(project, cutoff, `%"${fnameEscaped}"%`, `%"${filePathEscaped}"%`);
229
279
  } catch { /* events table may not exist on pre-v2.31 DBs — silent */ }
230
280
 
281
+ // A3 (v2.83): cross-hook dedup. UPS may have already injected some of
282
+ // these obs ids this prompt — re-emitting wastes the PreToolUse slot
283
+ // (and inflates context). Drop ids found in the cross-hook injected file
284
+ // inside the staleness window; keep file-cooldown unchanged (the same
285
+ // file might also re-warrant a different lesson next session).
286
+ const crossHookSeen = readCrossHookInjected(project);
287
+ const dedupedRows = crossHookSeen.size > 0
288
+ ? [...rows, ...eventRows].filter(r => !crossHookSeen.has(String(r.id)))
289
+ : [...rows, ...eventRows];
290
+
231
291
  // Merge: observations first (they carry richer lesson_learned), then events.
232
292
  // Edit/Write caps at 3 total; Read caps at 1 (single most-actionable hit).
233
293
  const mergeCap = isRead ? 1 : 3;
234
- const allRows = [...rows, ...eventRows].slice(0, mergeCap);
294
+ const allRows = dedupedRows.slice(0, mergeCap);
235
295
 
236
296
  // v2.31 T2: emit JSON with hookSpecificOutput.additionalContext so the message
237
297
  // reliably renders across CC variants (sdscc drops plain-text stdout from PreToolUse).
@@ -291,6 +351,11 @@ try {
291
351
  // file. Empty array on no-lesson branches keeps the schema uniform.
292
352
  cooldown[filePath] = { ts: now, lessonIds: allRows.map(r => r.id) };
293
353
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
354
+ // A3 (v2.83): merge our newly-emitted IDs into the cross-hook injected
355
+ // file so the next UPS prompt skips them too. Always write, even on
356
+ // empty allRows, so the file's ts stays fresh for the no-op case where
357
+ // we'd otherwise drift outside the dedup window.
358
+ mergeCrossHookInjected(project, allRows.map(r => r.id));
294
359
  } catch (e) {
295
360
  // Silent failure — never block editing, but record for self-observation.
296
361
  recordHookError('pre-recall:query', e, RUNTIME_DIR, { filePath });
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
7
7
  import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, notLowSignalTitleClause, noisePenaltyClause, stripPrivate } from '../utils.mjs';
8
+ import { citeFactorClause } from '../scoring-sql.mjs';
8
9
  import { cjkPrecisionOk } from '../nlp.mjs';
9
10
  import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
10
11
  import { join } from 'path';
@@ -216,6 +217,11 @@ function searchByFts(db, queryText, project, limit, typeFilter) {
216
217
  // v26 P0: noise penalty shrinks relevance magnitude for obs with high
217
218
  // inject:access ratio (auto-injected often, never cited/opened). See
218
219
  // docs/p0-injection-noise-baseline.txt.
220
+ // A1 (v2.83): cite_factor closes the citation-decay → ranking loop. Obs the
221
+ // assistant cited in past sessions (cited_count > 0) get boosted; obs with
222
+ // accumulating uncited_streak get dampened upstream of importance-decay.
223
+ // Disjoint signal from noise_penalty (which uses injection_count vs
224
+ // access_count) — see scoring-sql.mjs::citeFactorClause for the math.
219
225
  const sql = `
220
226
  SELECT o.id, o.type, o.title, o.lesson_learned,
221
227
  ${OBS_BM25} as bm25_raw,
@@ -223,7 +229,8 @@ function searchByFts(db, queryText, project, limit, typeFilter) {
223
229
  * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${TYPE_DECAY_CASE}))
224
230
  * ${TYPE_QUALITY_CASE}
225
231
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
226
- * ${noisePenaltyClause('o')} as relevance
232
+ * ${noisePenaltyClause('o')}
233
+ * ${citeFactorClause('o')} as relevance
227
234
  FROM observations_fts
228
235
  JOIN observations o ON o.id = observations_fts.rowid
229
236
  WHERE observations_fts MATCH ?