claude-mem-lite 2.61.0 → 2.63.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.61.0",
13
+ "version": "2.63.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.61.0",
3
+ "version": "2.63.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/hook-shared.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import { execFileSync, spawn } from 'child_process';
5
5
  import { randomUUID } from 'crypto';
6
6
  import { join } from 'path';
7
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync, statSync, unlinkSync } from 'fs';
8
8
  import { inferProject, debugCatch } from './utils.mjs';
9
9
  import { ensureDb, DB_DIR } from './schema.mjs';
10
10
  import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI } from './haiku-client.mjs';
@@ -62,6 +62,37 @@ export const HANDOFF_ANCHOR_MAX_AGE = 72 * 3600000; // 72h cap on gi
62
62
  export const HANDOFF_MATCH_THRESHOLD = 3; // min weighted score
63
63
  export const CONTINUE_KEYWORDS = /继续|接着|上次|之前的|前面的|刚才|\bcontinue\b|\bresume\b|\bwhere[\s-]+we[\s-]+left\b|\bpick[\s-]+up\b|\bcarry[\s-]+on\b/i;
64
64
 
65
+ // Orphan-sweep threshold for `ep-flush-*` / `pending-*` runtime artifacts.
66
+ // handleLLMEpisode's worst-case round-trip is ~60s (delay + LLM call + DB
67
+ // write); 1h leaves a wide safety margin against deleting an in-flight file.
68
+ // Older orphans are crashed workers or pre-shutdown buffers that no live
69
+ // caller will ever pick up, so sweeping them on SessionStart is safe.
70
+ export const ORPHAN_EPISODE_AGE_MS = 60 * 60 * 1000;
71
+
72
+ // Sweep stale `ep-flush-*` and `pending-*` files in `runtimeDir` whose mtime
73
+ // is older than `ageMs` (default 1h). Returns the number of files removed.
74
+ // fs-only — no DB / no network. Used by handleSessionStart auto-maintain to
75
+ // prevent the doctor "Stale temp files" warning from accumulating across
76
+ // crashes; equivalent to the manual path in `node install.mjs cleanup` but
77
+ // age-gated so concurrent in-flight workers are never raced.
78
+ export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE_MS, now = Date.now() } = {}) {
79
+ let entries;
80
+ try { entries = readdirSync(runtimeDir); } catch { return 0; }
81
+ const cutoff = now - ageMs;
82
+ let count = 0;
83
+ for (const f of entries) {
84
+ if (!(f.startsWith('ep-flush-') || f.startsWith('pending-'))) continue;
85
+ const full = join(runtimeDir, f);
86
+ try {
87
+ if (statSync(full).mtimeMs < cutoff) {
88
+ unlinkSync(full);
89
+ count++;
90
+ }
91
+ } catch { /* concurrent unlink / permission — ignore */ }
92
+ }
93
+ return count;
94
+ }
95
+
65
96
  // Ensure runtime directory exists
66
97
  try { if (!existsSync(RUNTIME_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true }); } catch {}
67
98
 
package/hook.mjs CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  RUNTIME_DIR, EPISODE_BUFFER_SIZE, EPISODE_TIME_GAP_MS,
41
41
  SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS,
42
42
  sessionFile, getSessionId, createSessionId, openDb,
43
- spawnBackground,
43
+ spawnBackground, sweepOrphanEpisodeFiles,
44
44
  } from './hook-shared.mjs';
45
45
  import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation } from './hook-llm.mjs';
46
46
  import { extractCitationsFromTranscript, bumpCitationAccess, computeCiteRecall } from './lib/citation-tracker.mjs';
@@ -885,6 +885,17 @@ async function handleSessionStart() {
885
885
  }
886
886
  }
887
887
 
