claude-mem-lite 2.98.0 → 2.99.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.
package/server.mjs CHANGED
@@ -5,13 +5,14 @@
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 { truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, scrubSecrets, fmtDate, debugLog, debugCatch, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined } from './utils.mjs';
9
- import { extractCjkLikePatterns, cjkPrecisionOk } from './nlp.mjs';
8
+ import { truncate, typeIcon, inferProject, scrubSecrets, fmtDate, debugLog, debugCatch, isPathConfined } from './utils.mjs';
10
9
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
11
10
  import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
12
11
  import { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './server-internals.mjs';
13
- import { searchObservationsHybrid, findFtsAnchor, countSearchTotal } from './search-engine.mjs';
12
+ import { searchObservationsHybrid, countSearchTotal } from './search-engine.mjs';
14
13
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
14
+ import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
15
+ import { buildSearchFtsQuery, parseDateBounds, computePerSourceWindow, effectiveObsFtsQuery, searchSessionsFts, searchPromptsFts, normalizeCrossSourceScores, applyUserSort, applyTierFilter } from './lib/search-core.mjs';
15
16
  import {
16
17
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
17
18
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
@@ -19,7 +20,7 @@ import {
19
20
  OP_CAP, STALE_AGE_MS,
20
21
  } from './lib/maintain-core.mjs';
21
22
  import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
22
- import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
23
+ import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
23
24
  import { formatObsFieldValue } from './cli/common.mjs';
24
25
  import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, memDeferSchema, memDeferListSchema, memDeferDropSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
25
26
 
@@ -36,7 +37,7 @@ import { join, sep } from 'path';
36
37
  import { homedir } from 'os';
37
38
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
38
39
  import { searchResources } from './registry-retriever.mjs';
39
- import { probeOtherSources as probeIdSources, parseIdToken, bucketIdTokens } from './lib/id-routing.mjs';
40
+ import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
40
41
  import { saveObservation } from './lib/save-observation.mjs';
41
42
  import { rebuildObservationDerived } from './lib/observation-write.mjs';
42
43
  import { recallByFile } from './lib/recall-core.mjs';
@@ -91,7 +92,7 @@ function getRegistryDb() {
91
92
  return registryDb;
92
93
  }
93
94
 
94
- // inferProject, jaccardSimilarity, sanitizeFtsQuery, typeIcon, truncate, fmtDate imported from utils.mjs
95
+ // inferProject, typeIcon, truncate, fmtDate imported from utils.mjs
95
96
 
96
97
  // ─── Project Name Resolution ────────────────────────────────────────────────
97
98
  // Users naturally type short names like "mem" but inferProject() stores
@@ -118,8 +119,7 @@ function resolveProject(name) { return _resolveProjectShared(db, name); }
118
119
  // Importance: 0.5 + 0.5 × importance (range 0.5–2.0)
119
120
  // Access bonus: 1 + 0.1 × ln(1 + access_count)
120
121
 
121
- // SESS_BM25, TYPE_DECAY_CASE imported from utils.mjs
122
- const RECENCY_HALF_LIFE_MS = DEFAULT_DECAY_HALF_LIFE_MS;
122
+ // Session/prompt FTS scoring (SESS_BM25 + recency decay) lives in lib/search-core.mjs.
123
123
 
124
124
  // ─── MCP Server ─────────────────────────────────────────────────────────────
125
125
 
@@ -176,30 +176,11 @@ function searchSessions(ctx) {
176
176
  const results = [];
177
177
 
178
178
  if (ftsQuery) {
179
- const now = Date.now();
180
- const sessionProjectBoost = args.project ? null : currentProject;
181
- const rows = db.prepare(`
182
- SELECT s.id, s.request, s.completed, s.project, s.created_at, s.created_at_epoch,
183
- ${SESS_BM25}
184
- * (1.0 + EXP(-0.693 * (? - s.created_at_epoch) / ${RECENCY_HALF_LIFE_MS}.0))
185
- * (CASE WHEN ? IS NOT NULL AND s.project = ? THEN 2.0 ELSE 1.0 END) as score
186
- FROM session_summaries_fts
187
- JOIN session_summaries s ON session_summaries_fts.rowid = s.id
188
- WHERE session_summaries_fts MATCH ?
189
- AND (? IS NULL OR s.project = ?)
190
- AND (? IS NULL OR s.created_at_epoch >= ?)
191
- AND (? IS NULL OR s.created_at_epoch <= ?)
192
- ORDER BY score
193
- LIMIT ? OFFSET ?
194
- `).all(
195
- now,
196
- sessionProjectBoost, sessionProjectBoost,
197
- ftsQuery,
198
- args.project ?? null, args.project ?? null,
199
- epochFrom, epochFrom,
200
- epochTo, epochTo,
201
- perSourceLimit, perSourceOffset
202
- );
179
+ const rows = searchSessionsFts(db, {
180
+ ftsQuery, project: args.project ?? null,
181
+ projectBoost: args.project ? null : currentProject,
182
+ epochFrom, epochTo, perSourceLimit, perSourceOffset,
183
+ });
203
184
  for (const r of rows) {
204
185
  results.push({ source: 'session', id: r.id, request: r.request, completed: r.completed, project: r.project, date: r.created_at, created_at_epoch: r.created_at_epoch, score: r.score });
205
186
  }
@@ -232,68 +213,15 @@ function searchPrompts(ctx) {
232
213
  const results = [];
233
214
 
234
215
  if (ftsQuery) {
235
- const rows = db.prepare(`
236
- SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch,
237
- bm25(user_prompts_fts, 1) as score
238
- FROM user_prompts_fts
239
- JOIN user_prompts p ON user_prompts_fts.rowid = p.id
240
- JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
241
- WHERE user_prompts_fts MATCH ?
242
- AND p.prompt_text NOT LIKE '<task-notification>%'
243
- AND (? IS NULL OR s.project = ?)
244
- AND (? IS NULL OR p.created_at_epoch >= ?)
245
- AND (? IS NULL OR p.created_at_epoch <= ?)
246
- ORDER BY score
247
- LIMIT ? OFFSET ?
248
- `).all(
249
- ftsQuery,
250
- args.project ?? null, args.project ?? null,
251
- epochFrom, epochFrom,
252
- epochTo, epochTo,
253
- perSourceLimit, perSourceOffset
254
- );
255
- // CJK precision filter: unicode61 FTS degrades CJK bigram queries to
256
- // single-char AND, letting any prose sharing common chars leak through.
257
- // Require ≥30% of query's CJK bigrams/keywords as contiguous substrings.
258
- const keptRows = args.query ? rows.filter(r => cjkPrecisionOk(args.query, r.prompt_text)) : rows;
259
- for (const r of keptRows) {
216
+ // CJK precision gate + LIKE fallback live in the shared core (see
217
+ // lib/search-core.mjs for the leak rationale).
218
+ const rows = searchPromptsFts(db, {
219
+ query: args.query, ftsQuery, project: args.project ?? null,
220
+ epochFrom, epochTo, perSourceLimit, perSourceOffset,
221
+ });
222
+ for (const r of rows) {
260
223
  results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, created_at_epoch: r.created_at_epoch, score: r.score });
261
224
  }
262
- // CJK LIKE fallback: FTS5 unicode61 can't tokenize CJK substrings in prompts
263
- if (keptRows.length === 0 && args.query) {
264
- const cjkPatterns = extractCjkLikePatterns(args.query);
265
- if (cjkPatterns.length > 0) {
266
- const likeConds = cjkPatterns.map(() => 'p.prompt_text LIKE ?');
267
- const likeParams = cjkPatterns.map(p => `%${p}%`);
268
- const fallbackRows = db.prepare(`
269
- SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch
270
- FROM user_prompts p
271
- JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
272
- WHERE (${likeConds.join(' OR ')})
273
- AND p.prompt_text NOT LIKE '<task-notification>%'
274
- AND (? IS NULL OR s.project = ?)
275
- AND (? IS NULL OR p.created_at_epoch >= ?)
276
- AND (? IS NULL OR p.created_at_epoch <= ?)
277
- ORDER BY p.created_at_epoch DESC
278
- LIMIT ? OFFSET ?
279
- `).all(
280
- ...likeParams,
281
- args.project ?? null, args.project ?? null,
282
- epochFrom, epochFrom,
283
- epochTo, epochTo,
284
- perSourceLimit, perSourceOffset
285
- );
286
- // Parity with mem-cli.mjs: the LIKE fallback is an OR'd bigram
287
- // substring scan with no scoring gate. The precision filter must
288
- // apply here too — without it, queries whose FTS set is empty
289
- // re-admit the full common-char noise band that FTS would have
290
- // dropped downstream anyway.
291
- const keptFallback = args.query ? fallbackRows.filter(r => cjkPrecisionOk(args.query, r.prompt_text)) : fallbackRows;
292
- for (const r of keptFallback) {
293
- results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, created_at_epoch: r.created_at_epoch, score: 0 });
294
- }
295
- }
296
- }
297
225
  } else if (searchType === 'prompts') {
298
226
  const params = [];
299
227
  const wheres = [];
@@ -388,32 +316,20 @@ server.registerTool(
388
316
  // args.or (Batch A CLI↔MCP alignment): force OR from start, matching
389
317
  // CLI `search --or`. The default path still does AND with OR-fallback
390
318
  // inside searchObservations when AND returns 0.
391
- let ftsQuery = sanitizeFtsQuery(args.query);
392
- if (ftsQuery && args.or) {
393
- ftsQuery = relaxFtsQueryToOr(ftsQuery) || ftsQuery;
394
- }
319
+ const ftsQuery = buildSearchFtsQuery(args.query, { or: args.or });
395
320
  const searchType = args.type;
396
321
  const currentProject = inferProject();
397
322
 
398
- // Over-fetch from offset 0 for EVERY mode, then apply `offset` exactly once at
399
- // the merge slice below — identical to the CLI (mem-cli.mjs perSourceOffset=0).
400
- // The old single-source branch (perSourceLimit=limit, perSourceOffset=offset)
401
- // double-applied offset: it pushed offset into the per-source SQL AND re-sliced
402
- // by offset at merge, so explicit `type=observations` paging overlapped (page 0
403
- // == page 1) and gapped (oldest rows unreachable). It also fetched only `limit`
404
- // rows fewer than offset+limit so there was nothing to page into. (#8217)
405
- const perSourceLimit = Math.max(limit * 3, offset + limit + 10);
406
- const perSourceOffset = 0;
407
-
408
- // Parse date bounds to epoch (with validation)
409
- // date_to with date-only format (YYYY-MM-DD) extends to end-of-day (23:59:59.999Z)
410
- const epochFrom = args.date_from ? new Date(args.date_from).getTime() : null;
411
- let epochTo = args.date_to ? new Date(args.date_to).getTime() : null;
412
- if (epochTo !== null && args.date_to && /^\d{4}-\d{2}-\d{2}$/.test(args.date_to)) {
413
- epochTo += 86400000 - 1; // extend to 23:59:59.999
414
- }
415
- if (epochFrom !== null && isNaN(epochFrom)) throw new Error(`Invalid date_from: "${args.date_from}" (use ISO 8601 or YYYY-MM-DD)`);
416
- if (epochTo !== null && isNaN(epochTo)) throw new Error(`Invalid date_to: "${args.date_to}" (use ISO 8601 or YYYY-MM-DD)`);
323
+ // Over-fetch from offset 0 for EVERY mode, then apply `offset` exactly once
324
+ // at the merge slice below — shared sizing with the CLI (see
325
+ // computePerSourceWindow for the #8217 double-offset rationale).
326
+ const { perSourceLimit, perSourceOffset } = computePerSourceWindow(limit, offset);
327
+
328
+ // Parse date bounds to epoch (with validation; date-only date_to extends
329
+ // to end-of-day 23:59:59.999Zshared with CLI --from/--to)
330
+ const bounds = parseDateBounds(args.date_from, args.date_to);
331
+ if (!bounds.ok) throw new Error(`Invalid date_${bounds.bad}: "${bounds.value}" (use ISO 8601 or YYYY-MM-DD)`);
332
+ const { epochFrom, epochTo } = bounds;
417
333
 
418
334
  // Early return when query was provided but sanitized to nothing (all FTS5 keywords/special chars)
419
335
  if (args.query && !ftsQuery && !epochFrom && !epochTo && !args.obs_type && !args.importance) {
@@ -450,19 +366,11 @@ server.registerTool(
450
366
  }
451
367
  }
452
368
 
453
- // Cross-source score normalization: normalize each source to [-1, 0] before merging
454
- // Prevents observations (BM25 scores can reach -40) from systematically outranking
455
- // sessions (-6) and prompts (-1) regardless of actual relevance
369
+ // Cross-source score normalization (shared with CLI lib/search-core.mjs):
370
+ // normalize each source to [-1, 0] before merging so observations (BM25 can
371
+ // reach -40) don't systematically outrank sessions (-6) and prompts (-1).
456
372
  if (isCrossSource && results.length > 0 && ftsQuery) {
457
- for (const source of ['obs', 'session', 'prompt']) {
458
- const sourceResults = results.filter(r => r.source === source && r.score !== null && r.score !== undefined);
459
- // Skip normalization for single-result sources — avoids inflating a weak match to -1.0
460
- if (sourceResults.length < 2) continue;
461
- const maxAbs = Math.max(...sourceResults.map(r => Math.abs(r.score)));
462
- if (maxAbs > 0) {
463
- for (const r of sourceResults) r.score = r.score / maxAbs;
464
- }
465
- }
373
+ normalizeCrossSourceScores(results, 'source');
466
374
  }
467
375
 
468
376
  // Global sort (cross-source)
@@ -482,41 +390,17 @@ server.registerTool(
482
390
  results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
483
391
  }
484
392
 
485
- // Tier post-filter: batch-lookup full rows and classify
393
+ // Tier post-filter: batch-lookup full rows and classify (shared with CLI).
394
+ // Classification uses the explicitly-requested project, not the CWD-inferred
395
+ // one — see applyTierFilter for the cross-project rationale.
486
396
  if (args.tier) {
487
- const obsIds = results.filter(r => r.source === 'obs').map(r => r.id);
488
- if (obsIds.length > 0) {
489
- const placeholders = obsIds.map(() => '?').join(',');
490
- const fullRows = db.prepare(
491
- `SELECT id, compressed_into, superseded_at, memory_session_id, project, importance, last_accessed_at, created_at_epoch, type FROM observations WHERE id IN (${placeholders})`
492
- ).all(...obsIds);
493
- const rowMap = new Map(fullRows.map(r => [r.id, r]));
494
- // Use the explicitly-requested project for tier classification, not the
495
- // CWD-inferred one — else computeTier's "obs.project === currentProject"
496
- // (working/active rules) fails for cross-project searches and the tier=
497
- // filter silently drops valid rows. mem_stats/mem_browse already resolve
498
- // args.project first; this restores parity.
499
- const tierCtx = { now: Date.now(), currentProject: args.project || currentProject, currentSessionId: '' };
500
- const filtered = results.filter(r => {
501
- if (r.source !== 'obs') return true;
502
- const full = rowMap.get(r.id);
503
- return full && computeTier(full, tierCtx) === args.tier;
504
- });
505
- results.length = 0;
506
- results.push(...filtered);
507
- } else if (args.tier !== 'archive') {
508
- // No obs results but tier filter set — keep non-obs results
509
- }
397
+ const filtered = applyTierFilter(db, results, { tier: args.tier, sourceKey: 'source', currentProject: args.project || currentProject });
398
+ results.length = 0;
399
+ results.push(...filtered);
510
400
  }
511
401
 
512
- // Apply user-requested sort (after relevance scoring)
513
- const sort = args.sort || 'relevance';
514
- if (sort === 'time') {
515
- results.sort((a, b) => (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
516
- } else if (sort === 'importance') {
517
- results.sort((a, b) => (b.importance ?? 1) - (a.importance ?? 1) || (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
518
- }
519
- // else 'relevance' keeps BM25 score order (already sorted)
402
+ // Apply user-requested sort (after relevance scoring; shared with CLI)
403
+ applyUserSort(results, args.sort || 'relevance');
520
404
 
521
405
  // `total` must be the TRUE population, invariant to limit/offset. In cross-source
522
406
  // mode results is over-fetched (perSourceLimit scales with limit+offset), so
@@ -526,7 +410,7 @@ server.registerTool(
526
410
  const trueTotal = countSearchTotal(db, {
527
411
  effectiveSource: effectiveType || null,
528
412
  ftsQuery,
529
- obsFtsQuery: ctx.orFallbackFired === true ? (relaxFtsQueryToOr(ftsQuery) || ftsQuery) : ftsQuery,
413
+ obsFtsQuery: effectiveObsFtsQuery(ftsQuery, ctx.orFallbackFired === true),
530
414
  args: { project: args.project || null, obs_type: args.obs_type || null, importance: args.importance || null, branch: args.branch || null },
531
415
  project: args.project || null,
532
416
  epochFrom, epochTo,
@@ -596,95 +480,33 @@ server.registerTool(
596
480
 
597
481
  // Resolve prefixed-token anchor (e.g. "P#3462" / "S#53" / "#8121") — users pasting
598
482
  // from mem_search results expect the same routing as CLI `timeline --anchor`.
599
- // Prompt/session anchors resolve to the nearest-in-time observation so
600
- // before/after semantics still apply to the observations timeline.
601
- // Also covers bare numeric anchors so compressed-obs routing applies uniformly
602
- // without this, `anchor: 7826` (int) would bypass the compressed check and
483
+ // Resolution ladder (prompt/session nearest obs, compressed re-anchor, bare-int
484
+ // fallback) is shared with the CLI via lib/timeline-core.mjs. Covers bare numeric
485
+ // anchors too, so `anchor: 7826` (int) can't bypass the compressed check and
603
486
  // silently straddle a dead record.
604
487
  if (typeof anchorId === 'string' || typeof anchorId === 'number') {
605
- const parsed = parseIdToken(anchorId);
606
- if (!parsed) {
607
- return { content: [{ type: 'text', text: `Invalid anchor "${args.anchor}". Expected N, #N, P#N, or S#N.` }] };
608
- }
609
- if (parsed.source === 'prompt' || parsed.source === 'session') {
610
- const srcTable = parsed.source === 'prompt' ? 'user_prompts' : 'session_summaries';
611
- const srcPrefix = parsed.source === 'prompt' ? 'P#' : 'S#';
612
- const row = db.prepare(`SELECT created_at_epoch FROM ${srcTable} WHERE id = ?`).get(parsed.id);
613
- if (!row) return { content: [{ type: 'text', text: `${parsed.source === 'prompt' ? 'Prompt' : 'Session'} ${srcPrefix}${parsed.id} not found.` }] };
614
- const projArg = args.project;
615
- const nearest = db.prepare(`
616
- SELECT id FROM observations
617
- WHERE COALESCE(compressed_into, 0) = 0 ${projArg ? 'AND project = ?' : ''}
618
- ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
619
- `).get(...(projArg ? [projArg, row.created_at_epoch] : [row.created_at_epoch]));
620
- if (!nearest) return { content: [{ type: 'text', text: `No observations near ${srcPrefix}${parsed.id}.` }] };
621
- anchorId = nearest.id;
622
- anchorNote = `(anchored to #${nearest.id}, closest obs to ${srcPrefix}${parsed.id})`;
623
- } else {
624
- // Bare "#N" or "N" — resolve to obs, falling back to prompt/session like CLI bare-int path.
625
- // Route compressed obs to its parent so the before/after window (which filters compressed)
626
- // isn't shown around a dead anchor. Negative sentinels (-1 dropped, -2 pending purge) surface
627
- // an explicit error — they have no canonical parent.
628
- const obsRow = db.prepare('SELECT compressed_into FROM observations WHERE id = ?').get(parsed.id);
629
- if (obsRow) {
630
- const ci = obsRow.compressed_into;
631
- if (ci && ci > 0) {
632
- anchorId = ci;
633
- anchorNote = `(anchored to #${ci}, #${parsed.id} was compressed into it)`;
634
- } else if (ci && ci < 0) {
635
- return { content: [{ type: 'text', text: `Observation #${parsed.id} was compressed and pruned; no canonical anchor available.` }] };
636
- } else {
637
- anchorId = parsed.id;
638
- }
639
- } else {
640
- const promptRow = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
641
- const sessionRow = promptRow ? null : db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
642
- const hit = promptRow ? { row: promptRow, prefix: 'P#', name: 'prompt' }
643
- : sessionRow ? { row: sessionRow, prefix: 'S#', name: 'session' }
644
- : null;
645
- if (!hit) {
646
- return { content: [{ type: 'text', text: `Observation, prompt, or session with id ${parsed.id} not found.` }] };
647
- }
648
- const projArg = args.project;
649
- const nearest = db.prepare(`
650
- SELECT id FROM observations
651
- WHERE COALESCE(compressed_into, 0) = 0 ${projArg ? 'AND project = ?' : ''}
652
- ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
653
- `).get(...(projArg ? [projArg, hit.row.created_at_epoch] : [hit.row.created_at_epoch]));
654
- if (!nearest) return { content: [{ type: 'text', text: `No observations near ${hit.prefix}${parsed.id} (${hit.name}).` }] };
655
- anchorId = nearest.id;
656
- anchorNote = `(anchored to #${nearest.id}, closest obs to ${hit.prefix}${parsed.id})`;
657
- }
488
+ const resolved = resolveAnchorToken(db, anchorId, { project: args.project ?? null });
489
+ if (!resolved.ok) {
490
+ return { content: [{ type: 'text', text: formatAnchorError(resolved.error, 'mcp') }] };
658
491
  }
492
+ anchorId = resolved.anchorId;
493
+ anchorNote = resolved.anchorNote;
659
494
  }
660
495
 
661
- // Auto-find anchor via FTS (with recency decay). Routes through shared
662
- // findFtsAnchor so CLI `timeline --query` and MCP mem_timeline use
663
- // identical AND→OR fallback semantics (paired-path per #8217). When the
664
- // OR fallback fired, surface a hint so the caller knows the match was
665
- // not an exact AND coverage of the query — mirrors search transparency.
496
+ // Auto-find anchor via FTS (with recency decay). Shared with CLI
497
+ // `timeline --query` so AND→OR fallback semantics stay identical (#8217);
498
+ // the relaxed-note hint mirrors search transparency.
666
499
  if (!anchorId && args.query) {
667
- const ftsQuery = sanitizeFtsQuery(args.query);
668
- const found = findFtsAnchor(db, { ftsQuery, project: args.project ?? null });
500
+ const found = resolveQueryAnchor(db, args.query, { project: args.project ?? null });
669
501
  if (found) {
670
- anchorId = found.id;
671
- if (found.relaxed && !anchorNote) {
672
- anchorNote = `(query "${args.query}" relaxed AND→OR — no row matched all terms)`;
673
- }
502
+ anchorId = found.anchorId;
503
+ if (found.anchorNote && !anchorNote) anchorNote = found.anchorNote;
674
504
  }
675
505
  }
676
506
 
677
507
  // No anchor: return most recent
678
508
  if (!anchorId) {
679
- const compressedFilter = 'COALESCE(compressed_into, 0) = 0';
680
- const projectFilter = args.project ? `WHERE ${compressedFilter} AND project = ?` : `WHERE ${compressedFilter}`;
681
- const params = args.project ? [args.project, before + after + 1] : [before + after + 1];
682
- const rows = db.prepare(`
683
- SELECT id, type, title, subtitle, project, created_at
684
- FROM observations ${projectFilter}
685
- ORDER BY created_at_epoch DESC
686
- LIMIT ?
687
- `).all(...params);
509
+ const rows = fetchRecentTimeline(db, { project: args.project ?? null, limit: before + after + 1 });
688
510
 
689
511
  if (rows.length === 0) {
690
512
  return { content: [{ type: 'text', text: 'No observations found.' }] };
@@ -697,45 +519,13 @@ server.registerTool(
697
519
  return { content: [{ type: 'text', text: lines.join('\n') }] };
698
520
  }
699
521
 
700
- // Get anchor epoch
701
- const anchorRow = db.prepare('SELECT created_at_epoch, project FROM observations WHERE id = ?').get(anchorId);
702
- if (!anchorRow) {
522
+ // Window fetch (access-count bump + project auto-scope) shared with CLI.
523
+ const win = fetchTimelineWindow(db, anchorId, { before, after, project: args.project ?? null });
524
+ if (!win) {
703
525
  return { content: [{ type: 'text', text: `Observation #${anchorId} not found.` }] };
704
526
  }
705
527
 
706
- // Update access_count for anchor (aligned with CLI timeline)
707
- try {
708
- db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?').run(Date.now(), anchorId);
709
- } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
710
-
711
- // Auto-scope to anchor's project when caller didn't pass one: "timeline around #N"
712
- // means same-project context by default; cross-project bleed breaks user mental model.
713
- const effectiveProject = args.project || anchorRow.project;
714
- const projectFilter = effectiveProject ? 'AND project = ?' : '';
715
- const baseParams = effectiveProject ? [effectiveProject] : [];
716
-
717
- // Before anchor
718
- const beforeRows = db.prepare(`
719
- SELECT id, type, title, subtitle, project, created_at
720
- FROM observations
721
- WHERE created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
722
- ORDER BY created_at_epoch DESC
723
- LIMIT ?
724
- `).all(anchorRow.created_at_epoch, ...baseParams, before);
725
-
726
- // After anchor
727
- const afterRows = db.prepare(`
728
- SELECT id, type, title, subtitle, project, created_at
729
- FROM observations
730
- WHERE created_at_epoch > ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
731
- ORDER BY created_at_epoch ASC
732
- LIMIT ?
733
- `).all(anchorRow.created_at_epoch, ...baseParams, after);
734
-
735
- // Anchor itself
736
- const anchor = db.prepare('SELECT id, type, title, subtitle, project, created_at FROM observations WHERE id = ?').get(anchorId);
737
-
738
- const all = [...beforeRows.reverse(), anchor, ...afterRows];
528
+ const all = [...win.beforeRows, win.anchor, ...win.afterRows];
739
529
  const lines = [`Timeline around #${anchorId}${anchorNote ? ' ' + anchorNote : ''}:\n`];
740
530
  for (const r of all) {
741
531
  const marker = r.id === anchorId ? ' ◀' : '';
package/source-files.mjs CHANGED
@@ -83,6 +83,13 @@ export const SOURCE_FILES = [
83
83
  // auto-update. Same single-source-of-truth pattern (see #8217).
84
84
  'lib/observation-write.mjs',
85
85
  'lib/recall-core.mjs',
86
+ // Shared timeline core (anchor resolution + before/after window) and shared
87
+ // cross-source search core (sessions/prompts FTS, CJK fallback, normalization,
88
+ // pagination math). Statically imported by mem-cli.mjs AND server.mjs — same
89
+ // single-source-of-truth pattern; missing either from the manifest would break
90
+ // `timeline`/`search` and mem_timeline/mem_search on auto-update.
91
+ 'lib/timeline-core.mjs',
92
+ 'lib/search-core.mjs',
86
93
  // Shared "compress old low-value observations into weekly summaries" core.
87
94
  // Statically imported by mem-cli.mjs (cmdCompress), server.mjs (mem_compress),
88
95
  // and hook.mjs (handleAutoCompress) — same single-source-of-truth pattern as