claude-mem-lite 2.9.1 → 2.9.3

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.9.1",
13
+ "version": "2.9.3",
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.9.1",
3
+ "version": "2.9.3",
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
@@ -121,7 +121,7 @@ The original sends **everything to the LLM and hopes it filters well**. claude-m
121
121
  /plugin install claude-mem-lite
122
122
  ```
123
123
 
124
- The plugin system handles everything: hooks, MCP server, and dependency installation (via Setup hook). Dependencies are installed automatically on first run.
124
+ 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.
125
125
 
126
126
  ### Method 2: npx (one-liner)
127
127
 
@@ -375,6 +375,8 @@ node install.mjs uninstall # Remove (keep data)
375
375
  node install.mjs uninstall --purge # Remove and delete all data
376
376
  node install.mjs status # Show current status
377
377
  node install.mjs doctor # Diagnose issues
378
+ node install.mjs cleanup-hooks # Remove only stale claude-mem-lite hooks from settings.json
379
+ node install.mjs update # Force-check for updates and install them (direct install / npx mode)
378
380
 
379
381
  # npx install:
380
382
  npx claude-mem-lite # Install / reinstall
@@ -382,13 +384,18 @@ npx claude-mem-lite uninstall # Remove (keep data)
382
384
  npx claude-mem-lite doctor # Diagnose issues
383
385
  ```
384
386
 
387
+ Notes:
388
+ - Plugin mode only reports available updates; it does not self-update plugin files.
389
+ - Direct install / npx mode keeps auto-update enabled and uses staged replacement with rollback on install failure.
390
+ - If you disabled the plugin but still have old mem hooks in `~/.claude/settings.json`, run `node install.mjs cleanup-hooks`.
391
+
385
392
  ### doctor
386
393
 
387
394
  Checks Node.js version, dependencies, server/hook files, database integrity, FTS5 indexes, and stale processes.
388
395
 
389
396
  ### status
390
397
 
391
- Shows MCP registration, hook configuration, and database stats (observation/session counts).
398
+ Shows MCP registration, hook configuration, plugin disabled state, and database stats (observation/session counts).
392
399
 
393
400
  ## Uninstall
394
401
 
package/README.zh-CN.md CHANGED
@@ -121,7 +121,7 @@
121
121
  /plugin install claude-mem-lite
122
122
  ```
123
123
 
124
- 插件系统会处理一切:钩子、MCP 服务器和依赖安装(通过 Setup 钩子)。依赖会在首次运行时自动安装。
124
+ 插件模式会管理自己的运行时与钩子。SessionStart 时它现在只会**检查并提示**新版本,不会直接覆盖插件目录中的文件。插件模式请通过 Claude 的插件更新流程完成升级。
125
125
 
126
126
  ### 方式二:npx(一行命令)
127
127
 
@@ -375,6 +375,8 @@ node install.mjs uninstall # 移除(保留数据)
375
375
  node install.mjs uninstall --purge # 移除并删除所有数据
376
376
  node install.mjs status # 显示当前状态
377
377
  node install.mjs doctor # 诊断问题
378
+ node install.mjs cleanup-hooks # 只清理 settings.json 中残留的 claude-mem-lite hooks
379
+ node install.mjs update # 强制检查并安装更新(direct install / npx 模式)
378
380
 
379
381
  # npx 安装:
380
382
  npx claude-mem-lite # 安装 / 重新安装
@@ -382,13 +384,18 @@ npx claude-mem-lite uninstall # 移除(保留数据)
382
384
  npx claude-mem-lite doctor # 诊断问题
383
385
  ```
384
386
 
387
+ 说明:
388
+ - 插件模式只提示可用更新,不会自更新插件文件。
389
+ - direct install / npx 模式保留自动更新,并使用 staged replacement;若依赖安装失败会回滚。
390
+ - 如果你禁用了插件,但 `~/.claude/settings.json` 里还有旧的 mem hooks,可运行 `node install.mjs cleanup-hooks`。
391
+
385
392
  ### doctor
386
393
 
387
394
  检查 Node.js 版本、依赖、服务器/钩子文件、数据库完整性、FTS5 索引和残留进程。
388
395
 
389
396
  ### status
390
397
 
391
- 显示 MCP 注册状态、钩子配置和数据库统计(观察/会话数量)。
398
+ 显示 MCP 注册状态、钩子配置、插件禁用状态和数据库统计(观察/会话数量)。
392
399
 
393
400
  ## 卸载
394
401
 