888
+ // Orphan sweep: remove `ep-flush-*` / `pending-*` runtime files older
889
+ // than 1h. handleLLMEpisode normally unlinks its own tmpFile on every
890
+ // exit path, but a crashed worker (OOM, host reboot, kill -9) leaves
891
+ // the file behind, and the doctor "Stale temp files" warning then
892
+ // accumulates indefinitely. fs-only; runs inside the 24h gate so it
893
+ // shares cadence with the rest of auto-maintain.
894
+ try {
895
+ const swept = sweepOrphanEpisodeFiles(RUNTIME_DIR);
896
+ if (swept > 0) debugLog('DEBUG', 'auto-maintain', `swept ${swept} orphan ep-flush/pending file(s)`);
897
+ } catch (e) { debugCatch(e, 'auto-maintain-orphan-sweep'); }
898
+
888
899
  // Mark maintenance as done (24h gate) — even though compression runs in background
889
900
  writeFileSync(maintainFile, JSON.stringify({ epoch: Date.now() }));
890
901
  // Weekly summary grouping runs in background to avoid blocking SessionStart
package/install.mjs CHANGED
@@ -280,6 +280,38 @@ function ok(msg) { console.log(` ✓ ${msg}`); }
280
280
  function warn(msg) { console.log(` ⚠ ${msg}`); }
281
281
  function fail(msg) { console.log(` ✗ ${msg}`); }
282
282
 
283
+ // Pure JSON-version field bumper for the release pipeline. Reads `filePath`,
284
+ // walks `keyPath` (e.g. `['version']` or `['plugins', 0, 'version']`), and
285
+ // rewrites only when the new value differs. Returns `{ changed, prev }` so
286
+ // callers can log "X → Y" with the captured-before-mutation value — pre-2.63.0
287
+ // the plugin.json branch in syncVersions logged "Y → Y" because it read the
288
+ // field after assignment.
289
+ export function bumpJsonField(filePath, keyPath, newVal) {
290
+ const json = JSON.parse(readFileSync(filePath, 'utf8'));
291
+ let parent = json;
292
+ for (let i = 0; i < keyPath.length - 1; i++) parent = parent?.[keyPath[i]];
293
+ if (!parent) return { changed: false, prev: undefined };
294
+ const lastKey = keyPath[keyPath.length - 1];
295
+ const prev = parent[lastKey];
296
+ if (prev === newVal) return { changed: false, prev };
297
+ parent[lastKey] = newVal;
298
+ writeFileSync(filePath, JSON.stringify(json, null, 2) + '\n');
299
+ return { changed: true, prev };
300
+ }
301
+
302
+ // Doctor's final summary line. Pure function so the 4-way contract
303
+ // (clean / warnings-only / issues / mixed) is unit-testable without spinning
304
+ // up the full doctor pipeline. `issues` are ✗-level (action required);
305
+ // `warnings` are ⚠-level (informational, "All checks passed!" must NOT lie
306
+ // about them).
307
+ export function buildDoctorSummary(issues, warnings) {
308
+ const wPlural = warnings === 1 ? '' : 's';
309
+ if (issues === 0 && warnings === 0) return 'All checks passed!';
310
+ if (issues === 0) return `All critical checks passed (${warnings} warning${wPlural}).`;
311
+ const warnSuffix = warnings > 0 ? ` (+${warnings} warning${wPlural})` : '';
312
+ return `${issues} issue(s) found.${warnSuffix}`;
313
+ }
314
+
283
315
  // Dev installs symlink server.mjs → the project's source file. Used to suppress
284
316
  // misleading "first run" messages since hook-update.mjs skips state-writes in
285
317
  // this mode (see hook-update.mjs isDevMode).
@@ -1152,6 +1184,13 @@ async function status() {
1152
1184
  async function doctor() {
1153
1185
  console.log('\nclaude-mem-lite doctor\n');
1154
1186
  let issues = 0;
1187
+ let warnings = 0;
1188
+ // Doctor-local ⚠ helper: visually identical to the file-level `warn`, but
1189
+ // bumps `warnings` so the summary line can distinguish "fully green" from
1190
+ // "warnings present". Used for informational ⚠ checks; the two ⚠ paths
1191
+ // that ALSO bump `issues` (stale procs, dev drift) keep using the file-level
1192
+ // `warn` directly to avoid double-counting.
1193
+ const dwarn = (msg) => { warnings++; console.log(` ⚠ ${msg}`); };
1155
1194
 
1156
1195
  // Node version
1157
1196
  const nodeVer = process.version;
@@ -1209,7 +1248,7 @@ async function doctor() {
1209
1248
  } else if (hasHooks) {
1210
1249
  ok('Plugin lifecycle: hooks active');
1211
1250
  } else {
1212
- warn('Plugin lifecycle: hooks not configured');
1251
+ dwarn('Plugin lifecycle: hooks not configured');
1213
1252
  }
1214
1253
 
1215
1254
  // Database
@@ -1232,7 +1271,7 @@ async function doctor() {
1232
1271
  if (healthy) {
1233
1272
  ok('FTS5 integrity: all indexes healthy');
1234
1273
  } else {
1235
- warn('FTS5 integrity issues detected:');
1274
+ dwarn('FTS5 integrity issues detected:');
1236
1275
  for (const d of details) log(` ${d}`);
1237
1276
  log(' Attempting FTS5 rebuild...');
1238
1277
  const { rebuilt, errors } = rebuildFTS(rwDb);
@@ -1243,17 +1282,17 @@ async function doctor() {
1243
1282
  rwDb.close();
1244
1283
  }
1245
1284
  } catch (e) {
1246
- warn('FTS5 integrity check failed: ' + e.message);
1285
+ dwarn('FTS5 integrity check failed: ' + e.message);
1247
1286
  }
1248
1287
  } else {
1249
- warn('FTS5 index: missing (will be created on server start)');
1288
+ dwarn('FTS5 index: missing (will be created on server start)');
1250
1289
  }
1251
1290
  } catch (e) {
1252
1291
  fail('Database: ' + e.message);
1253
1292
  issues++;
1254
1293
  }
1255
1294
  } else {
1256
- warn('Database: not found (will be created)');
1295
+ dwarn('Database: not found (will be created)');
1257
1296
  }
1258
1297
 
1259
1298
  // Check for stale processes
@@ -1287,10 +1326,10 @@ async function doctor() {
1287
1326
  // short-circuits before writing state (see hook-update.mjs isDevMode).
1288
1327
  ok('Update state: skipped (dev mode — symlinked install)');
1289
1328
  } else {
1290
- warn('Update state: no state file (first run?)');
1329
+ dwarn('Update state: no state file (first run?)');
1291
1330
  }
1292
1331
  } catch {
1293
- warn('Update state: failed to read');
1332
+ dwarn('Update state: failed to read');
1294
1333
  }
1295
1334
 
1296
1335
  // Dev drift: in dev-mode installs, all SOURCE_FILES entries should be
@@ -1310,7 +1349,7 @@ async function doctor() {
1310
1349
  }
1311
1350
  // Prod (all plain) install: no message — dev-drift is a dev-only concern.
1312
1351
  } catch (e) {
1313
- warn('Dev drift: check failed — ' + e.message);
1352
+ dwarn('Dev drift: check failed — ' + e.message);
1314
1353
  }
1315
1354
 
1316
1355
  // Stale temp files
@@ -1329,12 +1368,12 @@ async function doctor() {
1329
1368
  }
1330
1369
  }
1331
1370
  if (staleCount > 0) {
1332
- warn(`Stale temp files: ${staleCount} found (run: node install.mjs cleanup)`);
1371
+ dwarn(`Stale temp files: ${staleCount} found (run: node install.mjs cleanup)`);
1333
1372
  } else {
1334
1373
  ok('Stale temp files: none');
1335
1374
  }
1336
1375
  } catch {
1337
- warn('Stale temp files: check failed');
1376
+ dwarn('Stale temp files: check failed');
1338
1377
  }