package/cli.mjs CHANGED
@@ -1,2 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import './install.mjs';
2
+ import { main } from './install.mjs';
3
+
4
+ await main(process.argv.slice(2));
package/dispatch.mjs CHANGED
@@ -216,7 +216,7 @@ const NEGATION_CJK = /(?:不要|别|不用|先别|暂时不|不需要|跳过|停
216
216
 
217
217
  // Test-run vs test-write disambiguation (module-scoped for performance)
218
218
  const _RUN_TEST = /\b(run\w*\s+(?:the\s+)?tests?|npm\s+test|npx\s+(?:vitest|jest|mocha|pytest)|yarn\s+test|pnpm\s+test|make\s+test|cargo\s+test|go\s+test|check\s+(?:if\s+)?tests?\s+pass|execute\s+(?:the\s+)?tests?)\b/i;
219
- const _RUN_TEST_CJK = /(?:运行测试|跑测试|跑一下测试|跑单测|执行测试|测试跑|看测试)/;
219
+ const _RUN_TEST_CJK = /(?:运行测试|跑测试|跑一下测试|跑单测|跑一下单测|执行测试|执行单测|测试跑|看测试|看单测)/;
220
220
  const _WRITE_TEST = /\b(write\s+tests?|add\s+tests?|create\s+tests?|need\s+tests?|missing\s+tests?|tdd|test.?driven|red.?green|increase\s+coverage|improve\s+coverage)\b/i;
221
221
  const _WRITE_TEST_CJK = /(?:写测试|加测试|补测试|补单测|缺测试|测试覆盖)/;
222
222
 
@@ -259,7 +259,7 @@ const _INTENT_PATTERNS = (() => {
259
259
  // ── Chinese patterns ──
260
260
  [/(测试|写测试|单测|单元测试|用例|覆盖率)/, 'test'],
261
261
  [/(修复|修bug|改bug|找bug|有bug|调试|排错|报错|出错|有问题|不工作|跑不起来|不能用|挂了|崩溃)/, 'fix'],
262
- [/(审查|审核|代码审查|评审|代码审核|看看代码|review)/, 'review'],
262
+ [/(审查|审核|审计|代码审查|评审|代码审核|看看代码|review)/, 'review'],
263
263
  [/(提交|推送|上传)/, 'commit'],
264
264
  [/(部署|上线|发布|回滚)/, 'deploy'],
265
265
  [/(规划|架构|方案|设计方案)/, 'plan'],
@@ -273,6 +273,10 @@ const _INTENT_PATTERNS = (() => {
273
273
  [/(优化|性能|卡顿|耗时|太慢|慢死了|好慢|缓存)/, 'fast'],
274
274
  [/(格式化|代码风格|代码规范|类型检查)/, 'lint'],
275
275
  [/(界面|前端|样式|页面|组件|布局)/, 'design'],
276
+ // search: only unambiguous web/info search indicators — NOT code search (grep/find).
277
+ // "搜索" alone is ambiguous (code search vs web search), so require context modifiers.
278
+ [/(联网搜索|网上搜索|在线搜索|上网查|搜索.{0,2}最新|搜一下.{0,2}最新|查.{0,2}最新|查资料|找资料|搜索资料|搜索文档)/, 'search'],
279
+ [/\b(google|search\s+online|web\s+search|look\s+up\s+(?:the\s+)?(?:latest|newest|recent|docs?|documentation))\b/i, 'search'],
276
280
  ];
277
281
  // Pre-compile global variants for matchAll — avoids creating new RegExp on every extractIntent call
278
282
  return raw.map(([p, tag]) => [p, new RegExp(p.source, p.flags.includes('g') ? p.flags : p.flags + 'g'), tag]);
@@ -311,15 +315,19 @@ function extractIntent(prompt) {
311
315
  }
312
316
 
313
317
  const found = [];
318
+ const suppressed = [];
314
319
  for (const tag of tagMatched) {
315
320
  if (tagHasAffirmative.get(tag) && !found.includes(tag)) {
316
321
  found.push(tag);
322
+ } else if (!tagHasAffirmative.get(tag)) {
323
+ // Tag was matched but ALL instances were negated → suppress it.
324
+ // This feeds the text-fallback filter to prevent recommending negated resources.
325
+ suppressed.push(tag);
317
326
  }
318
327
  }
319
328
 
320
329
  // Distinguish test-running from test-writing: "run tests" / "npm test" / "运行测试" should NOT
321
330
  // trigger TDD recommendations. Only keep 'test' intent when the prompt implies *writing* tests.
322
- const suppressed = [];
323
331
  if (found.includes('test')) {
324
332
  const isRunning = _RUN_TEST.test(prompt) || _RUN_TEST_CJK.test(prompt);
325
333
  const isWriting = _WRITE_TEST.test(prompt) || _WRITE_TEST_CJK.test(prompt);
@@ -810,8 +818,13 @@ function applyAdoptionDecay(results, db) {
810
818
  */
811
819
  function passesConfidenceGate(results, signals) {
812
820
  // BM25 absolute minimum: filter weak text matches.
813
- // Stricter threshold for 3+ results (reliable IDF); gentler floor for 1-2 results.
814
- const minThreshold = results.length >= 3 ? BM25_MIN_THRESHOLD : 0.5;
821
+ // Threshold is relative to the top result's score to handle varying corpus sizes:
822
+ // small corpora (< 50 resources) naturally produce lower BM25 IDF values,
823
+ // so an absolute threshold would over-filter genuine matches.
824
+ const baseThreshold = results.length >= 3 ? BM25_MIN_THRESHOLD : 0.5;
825
+ const topScore = results.length > 0 ? Math.abs(results[0].composite_score ?? results[0].relevance ?? 0) : 0;
826
+ // Use the lower of: absolute threshold OR 30% of top score (corpus-size-adaptive floor)
827
+ const minThreshold = topScore > 0 ? Math.min(baseThreshold, topScore * 0.3) : baseThreshold;
815
828
  results = results.filter(r => {
816
829
  const raw = r.composite_score ?? r.relevance;
817
830
  if (raw === null || raw === undefined) return true; // no score → pass (pre-scored or synthetic result)
@@ -821,10 +834,18 @@ function passesConfidenceGate(results, signals) {
821
834
  // Gap check: if top-2 results are too close in score, the query is ambiguous.
822
835
  // This prevents recommending when multiple resources match equally well,
823
836
  // which usually means the match is incidental rather than precise.
837
+ // Skip the gap check when rawKeywords promoted #1 (keyword re-ranking changes order,
838
+ // so the BM25 gap no longer reflects true relevance — the keyword match is extra signal).
824
839
  if (results.length >= 2) {
825
840
  const top1 = Math.abs(results[0].composite_score ?? results[0].relevance ?? 0);
826
841
  const top2 = Math.abs(results[1].composite_score ?? results[1].relevance ?? 0);
827
- if (top1 > 0) {
842
+ // After keyword re-ranking, #1 may have lower raw BM25 than #2.
843
+ // Only skip gap check if #1 was actually promoted by a keyword match
844
+ // (not just any rawKeywords present with incidentally inverted scores).
845
+ const top1Tags = (results[0].intent_tags || '').toLowerCase();
846
+ const top1MatchesKw = signals?.rawKeywords?.some(kw => top1Tags.includes(kw));
847
+ const wasReRanked = top1MatchesKw && top1 < top2;
848
+ if (!wasReRanked && top1 > 0) {
828
849
  const gapRatio = (top1 - top2) / top1;
829
850
  if (gapRatio < 0.2) {
830
851
  // Top-1 has no clear lead — ambiguous match, suppress recommendation
@@ -976,7 +997,24 @@ function decideTier(resource, signals) {
976
997
  // Normalize: typical good matches score 5-50, great matches 20+
977
998
  // Sigmoid-like mapping to 0-1 range
978
999
  const normalized = raw / (raw + 5.0); // 5→0.5, 10→0.67, 20→0.8, 50→0.91
979
- const confidence = Math.min(1.0, normalized + patternBoost * 0.3);
1000
+
1001
+ // Signal-based confidence floor: if the result passed structured intent matching
1002
+ // + keyword re-ranking, BM25 score alone shouldn't downgrade to 'silent'.
1003
+ // Small corpora produce low BM25 scores even for strong matches.
1004
+ let signalBoost = 0;
1005
+ if (signals?.primaryIntent) {
1006
+ const tags = (resource.intent_tags || '').toLowerCase().split(/[\s,]+/);
1007
+ // Direct intent match: resource's intent_tags contain the detected primary intent.
1008
+ // Strong boost (0.3) ensures small-corpus matches still reach 'hint' tier.
1009
+ if (tags.includes(signals.primaryIntent)) signalBoost += 0.3;
1010
+ else signalBoost += 0.1;
1011
+ }
1012
+ if (signals?.rawKeywords?.length > 0) {
1013
+ const tagArr = (resource.intent_tags || '').toLowerCase().split(/[\s,]+/);
1014
+ if (signals.rawKeywords.some(kw => tagArr.includes(kw))) signalBoost += 0.2;
1015
+ }
1016
+
1017
+ const confidence = Math.min(1.0, normalized + patternBoost * 0.3 + signalBoost);
980
1018
 
981
1019
  if (confidence >= 0.55) return 'full';
982
1020
  if (confidence >= 0.3) return 'hint';
package/hook-update.mjs CHANGED
@@ -3,8 +3,8 @@
3
3
  // Skips in dev mode (symlinked installs). Silent on network failure.
4
4
 
5
5
  import { execSync } from 'node:child_process';
6
- import { readFileSync, writeFileSync, copyFileSync, readdirSync, existsSync, lstatSync, mkdirSync, rmSync } from 'node:fs';
7
- import { join } from 'node:path';
6
+ import { readFileSync, writeFileSync, copyFileSync, readdirSync, existsSync, lstatSync, mkdirSync, rmSync, renameSync } from 'node:fs';
7
+ import { join, dirname } from 'node:path';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { DB_DIR } from './schema.mjs';
10
10
  import { debugCatch, debugLog } from './utils.mjs';
@@ -16,17 +16,29 @@ const STATE_FILE = join(INSTALL_DIR, 'runtime', 'update-state.json');
16
16
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
17
17
  const FETCH_TIMEOUT_MS = 3000; // 3s network timeout
18
18
  const RATE_LIMIT_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h if rate-limited
19
+ const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
19
20
 
20
21
  // ── Main Entry ─────────────────────────────────────────────
21
- export async function checkForUpdate() {
22
+ export async function checkForUpdate(options = {}) {
22
23
  try {
24
+ const pluginMode = isPluginMode();
25
+ const force = Boolean(options.force);
26
+ const allowInstall = options.allowInstall ?? !pluginMode;
27
+
23
28
  if (isDevMode() || process.env.CLAUDE_MEM_SKIP_UPDATE) return null;
24
29
 
25
30
  const state = readState();
26
- if (!shouldCheck(state)) {
31
+ if (!force && !shouldCheck(state)) {
27
32
  // Return cached update info if previously detected
28
33
  if (state.updateAvailable && state.latestVersion) {
29
- return { updateAvailable: true, from: state.installedVersion, to: state.latestVersion };
34
+ return {
35
+ updateAvailable: true,
36
+ updated: false,
37
+ from: state.installedVersion,
38
+ to: state.latestVersion,
39
+ installDeferred: pluginMode || !allowInstall,
40
+ pluginMode,
41
+ };
30
42
  }
31
43
  return null;
32
44
  }
@@ -42,7 +54,8 @@ export async function checkForUpdate() {
42
54
 
43
55
  if (hasUpdate) {
44
56
  debugLog('DEBUG', 'hook-update', `Update available: ${currentVersion} → ${latest.version}`);
45
- const success = await downloadAndInstall(latest.tarballUrl);
57
+ const canInstall = !pluginMode && Boolean(allowInstall);
58
+ const success = canInstall ? await downloadAndInstall(latest.tarballUrl) : false;
46
59
  const newState = {
47
60
  lastCheck: new Date().toISOString(),
48
61
  installedVersion: success ? latest.version : currentVersion,
@@ -58,6 +71,8 @@ export async function checkForUpdate() {
58
71
  updated: success,
59
72
  from: currentVersion,
60
73
  to: latest.version,
74
+ installDeferred: !canInstall,
75
+ pluginMode,
61
76
  };
62
77
  }
63
78
 
@@ -76,6 +91,10 @@ export async function checkForUpdate() {
76
91
  }
77
92
  }
78
93
 
94
+ function isPluginMode() {
95
+ return Boolean(process.env.CLAUDE_PLUGIN_ROOT);
96
+ }
97
+
79
98
  // ── Dev Mode Detection ─────────────────────────────────────
80
99
  function isDevMode() {
81
100
  try {
@@ -172,12 +191,13 @@ const SOURCE_FILES = [
172
191
  'server.mjs', 'server-internals.mjs', 'tool-schemas.mjs',
173
192
  'hook.mjs', 'hook-shared.mjs', 'hook-llm.mjs', 'hook-memory.mjs',
174
193
  'hook-semaphore.mjs', 'hook-episode.mjs', 'hook-context.mjs', 'hook-handoff.mjs', 'hook-update.mjs',
175
- 'haiku-client.mjs', 'utils.mjs', 'schema.mjs', 'package.json', 'skill.md',
194
+ 'haiku-client.mjs', 'utils.mjs', 'schema.mjs', 'package.json', 'package-lock.json', 'skill.md',
176
195
  'registry.mjs', 'registry-scanner.mjs', 'registry-indexer.mjs',
177
196
  'registry-retriever.mjs', 'resource-discovery.mjs',
178
- 'dispatch.mjs', 'dispatch-inject.mjs', 'dispatch-feedback.mjs', 'dispatch-workflow.mjs',
197
+ 'dispatch.mjs', 'dispatch-inject.mjs', 'dispatch-feedback.mjs', 'dispatch-patterns.mjs', 'dispatch-workflow.mjs',
179
198
  'install.mjs',
180
199
  ];
200
+ const SWITCHABLE_PATHS = [...SOURCE_FILES, 'scripts', 'registry', 'node_modules'];
181
201
 
182
202
  // ── Download & Install ─────────────────────────────────────
183
203
  // Direct file copy instead of running old install.mjs (avoids symlink overwrite in dev)
@@ -197,53 +217,113 @@ async function downloadAndInstall(tarballUrl) {
197
217
  { timeout: 30000, stdio: 'pipe' }
198
218
  );
199
219
 
200
- // Direct copy: overwrite source files in INSTALL_DIR
201
- // Safer than running old install.mjs which may not respect CLAUDE_MEM_DIR
202
- let copied = 0;
203
- for (const f of SOURCE_FILES) {
204
- const src = join(tmpDir, f);
205
- const dest = join(INSTALL_DIR, f);
206
- if (existsSync(src)) {
207
- copyFileSync(src, dest);
208
- copied++;
209
- }
210
- }
220
+ return installExtractedRelease(tmpDir);
221
+ } catch (err) {
222
+ debugCatch(err, 'downloadAndInstall');
223
+ return false;
224
+ } finally {
225
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
226
+ }
227
+ }
228
+
229
+ export function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR) {
230
+ const ts = `${Date.now()}-${process.pid}`;
231
+ const stagingDir = join(targetDir, `.update-staging-${ts}`);
232
+ const backupDir = join(targetDir, `.update-backup-${ts}`);
233
+ const backedUp = [];
234
+ const installed = [];
235
+
236
+ try {
237
+ mkdirSync(stagingDir, { recursive: true });
238
+ mkdirSync(backupDir, { recursive: true });
239
+
240
+ copyReleaseIntoStaging(sourceDir, stagingDir);
241
+ execSync(NPM_INSTALL_CMD, {
242
+ cwd: stagingDir,
243
+ timeout: 60000,
244
+ stdio: 'pipe',
245
+ });
246
+
247
+ for (const relPath of SWITCHABLE_PATHS) {
248
+ const stagedPath = join(stagingDir, relPath);
249
+ if (!existsSync(stagedPath)) continue;
250
+
251
+ const targetPath = join(targetDir, relPath);
252
+ const backupPath = join(backupDir, relPath);
211
253
 
212
- // Copy scripts/ directory if present
213
- const srcScripts = join(tmpDir, 'scripts');
214
- if (existsSync(srcScripts)) {
215
- const destScripts = join(INSTALL_DIR, 'scripts');
216
- mkdirSync(destScripts, { recursive: true });
217
- for (const f of readdirSync(srcScripts)) {
218
- copyFileSync(join(srcScripts, f), join(destScripts, f));
254
+ mkdirSync(dirname(targetPath), { recursive: true });
255
+ mkdirSync(dirname(backupPath), { recursive: true });
256
+
257
+ if (existsSync(targetPath)) {
258
+ renameSync(targetPath, backupPath);
259
+ backedUp.push(relPath);
219
260
  }
261
+
262
+ renameSync(stagedPath, targetPath);
263
+ installed.push(relPath);
220
264
  }
221
265
 
222
- // Run npm install for dependencies (skip if node_modules is a symlink = dev mode)
223
- const nmPath = join(INSTALL_DIR, 'node_modules');
224
- if (!existsSync(nmPath) || !lstatSync(nmPath).isSymbolicLink()) {
266
+ rmSync(stagingDir, { recursive: true, force: true });
267
+ rmSync(backupDir, { recursive: true, force: true });
268
+ debugLog('DEBUG', 'hook-update', `Auto-update: switched ${installed.length} paths`);
269
+ return true;
270
+ } catch (err) {
271
+ debugCatch(err, 'installExtractedRelease');
272
+
273
+ for (const relPath of installed.reverse()) {
274
+ try { rmSync(join(targetDir, relPath), { recursive: true, force: true }); } catch {}
275
+ }
276
+ for (const relPath of backedUp.reverse()) {
277
+ const backupPath = join(backupDir, relPath);
278
+ const targetPath = join(targetDir, relPath);
225
279
  try {
226
- execSync('npm install --omit=dev', {
227
- cwd: INSTALL_DIR,
228
- timeout: 60000,
229
- stdio: 'pipe',
230
- });
231
- } catch (err) {
232
- debugCatch(err, 'downloadAndInstall-npm');
233
- // Non-fatal: old node_modules may still work
280
+ if (existsSync(backupPath)) {
281
+ mkdirSync(dirname(targetPath), { recursive: true });
282
+ renameSync(backupPath, targetPath);
283
+ }
284
+ } catch (restoreErr) {
285
+ debugCatch(restoreErr, `installExtractedRelease-restore-${relPath}`);
234
286
  }
235
287
  }
236
288
 
237
- debugLog('DEBUG', 'hook-update', `Auto-update: ${copied} files copied`);
238
- return true;
239
- } catch (err) {
240
- debugCatch(err, 'downloadAndInstall');
289
+ try { rmSync(stagingDir, { recursive: true, force: true }); } catch {}
290
+ try { rmSync(backupDir, { recursive: true, force: true }); } catch {}
241
291
  return false;
242
- } finally {
243
- try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
244
292
  }
245
293
  }
246
294
 
295
+ function copyReleaseIntoStaging(sourceDir, stagingDir) {
296
+ let copied = 0;
297
+
298
+ for (const f of SOURCE_FILES) {
299
+ const src = join(sourceDir, f);
300
+ const dest = join(stagingDir, f);
301
+ if (!existsSync(src)) continue;
302
+ mkdirSync(dirname(dest), { recursive: true });
303
+ copyFileSync(src, dest);
304
+ copied++;
305
+ }
306
+
307
+ for (const dirName of ['scripts', 'registry']) {
308
+ const srcDir = join(sourceDir, dirName);
309
+ const destDir = join(stagingDir, dirName);
310
+ if (!existsSync(srcDir)) continue;
311
+ mkdirSync(destDir, { recursive: true });
312
+ for (const entry of readdirSync(srcDir)) {
313
+ copyFileSync(join(srcDir, entry), join(destDir, entry));
314
+ }
315
+ }
316
+
317
+ const stagedScripts = join(stagingDir, 'scripts');
318
+ if (existsSync(stagedScripts)) {
319
+ for (const sf of readdirSync(stagedScripts).filter(n => n.endsWith('.sh'))) {
320
+ try { execSync(`chmod +x "${join(stagedScripts, sf)}"`, { stdio: 'pipe' }); } catch {}
321
+ }
322
+ }
323
+
324
+ debugLog('DEBUG', 'hook-update', `Auto-update staged ${copied} source files`);
325
+ }
326
+
247
327
  // ── State Persistence ──────────────────────────────────────
248
328
  function readState() {
249
329
  try {
package/hook.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  import { randomUUID } from 'crypto';
8
8
  import { join, basename } from 'path';
9
9
  import { readFileSync, writeFileSync, unlinkSync, readdirSync, renameSync, statSync } from 'fs';
10
+ import { homedir } from 'os';
10
11
  import {
11
12
  truncate, typeIcon, inferProject, detectBashSignificance,
12
13
  extractErrorKeywords, extractFilePaths, isRelatedToEpisode,
@@ -40,6 +41,22 @@ import { checkForUpdate } from './hook-update.mjs';
40
41
  // Background workers (llm-episode, llm-summary, resource-scan) are exempt — they're ours
41
42
  const event = process.argv[2];
42
43
  const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'resource-scan']);
44
+
45
+ // Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
46
+ // install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
47
+ // in Claude UI does not automatically remove them. Exit early to make disable actually work.
48
+ const PLUGIN_KEY = 'claude-mem-lite@sdsrss';
49
+ function isPluginExplicitlyDisabled() {
50
+ try {
51
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
52
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
53
+ return settings.enabledPlugins?.[PLUGIN_KEY] === false;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ if (event && isPluginExplicitlyDisabled()) process.exit(0);
43
60
  if (process.env.CLAUDE_MEM_HOOK_RUNNING && !BG_EVENTS.has(event)) process.exit(0);
44
61
 
45
62
  // Crash-safe: flush episode buffer on unexpected termination to prevent data loss
@@ -820,7 +837,10 @@ async function handleSessionStart() {
820
837
  if (updateResult?.updated) {
821
838
  process.stdout.write(`\n🔄 claude-mem-lite: v${updateResult.from} → v${updateResult.to} updated\n`);
822
839
  } else if (updateResult?.updateAvailable) {
823
- process.stdout.write(`\n📦 claude-mem-lite: v${updateResult.to} available (current: v${updateResult.from})\n`);
840
+ const hint = updateResult.installDeferred
841
+ ? ' — plugin mode only checks for updates; reinstall/update the plugin to apply it'
842
+ : '';
843
+ process.stdout.write(`\n📦 claude-mem-lite: v${updateResult.to} available (current: v${updateResult.from})${hint}\n`);
824
844
  }
825
845
  } catch (e) { debugCatch(e, 'session-start-update'); }
826
846
 
package/install.mjs CHANGED
@@ -22,6 +22,8 @@ const INSTALL_DIR = DATA_DIR;
22
22
  const SERVER_PATH = join(INSTALL_DIR, 'server.mjs');
23
23
  const HOOK_PATH = join(INSTALL_DIR, 'hook.mjs');
24
24
  const MARKETPLACE_KEY = 'sdsrss';
25
+ const PLUGIN_KEY = `claude-mem-lite@${MARKETPLACE_KEY}`;
26
+ const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
25
27
 
26
28
  // ─── Curated Resource Metadata ───────────────────────────────────────────────
27
29
  // Replaces generic name-echo fallback with FTS5-optimized metadata per resource.
@@ -2033,8 +2035,8 @@ function registerVirtualResources(rdb) {
2033
2035
  return count;
2034
2036
  }
2035
2037
 
2036
- const cmd = process.argv[2];
2037
- const flags = new Set(process.argv.slice(3));
2038
+ let cmd = process.argv[2];
2039
+ let flags = new Set(process.argv.slice(3));
2038
2040
 
2039
2041
  function log(msg) { console.log(` ${msg}`); }
2040
2042
  function ok(msg) { console.log(` ✓ ${msg}`); }
@@ -2063,7 +2065,7 @@ async function install() {
2063
2065
  'server.mjs', 'server-internals.mjs', 'tool-schemas.mjs',
2064
2066
  'hook.mjs', 'hook-shared.mjs', 'hook-llm.mjs', 'hook-memory.mjs',
2065
2067
  'hook-semaphore.mjs', 'hook-episode.mjs', 'hook-context.mjs', 'hook-handoff.mjs', 'hook-update.mjs',
2066
- 'haiku-client.mjs', 'utils.mjs', 'schema.mjs', 'package.json', 'skill.md',
2068
+ 'haiku-client.mjs', 'utils.mjs', 'schema.mjs', 'package.json', 'package-lock.json', 'skill.md',
2067
2069
  'registry.mjs', 'registry-scanner.mjs', 'registry-indexer.mjs',
2068
2070
  'registry-retriever.mjs', 'resource-discovery.mjs',
2069
2071
  'dispatch.mjs', 'dispatch-inject.mjs', 'dispatch-feedback.mjs', 'dispatch-patterns.mjs', 'dispatch-workflow.mjs',
@@ -2120,17 +2122,15 @@ async function install() {
2120
2122
  // 2. npm install (skip for --dev: node_modules is symlinked)
2121
2123
  if (IS_DEV) {
2122
2124
  ok('Dependencies: using dev dir (symlinked)');
2123
- } else if (!existsSync(join(INSTALL_DIR, 'node_modules'))) {
2124
- log('Installing dependencies...');
2125
+ } else {
2126
+ log('Ensuring dependencies installed...');
2125
2127
  try {
2126
- execSync('npm install --omit=dev', { cwd: INSTALL_DIR, stdio: 'pipe' });
2128
+ execSync(NPM_INSTALL_CMD, { cwd: INSTALL_DIR, stdio: 'pipe' });
2127
2129
  ok('Dependencies installed');
2128
2130
  } catch (e) {
2129
2131
  fail('npm install failed: ' + e.message);
2130
2132
  process.exit(1);
2131
2133
  }
2132
- } else {
2133
- ok('Dependencies already installed');
2134
2134
  }
2135
2135
 
2136
2136
  // 3. Register MCP server
@@ -2187,6 +2187,9 @@ async function install() {
2187
2187
  // 4. Configure hooks (merge: preserve user's existing hooks, replace ours)
2188
2188
  log('Configuring hooks...');
2189
2189
  const settings = readSettings();
2190
+ if (clearPluginDisabledMarkerForDirectInstall(settings)) {
2191
+ ok('Cleared stale disabled plugin flag so install.mjs-managed hooks can run');
2192
+ }
2190
2193
  settings.hooks = settings.hooks || {};
2191
2194
 
2192
2195
  const PREFILTER_PATH = join(INSTALL_DIR, 'scripts', 'post-tool-use.sh');
@@ -2543,74 +2546,72 @@ async function uninstall() {
2543
2546
 
2544
2547
  // 2. Remove hooks from settings.json (match both npx and git-clone install paths)
2545
2548
  const settings = readSettings();
2546
- if (settings.hooks) {
2547
- for (const [event, configs] of Object.entries(settings.hooks)) {
2548
- if (!Array.isArray(configs)) continue;
2549
- settings.hooks[event] = configs.filter(cfg => !isMemHook(cfg));
2550
- if (settings.hooks[event].length === 0) delete settings.hooks[event];
2549
+ cleanupMemHooksFromSettings(settings);
2550
+
2551
+ // 3. Clean plugin registry entries conservatively (avoid deleting other plugins
2552
+ // from the same marketplace publisher)
2553
+ const pluginsDir = join(homedir(), '.claude', 'plugins');
2554
+ const installedPath = join(pluginsDir, 'installed_plugins.json');
2555
+ let canRemoveMarketplaceArtifacts;
2556
+ try {
2557
+ const installed = JSON.parse(readFileSync(installedPath, 'utf8'));
2558
+ const plugins = getInstalledPluginEntries(installed);
2559
+ let cleaned = false;
2560
+ if (PLUGIN_KEY in plugins) {
2561
+ delete plugins[PLUGIN_KEY];
2562
+ cleaned = true;
2551
2563
  }
2552
- if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
2564
+ canRemoveMarketplaceArtifacts = !hasOtherMarketplacePlugins(installed);
2565
+ if (cleaned) {
2566
+ writeFileSync(installedPath, JSON.stringify(installed, null, 2) + '\n');
2567
+ ok('Removed from installed_plugins.json');
2568
+ }
2569
+ } catch {
2570
+ // Conservative default: if registry shape is unknown, preserve marketplace cache.
2571
+ canRemoveMarketplaceArtifacts = false;
2553
2572
  }
2554
2573
 
2555
- // 3. Clean plugin system entries from settings.json
2556
- const pluginKey = `claude-mem-lite@${MARKETPLACE_KEY}`;
2574
+ // 4. Clean plugin system entries from settings.json
2557
2575
  const marketplaceKey = MARKETPLACE_KEY;
2558
2576
  if (settings.enabledPlugins) {
2559
- delete settings.enabledPlugins[pluginKey];
2577
+ delete settings.enabledPlugins[PLUGIN_KEY];
2560
2578
  }
2561
- if (settings.extraKnownMarketplaces) {
2579
+ if (settings.extraKnownMarketplaces && canRemoveMarketplaceArtifacts) {
2562
2580
  delete settings.extraKnownMarketplaces[marketplaceKey];
2563
2581
  }
2564
2582
  writeSettings(settings);
2565
2583
  ok('Hooks and plugin settings cleaned');
2566
2584
 
2567
- // 4. Clean plugin system registry files
2568
- const pluginsDir = join(homedir(), '.claude', 'plugins');
2569
-
2570
- // 4a. Remove marketplace directory
2585
+ // 5. Clean plugin system registry files (only if no other marketplace plugins remain)
2571
2586
  const marketplaceDir = join(pluginsDir, 'marketplaces', marketplaceKey);
2572
- if (existsSync(marketplaceDir)) {
2587
+ if (canRemoveMarketplaceArtifacts && existsSync(marketplaceDir)) {
2573
2588
  rmSync(marketplaceDir, { recursive: true, force: true });
2574
2589
  ok('Marketplace directory removed');
2575
2590
  }
2576
2591
 
2577
- // 4b. Remove cache directory
2592
+ // 5b. Remove cache directory
2578
2593
  const cacheDir = join(pluginsDir, 'cache', marketplaceKey);
2579
- if (existsSync(cacheDir)) {
2594
+ if (canRemoveMarketplaceArtifacts && existsSync(cacheDir)) {
2580
2595
  rmSync(cacheDir, { recursive: true, force: true });
2581
2596
  ok('Plugin cache removed');
2582
2597
  }
2583
2598
 
2584
- // 4c. Clean known_marketplaces.json
2599
+ // 5c. Clean known_marketplaces.json
2585
2600
  const knownPath = join(pluginsDir, 'known_marketplaces.json');
2586
2601
  try {
2587
2602
  const known = JSON.parse(readFileSync(knownPath, 'utf8'));
2588
- if (marketplaceKey in known) {
2603
+ if (canRemoveMarketplaceArtifacts && marketplaceKey in known) {
2589
2604
  delete known[marketplaceKey];
2590
2605
  writeFileSync(knownPath, JSON.stringify(known, null, 2) + '\n');
2591
2606
  ok('Removed from known_marketplaces.json');
2592
2607
  }
2593
2608
  } catch { /* file may not exist */ }
2594
2609
 
2595
- // 4d. Clean installed_plugins.json
2596
- const installedPath = join(pluginsDir, 'installed_plugins.json');
2597
- try {
2598
- const installed = JSON.parse(readFileSync(installedPath, 'utf8'));
2599
- const plugins = installed.plugins || installed;
2600
- let cleaned = false;
2601
- for (const key of Object.keys(plugins)) {
2602
- if (key.includes('claude-mem-lite') || key.includes('sdsrss')) {
2603
- delete plugins[key];
2604
- cleaned = true;
2605
- }
2606
- }
2607
- if (cleaned) {
2608
- writeFileSync(installedPath, JSON.stringify(installed, null, 2) + '\n');
2609
- ok('Removed from installed_plugins.json');
2610
- }
2611
- } catch { /* file may not exist */ }
2610
+ if (!canRemoveMarketplaceArtifacts && (existsSync(marketplaceDir) || existsSync(cacheDir))) {
2611
+ log('Marketplace cache preserved (other plugins may still depend on sdsrss marketplace)');
2612
+ }
2612
2613
 
2613
- // 5. Purge data if requested
2614
+ // 6. Purge data if requested
2614
2615
  if (flags.has('--purge')) {
2615
2616
  const expectedPurgePath = join(homedir(), '.claude-mem-lite');
2616
2617
  if (existsSync(DATA_DIR) && DATA_DIR === expectedPurgePath) {
@@ -2626,6 +2627,24 @@ async function uninstall() {
2626
2627
  console.log('\n Done!\n');
2627
2628
  }
2628
2629
 
2630
+ // ─── Cleanup Hooks ───────────────────────────────────────────────────────────
2631
+
2632
+ async function cleanupHooks() {
2633
+ console.log('\nclaude-mem-lite cleanup-hooks\n');
2634
+
2635
+ const settings = readSettings();
2636
+ const removed = cleanupMemHooksFromSettings(settings);
2637
+
2638
+ if (removed > 0) {
2639
+ writeSettings(settings);
2640
+ ok(`Removed ${removed} claude-mem-lite hook configuration${removed === 1 ? '' : 's'} from settings.json`);
2641
+ } else {
2642
+ ok('No claude-mem-lite hooks found in settings.json');
2643
+ }
2644
+
2645
+ console.log('');
2646
+ }
2647
+
2629
2648
  // ─── Status ─────────────────────────────────────────────────────────────────
2630
2649
 
2631
2650
  async function status() {
@@ -2645,11 +2664,24 @@ async function status() {
2645
2664
 
2646
2665
  // Hooks
2647
2666
  const settings = readSettings();
2648
- const hasHooks = settings.hooks && Object.values(settings.hooks).some(configs =>
2649
- configs.some(cfg => cfg.hooks?.some(h => h.command?.includes('hook.mjs')))
2650
- );
2651
- if (hasHooks) {
2667
+ const hasHooks = hasMemHooksConfigured(settings);
2668
+ const pluginDisabled = isPluginExplicitlyDisabled(settings);
2669
+ const pluginEnabled = settings.enabledPlugins?.[PLUGIN_KEY] === true;
2670
+
2671
+ if (pluginEnabled) {
2672
+ ok('Plugin: enabled in settings');
2673
+ } else if (pluginDisabled) {
2674
+ warn('Plugin: disabled in settings');
2675
+ } else {
2676
+ warn('Plugin: not present in enabledPlugins');
2677
+ }
2678
+
2679
+ if (hasHooks && pluginDisabled) {
2680
+ warn('Hooks: still configured in settings.json while plugin is disabled (runtime ignores them; run cleanup-hooks or uninstall to clean up)');
2681
+ } else if (hasHooks) {
2652
2682
  ok('Hooks: configured');
2683
+ } else if (pluginDisabled) {
2684
+ ok('Hooks: not configured');
2653
2685
  } else {
2654
2686
  fail('Hooks: not configured');
2655
2687
  }
@@ -2727,6 +2759,21 @@ async function doctor() {
2727
2759
  issues++;
2728
2760
  }
2729
2761
 
2762
+ // Plugin/hook lifecycle state
2763
+ const settings = readSettings();
2764
+ const hasHooks = hasMemHooksConfigured(settings);
2765
+ const pluginDisabled = isPluginExplicitlyDisabled(settings);
2766
+ if (pluginDisabled && hasHooks) {
2767
+ fail('Plugin lifecycle: plugin is disabled but claude-mem-lite hooks still remain in settings.json');
2768
+ issues++;
2769
+ } else if (pluginDisabled) {
2770
+ ok('Plugin lifecycle: disabled cleanly (no active mem hooks)');
2771
+ } else if (hasHooks) {
2772
+ ok('Plugin lifecycle: hooks active');
2773
+ } else {
2774
+ warn('Plugin lifecycle: hooks not configured');
2775
+ }
2776
+
2730
2777
  // Database
2731
2778
  if (existsSync(DB_PATH)) {
2732
2779
  try {
@@ -2799,6 +2846,50 @@ function isMemHook(cfg) {
2799
2846
  });
2800
2847
  }
2801
2848
 
2849
+ function hasMemHooksConfigured(settings) {
2850
+ if (!settings?.hooks) return false;
2851
+ return Object.values(settings.hooks).some(configs =>
2852
+ Array.isArray(configs) && configs.some(cfg => isMemHook(cfg))
2853
+ );
2854
+ }
2855
+
2856
+ export function clearPluginDisabledMarkerForDirectInstall(settings) {
2857
+ if (settings?.enabledPlugins?.[PLUGIN_KEY] !== false) return false;
2858
+ delete settings.enabledPlugins[PLUGIN_KEY];
2859
+ if (Object.keys(settings.enabledPlugins).length === 0) delete settings.enabledPlugins;
2860
+ return true;
2861
+ }
2862
+
2863
+ function cleanupMemHooksFromSettings(settings) {
2864
+ if (!settings?.hooks) return 0;
2865
+
2866
+ let removed = 0;
2867
+ for (const [event, configs] of Object.entries(settings.hooks)) {
2868
+ if (!Array.isArray(configs)) continue;
2869
+ const kept = configs.filter(cfg => !isMemHook(cfg));
2870
+ removed += configs.length - kept.length;
2871
+ if (kept.length > 0) settings.hooks[event] = kept;
2872
+ else delete settings.hooks[event];
2873
+ }
2874
+
2875
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
2876
+ return removed;
2877
+ }
2878
+
2879
+ function isPluginExplicitlyDisabled(settings) {
2880
+ return settings?.enabledPlugins?.[PLUGIN_KEY] === false;
2881
+ }
2882
+
2883
+ function getInstalledPluginEntries(installed) {
2884
+ if (installed?.plugins && typeof installed.plugins === 'object') return installed.plugins;
2885
+ return installed && typeof installed === 'object' ? installed : {};
2886
+ }
2887
+
2888
+ export function hasOtherMarketplacePlugins(installed, marketplaceKey = MARKETPLACE_KEY, pluginKey = PLUGIN_KEY) {
2889
+ const plugins = getInstalledPluginEntries(installed);
2890
+ return Object.keys(plugins).some(key => key !== pluginKey && key.endsWith(`@${marketplaceKey}`));
2891
+ }
2892
+
2802
2893
  function readSettings() {
2803
2894
  try {
2804
2895
  return JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
@@ -2823,10 +2914,12 @@ async function manualUpdate() {
2823
2914
  // Force check by importing hook-update (bypasses throttle for manual use)
2824
2915
  const { checkForUpdate, getCurrentVersion } = await import('./hook-update.mjs');
2825
2916
  log('Checking for updates...');
2826
- const result = await checkForUpdate();
2917
+ const result = await checkForUpdate({ force: true, allowInstall: true });
2827
2918
 
2828
2919
  if (result?.updated) {
2829
2920
  ok(`Updated: v${result.from} → v${result.to}`);
2921
+ } else if (result?.updateAvailable && result?.installDeferred) {
2922
+ warn(`v${result.to} available — plugin mode only checks for updates, reinstall/update the plugin to apply it`);
2830
2923
  } else if (result?.updateAvailable) {
2831
2924
  warn(`v${result.to} available but install failed — try: node install.mjs install`);
2832
2925
  } else {
@@ -2881,31 +2974,38 @@ function syncVersions() {
2881
2974
 
2882
2975
  // ─── Main ───────────────────────────────────────────────────────────────────
2883
2976
 
2884
- switch (cmd) {
2885
- case 'install':
2886
- await install();
2887
- break;
2888
- case 'uninstall':
2889
- await uninstall();
2890
- break;
2891
- case 'status':
2892
- await status();
2893
- break;
2894
- case 'doctor':
2895
- await doctor();
2896
- break;
2897
- case 'update':
2898
- await manualUpdate();
2899
- break;
2900
- case 'release':
2901
- syncVersions();
2902
- break;
2903
- default:
2904
- if (IS_NPX) {
2905
- // npx claude-mem-lite (no args) → auto install
2977
+ export async function main(argv = process.argv.slice(2)) {
2978
+ cmd = argv[0];
2979
+ flags = new Set(argv.slice(1));
2980
+
2981
+ switch (cmd) {
2982
+ case 'install':
2906
2983
  await install();
2907
- } else {
2908
- console.log(`
2984
+ break;
2985
+ case 'uninstall':
2986
+ await uninstall();
2987
+ break;
2988
+ case 'status':
2989
+ await status();
2990
+ break;
2991
+ case 'doctor':
2992
+ await doctor();
2993
+ break;
2994
+ case 'cleanup-hooks':
2995
+ await cleanupHooks();
2996
+ break;
2997
+ case 'update':
2998
+ await manualUpdate();
2999
+ break;
3000
+ case 'release':
3001
+ syncVersions();
3002
+ break;
3003
+ default:
3004
+ if (IS_NPX) {
3005
+ // npx claude-mem-lite (no args) → auto install
3006
+ await install();
3007
+ } else {
3008
+ console.log(`
2909
3009
  claude-mem-lite — Lightweight memory system for Claude Code
2910
3010
 
2911
3011
  Usage:
@@ -2915,10 +3015,15 @@ Usage:
2915
3015
  node install.mjs uninstall --purge Remove and delete all data
2916
3016
  node install.mjs status Show current status
2917
3017
  node install.mjs doctor Diagnose issues
3018
+ node install.mjs cleanup-hooks Remove only claude-mem-lite hooks from settings.json
2918
3019
  node install.mjs update Check for and install updates
2919
3020
  node install.mjs release Sync version to plugin.json + marketplace.json
2920
3021
 
2921
3022
  npx claude-mem-lite Install via npx (one-liner)
2922
3023
  `);
2923
- }
3024
+ }
3025
+ }
2924
3026
  }
3027
+
3028
+ const IS_MAIN = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
3029
+ if (IS_MAIN) await main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.9.1",
3
+ "version": "2.9.3",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
@@ -23,6 +23,7 @@ export const DISPATCH_SYNONYMS = {
23
23
  'plan': ['planning', 'architecture', 'spec', 'blueprint', 'rfc', 'proposal', 'roadmap'],
24
24
  'build': ['compile', 'bundle', 'webpack', 'vite', 'typescript', 'tsc', 'esbuild', 'rollup', 'parcel', 'babel', 'swc', 'transpile'],
25
25
  'lint': ['eslint', 'prettier', 'biome', 'stylelint', 'format', 'style'],
26
+ 'search': ['lookup', 'latest', 'best-practices', 'perplexity'],
26
27
  // Chinese intent mappings
27
28
  '清理': ['refactor', 'clean', 'lint', 'format', 'simplify'],
28
29
  '测试': ['test', 'testing', 'tdd', 'qa', 'spec', 'jest', 'vitest', 'pytest'],
@@ -44,6 +45,7 @@ export const DISPATCH_SYNONYMS = {
44
45
  '打包': ['bundle', 'build', 'webpack', 'vite'],
45
46
  '容器': ['docker', 'container', 'kubernetes', 'infrastructure'],
46
47
  '运维': ['devops', 'infrastructure', 'deploy', 'docker'],
48
+ '搜索': ['search', 'lookup', 'latest', 'perplexity'],
47
49
  };
48
50
 
49
51
  // ─── CJK Tokenization ───────────────────────────────────────────────────────
@@ -98,6 +100,9 @@ const CJK_INTENT_MAP = {
98
100
  '接口': 'api', '路由': 'route',
99
101
  // plan
100
102
  '规划': 'planning', '架构': 'architecture', '方案': 'plan', '设计方案': 'architecture',
103
+ // search — only web/info search, NOT code search (grep/find)
104
+ '联网搜索': 'search', '网上搜索': 'search', '查资料': 'search', '找资料': 'search',
105
+ '搜索最新': 'search', '搜索资料': 'search', '搜索文档': 'search',
101
106
  };
102
107
 
103
108
  // Merge all CJK keys from both maps, longest-first to avoid partial matches