1339
1378
 
1340
1379
  // DB stats
@@ -1350,7 +1389,7 @@ async function doctor() {
1350
1389
  db.close();
1351
1390
  ok(`DB stats: ${sizeMB}MB, ${obsCount} observations, ${sessCount} sessions`);
1352
1391
  } catch (e) {
1353
- warn('DB stats: ' + e.message);
1392
+ dwarn('DB stats: ' + e.message);
1354
1393
  }
1355
1394
  }
1356
1395
 
@@ -1364,14 +1403,14 @@ async function doctor() {
1364
1403
  sizeStr = execFileSync('du', ['-sh', pluginCacheBase], { encoding: 'utf8', timeout: 5000 }).trim().split('\t')[0];
1365
1404
  } catch { sizeStr = '?'; }
1366
1405
  if (versions.length > 3) {
1367
- warn(`Plugin cache: ${versions.length} versions (${sizeStr}) — run setup.sh or update to auto-prune to 3`);
1406
+ dwarn(`Plugin cache: ${versions.length} versions (${sizeStr}) — run setup.sh or update to auto-prune to 3`);
1368
1407
  } else {
1369
1408
  ok(`Plugin cache: ${versions.length} version(s) (${sizeStr})`);
1370
1409
  }
1371
1410
  } catch {}
1372
1411
  }
1373
1412
 
1374
- console.log(`\n ${issues === 0 ? 'All checks passed!' : `${issues} issue(s) found.`}\n`);
1413
+ console.log(`\n ${buildDoctorSummary(issues, warnings)}\n`);
1375
1414
  }
1376
1415
 
1377
1416
  // ─── Settings helpers ───────────────────────────────────────────────────────
@@ -1573,34 +1612,19 @@ function syncVersions() {
1573
1612
  const version = pkg.version;
1574
1613
  log(`package.json version: ${version}`);
1575
1614
 
1576
- // Sync plugin.json
1577
1615
  const pluginJsonPath = join(PROJECT_DIR, '.claude-plugin', 'plugin.json');
1578
1616
  if (existsSync(pluginJsonPath)) {
1579
- const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf8'));
1580
- if (pluginJson.version !== version) {
1581
- pluginJson.version = version;
1582
- writeFileSync(pluginJsonPath, JSON.stringify(pluginJson, null, 2) + '\n');
1583
- ok(`plugin.json: ${pluginJson.version} → ${version}`);
1584
- } else {
1585
- ok(`plugin.json: already ${version}`);
1586
- }
1617
+ const r = bumpJsonField(pluginJsonPath, ['version'], version);
1618
+ ok(r.changed ? `plugin.json: ${r.prev} → ${version}` : `plugin.json: already ${version}`);
1587
1619
  } else {
1588
1620
  warn('plugin.json not found');
1589
1621
  }
1590
1622
 
1591
- // Sync marketplace.json
1592
1623
  const marketJsonPath = join(PROJECT_DIR, '.claude-plugin', 'marketplace.json');
1593
1624
  if (existsSync(marketJsonPath)) {
1594
- const marketJson = JSON.parse(readFileSync(marketJsonPath, 'utf8'));
1595
- const plugin = marketJson.plugins?.[0];
1596
- if (plugin && plugin.version !== version) {
1597
- const prev = plugin.version;
1598
- plugin.version = version;
1599
- writeFileSync(marketJsonPath, JSON.stringify(marketJson, null, 2) + '\n');
1600
- ok(`marketplace.json: ${prev} → ${version}`);
1601
- } else if (plugin) {
1602
- ok(`marketplace.json: already ${version}`);
1603
- }
1625
+ const r = bumpJsonField(marketJsonPath, ['plugins', 0, 'version'], version);
1626
+ if (r.prev === undefined) warn('marketplace.json: plugins[0] not found');
1627
+ else ok(r.changed ? `marketplace.json: ${r.prev} → ${version}` : `marketplace.json: already ${version}`);
1604
1628
  } else {
1605
1629
  warn('marketplace.json not found');
1606
1630
  }
@@ -1629,6 +1653,29 @@ function syncVersions() {
1629
1653
  console.log('');
1630
1654
  }
1631
1655
 
1656
+ // Regenerate package-lock.json via npm@10.9.2 to guarantee CI parity. The
1657
+ // drift this prevents: `npm install --package-lock-only` on npm@11+ silently
1658
+ // strips top-level `@emnapi/core` + `@emnapi/runtime` entries when those are
1659
+ // transitive deps of platform-optional bindings (e.g. `@oxc-parser/binding-*`
1660
+ // from knip), and CI's bundled npm@10 (Node 22 default in GitHub Actions)
1661
+ // then refuses `npm ci` with EUSAGE. Same recipe bit twice (#8271 / 2.58.2 /
1662
+ // 2.62.1) before this guard. The packageManager field in package.json
1663
+ // declares the same version for corepack-aware tooling. Network cost: ~5-30s
1664
+ // per release; release cadence makes this acceptable.
1665
+ function regenerateLockfile() {
1666
+ console.log('\nclaude-mem-lite release — regenerate lockfile (npm@10.9.2)\n');
1667
+ try {
1668
+ execFileSync('npx', ['--yes', 'npm@10.9.2', 'install'], {
1669
+ stdio: 'inherit',
1670
+ cwd: PROJECT_DIR,
1671
+ });
1672
+ ok('lockfile regenerated');
1673
+ } catch (e) {
1674
+ fail('lockfile regen failed: ' + e.message);
1675
+ throw e;
1676
+ }
1677
+ }
1678
+
1632
1679
  // ─── Main ───────────────────────────────────────────────────────────────────
1633
1680
 
1634
1681
  export async function main(argv = process.argv.slice(2)) {
@@ -1660,6 +1707,7 @@ export async function main(argv = process.argv.slice(2)) {
1660
1707
  break;
1661
1708
  case 'release':
1662
1709
  syncVersions();
1710
+ if (!flags.has('--no-lock')) regenerateLockfile();
1663
1711
  break;
1664
1712
  default:
1665
1713
  if (IS_NPX) {
@@ -1679,7 +1727,7 @@ Usage:
1679
1727
  node install.mjs cleanup Remove stale temp/staging files
1680
1728
  node install.mjs cleanup-hooks Remove only claude-mem-lite hooks from settings.json
1681
1729
  node install.mjs self-update Check for and install updates
1682
- node install.mjs release Sync version to plugin.json + marketplace.json
1730
+ node install.mjs release Sync versions (plugin/marketplace/CLAUDE.md) + regen lockfile via npm@10.9.2 (use --no-lock to skip lock regen)
1683
1731
 
1684
1732
  npx claude-mem-lite Install via npx (one-liner)
1685
1733
  `);
package/mem-cli.mjs CHANGED
@@ -4,14 +4,14 @@
4
4
 
5
5
  import { homedir } from 'os';
6
6
  import { ensureDb, DB_PATH, REGISTRY_DB_PATH } from './schema.mjs';
7
- import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, jaccardSimilarity, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, isoWeekKey, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause } from './utils.mjs';
7
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, jaccardSimilarity, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, isoWeekKey, COMPRESSED_PENDING_PURGE, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause } from './utils.mjs';
8
8
  import { cjkPrecisionOk } from './nlp.mjs';
9
9
  import { extractCjkLikePatterns } from './nlp.mjs';
10
10
  import { resolveProject } from './project-utils.mjs';
11
11
  import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
12
12
  import { getVocabulary, computeVector, rebuildVocabulary, _resetVocabCache } from './tfidf.mjs';
13
13
  import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-internals.mjs';
14
- import { searchObservationsHybrid } from './search-engine.mjs';
14
+ import { searchObservationsHybrid, findFtsAnchor } from './search-engine.mjs';
15
15
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
16
16
  import { searchResources } from './registry-retriever.mjs';
17
17
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
@@ -447,6 +447,25 @@ function cmdRecall(db, args) {
447
447
 
448
448
  const OBS_FIELDS = ['id', 'type', 'title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases', 'files_read', 'files_modified', 'project', 'created_at', 'memory_session_id', 'prompt_number', 'importance', 'related_ids', 'access_count', 'branch', 'superseded_at', 'superseded_by', 'last_accessed_at'];
449
449
 
450
+ // Integer-typed time-epoch fields on the observations table that the `get`
451
+ // command renders. Callers expect raw ms (audit) AND a relative-time hint
452
+ // (human-scan), so formatObsFieldValue emits both. Other epoch fields like
453
+ // `created_at_epoch` / `optimized_at` / `last_injected_at` aren't in
454
+ // OBS_FIELDS so they don't surface via `get`.
455
+ export const OBS_TIME_FIELDS = ['superseded_at', 'last_accessed_at'];
456
+
457
+ // Pure formatter — null/undefined/non-time pass through; time fields on
458
+ // integer values render as `<raw> (<relative>)` mirroring the convention
459
+ // already used by `recent` / `timeline` / `recall`. Pre-2.63.0 the get
460
+ // path printed bare ms (e.g. `last_accessed_at: 1778357330957`).
461
+ export function formatObsFieldValue(field, val) {
462
+ if (val === null || val === undefined) return val;
463
+ if (OBS_TIME_FIELDS.includes(field) && typeof val === 'number') {
464
+ return `${val} (${relativeTime(val)})`;
465
+ }
466
+ return val;
467
+ }
468
+
450
469
  function renderObsRows(db, ids, requestedFields) {
451
470
  const placeholders = ids.map(() => '?').join(',');
452
471
  try {
@@ -465,8 +484,9 @@ function renderObsRows(db, ids, requestedFields) {
465
484
  const val = r[f];
466
485
  if (val === null || val === undefined || val === '') continue;
467
486
  if (f === 'text' && r.narrative && typeof val === 'string' && val.startsWith(r.narrative)) continue;
487
+ const formatted = formatObsFieldValue(f, val);
468
488
  const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
469
- const display = typeof val === 'string' && val.length > maxLen ? val.slice(0, maxLen) + '…' : val;
489
+ const display = typeof formatted === 'string' && formatted.length > maxLen ? formatted.slice(0, maxLen) + '…' : formatted;
470
490
  lines.push(`${f}: ${display}`);
471
491
  }
472
492
  parts.push(lines.join('\n'));
@@ -663,24 +683,20 @@ function cmdTimeline(db, args) {
663
683
  }
664
684
  }
665
685
 
666
- // Support query-based anchor: `timeline --query "search terms"` or positional
667
- // Uses recency-weighted BM25 + project filter (aligned with MCP mem_timeline)
686
+ // Support query-based anchor: `timeline --query "search terms"` or positional.
687
+ // Routes through shared findFtsAnchor (paired-path with MCP mem_timeline)
688
+ // so AND→OR fallback semantics match `search` — without this, queries like
689
+ // "ep-flush leak" miss rows whose title is "ep-flush ... leaked" that
690
+ // search would otherwise find via OR relaxation.
668
691
  const queryStr = flags.query || positional.join(' ');
669
692
  if ((!anchorId || isNaN(anchorId)) && queryStr) {
670
693
  const ftsQuery = sanitizeFtsQuery(queryStr);
671
- if (ftsQuery) {
672
- const nowT = Date.now();
673
- const match = db.prepare(`
674
- SELECT o.id FROM observations_fts
675
- JOIN observations o ON observations_fts.rowid = o.id
676
- WHERE observations_fts MATCH ?
677
- AND (? IS NULL OR o.project = ?)
678
- AND COALESCE(o.compressed_into, 0) = 0
679
- ORDER BY ${OBS_BM25}
680
- * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${DEFAULT_DECAY_HALF_LIFE_MS}.0))
681
- LIMIT 1
682
- `).get(ftsQuery, project ?? null, project ?? null, nowT);
683
- if (match) anchorId = match.id;
694
+ const found = findFtsAnchor(db, { ftsQuery, project: project ?? null });
695
+ if (found) {
696
+ anchorId = found.id;
697
+ if (found.relaxed && !anchorNote) {
698
+ anchorNote = `(query "${queryStr}" relaxed AND→OR — no row matched all terms)`;
699
+ }
684
700
  }
685
701
  }
686
702
 
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.61.0",
3
+ "version": "2.63.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
+ "packageManager": "npm@10.9.2",
6
7
  "engines": {
7
8
  "node": ">=20"
8
9
  },
package/search-engine.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import {
9
9
  OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE,
10
+ DEFAULT_DECAY_HALF_LIFE_MS,
10
11
  notLowSignalTitleClause, LOW_SIGNAL_TITLE,
11
12
  relaxFtsQueryToOr, debugLog, debugCatch,
12
13
  } from './utils.mjs';
@@ -141,6 +142,55 @@ function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includ
141
142
  * perSourceOffset, currentProject, limit, orFallbackFired }
142
143
  * @returns {Array} list of result objects (mutated ctx may set orFallbackFired)
143
144
  */
145
+ /**
146
+ * Resolve `timeline --query "..."` / mem_timeline auto-anchor to a single
147
+ * observation id. Shared between mem-cli.mjs cmdTimeline and server.mjs
148
+ * mem_timeline so both surfaces use identical AND→OR fallback semantics
149
+ * (paired-path discipline per #8217).
150
+ *
151
+ * Pipeline:
152
+ * 1. FTS5 MATCH with the sanitized query (AND-by-default), recency-weighted
153
+ * 2. If AND returns 0 → relaxFtsQueryToOr fallback (mirrors searchObservationsHybrid)
154
+ *
155
+ * Always skips compressed rows.
156
+ *
157
+ * @param {Database} db
158
+ * @param {object} opts
159
+ * @param {string|null} opts.ftsQuery pre-sanitized FTS5 query
160
+ * @param {string|null} [opts.project] restrict to this project (boost-by-membership; null = no filter)
161
+ * @param {number} [opts.nowT] Date.now() override (for deterministic tests)
162
+ * @param {number} [opts.halfLifeMs] recency half-life (default DEFAULT_DECAY_HALF_LIFE_MS)
163
+ * @returns {{id:number, relaxed:boolean}|null} `relaxed:true` when AND returned 0 and OR rescued —
164
+ * callers should surface a "(relaxed AND→OR)" hint to mirror search transparency.
165
+ */
166
+ export function findFtsAnchor(db, { ftsQuery, project = null, nowT = null, halfLifeMs = DEFAULT_DECAY_HALF_LIFE_MS } = {}) {
167
+ if (!ftsQuery) return null;
168
+ const now = nowT ?? Date.now();
169
+ const sql = `
170
+ SELECT o.id FROM observations_fts
171
+ JOIN observations o ON observations_fts.rowid = o.id
172
+ WHERE observations_fts MATCH ?
173
+ AND (? IS NULL OR o.project = ?)
174
+ AND COALESCE(o.compressed_into, 0) = 0
175
+ ORDER BY ${OBS_BM25}
176
+ * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${halfLifeMs}.0))
177
+ LIMIT 1
178
+ `;
179
+ const stmt = db.prepare(sql);
180
+ try {
181
+ const m = stmt.get(ftsQuery, project, project, now);
182
+ if (m) return { id: m.id, relaxed: false };
183
+ } catch (e) { debugCatch(e, 'findFtsAnchor-and'); }
184
+ const orQuery = relaxFtsQueryToOr(ftsQuery);
185
+ if (orQuery && orQuery !== ftsQuery) {
186
+ try {
187
+ const m = stmt.get(orQuery, project, project, now);
188
+ if (m) return { id: m.id, relaxed: true };
189
+ } catch (e) { debugCatch(e, 'findFtsAnchor-or'); }
190
+ }
191
+ return null;
192
+ }
193
+
144
194
  export function searchObservationsHybrid(db, ctx) {
145
195
  const { ftsQuery, args, epochFrom, epochTo, perSourceLimit, perSourceOffset, currentProject, limit } = ctx;
146
196
  const results = [];
package/server.mjs CHANGED
@@ -5,12 +5,12 @@
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
- import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, fmtDate, isoWeekKey, debugLog, debugCatch, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined, notLowSignalTitleClause } from './utils.mjs';
8
+ import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, fmtDate, isoWeekKey, debugLog, debugCatch, COMPRESSED_PENDING_PURGE, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined, notLowSignalTitleClause } from './utils.mjs';
9
9
  import { extractCjkLikePatterns, cjkPrecisionOk } from './nlp.mjs';
10
10
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
11
11
  import { ensureDb, DB_PATH, REGISTRY_DB_PATH } from './schema.mjs';
12
12
  import { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './server-internals.mjs';
13
- import { searchObservationsHybrid } from './search-engine.mjs';
13
+ import { searchObservationsHybrid, findFtsAnchor } from './search-engine.mjs';
14
14
  import { effectiveQuiet } from './hook-shared.mjs';
15
15
  import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
16
16
  import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
@@ -103,7 +103,7 @@ function resolveProject(name) { return _resolveProjectShared(db, name); }
103
103
  // Importance: 0.5 + 0.5 × importance (range 0.5–2.0)
104
104
  // Access bonus: 1 + 0.1 × ln(1 + access_count)
105
105
 
106
- // OBS_BM25, SESS_BM25, TYPE_DECAY_CASE imported from utils.mjs
106
+ // SESS_BM25, TYPE_DECAY_CASE imported from utils.mjs
107
107
  const RECENCY_HALF_LIFE_MS = DEFAULT_DECAY_HALF_LIFE_MS;
108
108
 
109
109
  // ─── MCP Server ─────────────────────────────────────────────────────────────
@@ -612,23 +612,19 @@ server.registerTool(
612
612
  }
613
613
  }
614
614
 
615
- // Auto-find anchor via FTS (with recency decay)
615
+ // Auto-find anchor via FTS (with recency decay). Routes through shared
616
+ // findFtsAnchor so CLI `timeline --query` and MCP mem_timeline use
617
+ // identical AND→OR fallback semantics (paired-path per #8217). When the
618
+ // OR fallback fired, surface a hint so the caller knows the match was
619
+ // not an exact AND coverage of the query — mirrors search transparency.
616
620
  if (!anchorId && args.query) {
617
621
  const ftsQuery = sanitizeFtsQuery(args.query);
618
- if (ftsQuery) {
619
- const nowT = Date.now();
620
- const row = db.prepare(`
621
- SELECT o.id
622
- FROM observations_fts
623
- JOIN observations o ON observations_fts.rowid = o.id
624
- WHERE observations_fts MATCH ?
625
- AND (? IS NULL OR o.project = ?)
626
- AND COALESCE(o.compressed_into, 0) = 0
627
- ORDER BY ${OBS_BM25}
628
- * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${RECENCY_HALF_LIFE_MS}.0))
629
- LIMIT 1
630
- `).get(ftsQuery, args.project ?? null, args.project ?? null, nowT);
631
- if (row) anchorId = row.id;
622
+ const found = findFtsAnchor(db, { ftsQuery, project: args.project ?? null });
623
+ if (found) {
624
+ anchorId = found.id;
625
+ if (found.relaxed && !anchorNote) {
626
+ anchorNote = `(query "${args.query}" relaxed AND→OR — no row matched all terms)`;
627
+ }
632
628
  }
633
629
  }
634
630