claude-mem-lite 2.97.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, cjkBigrams, fmtDate, debugLog, debugCatch, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined, notLowSignalTitleClause } 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
 
@@ -32,18 +33,20 @@ function descriptionOf(name) {
32
33
  return d;
33
34
  }
34
35
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
35
- import { basename, join, sep } from 'path';
36
+ 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';
42
+ import { rebuildObservationDerived } from './lib/observation-write.mjs';
43
+ import { recallByFile } from './lib/recall-core.mjs';
41
44
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
42
45
  import {
43
46
  insertDeferred, listOpenWithOrdinal, dropDeferred,
44
47
  resolveDeferredIds, closeDeferredItems,
45
48
  } from './lib/deferred-work.mjs';
46
- import { getVocabulary, _resetVocabCache, computeVector } from './tfidf.mjs';
49
+ import { _resetVocabCache } from './tfidf.mjs';
47
50
  import { createRequire } from 'module';
48
51
 
49
52
  const require = createRequire(import.meta.url);
@@ -89,7 +92,7 @@ function getRegistryDb() {
89
92
  return registryDb;
90
93
  }
91
94
 
92
- // inferProject, jaccardSimilarity, sanitizeFtsQuery, typeIcon, truncate, fmtDate imported from utils.mjs
95
+ // inferProject, typeIcon, truncate, fmtDate imported from utils.mjs
93
96
 
94
97
  // ─── Project Name Resolution ────────────────────────────────────────────────
95
98
  // Users naturally type short names like "mem" but inferProject() stores
@@ -116,8 +119,7 @@ function resolveProject(name) { return _resolveProjectShared(db, name); }
116
119
  // Importance: 0.5 + 0.5 × importance (range 0.5–2.0)
117
120
  // Access bonus: 1 + 0.1 × ln(1 + access_count)
118
121
 
119
- // SESS_BM25, TYPE_DECAY_CASE imported from utils.mjs
120
- 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.
121
123
 
122
124
  // ─── MCP Server ─────────────────────────────────────────────────────────────
123
125
 
@@ -174,30 +176,11 @@ function searchSessions(ctx) {
174
176
  const results = [];
175
177
 
176
178
  if (ftsQuery) {
177
- const now = Date.now();
178
- const sessionProjectBoost = args.project ? null : currentProject;
179
- const rows = db.prepare(`
180
- SELECT s.id, s.request, s.completed, s.project, s.created_at, s.created_at_epoch,
181
- ${SESS_BM25}
182
- * (1.0 + EXP(-0.693 * (? - s.created_at_epoch) / ${RECENCY_HALF_LIFE_MS}.0))
183
- * (CASE WHEN ? IS NOT NULL AND s.project = ? THEN 2.0 ELSE 1.0 END) as score
184
- FROM session_summaries_fts
185
- JOIN session_summaries s ON session_summaries_fts.rowid = s.id
186
- WHERE session_summaries_fts MATCH ?
187
- AND (? IS NULL OR s.project = ?)
188
- AND (? IS NULL OR s.created_at_epoch >= ?)
189
- AND (? IS NULL OR s.created_at_epoch <= ?)
190
- ORDER BY score
191
- LIMIT ? OFFSET ?
192
- `).all(
193
- now,
194
- sessionProjectBoost, sessionProjectBoost,
195
- ftsQuery,
196
- args.project ?? null, args.project ?? null,
197
- epochFrom, epochFrom,
198
- epochTo, epochTo,
199
- perSourceLimit, perSourceOffset
200
- );
179
+ const rows = searchSessionsFts(db, {
180
+ ftsQuery, project: args.project ?? null,
181
+ projectBoost: args.project ? null : currentProject,
182
+ epochFrom, epochTo, perSourceLimit, perSourceOffset,
183
+ });
201
184
  for (const r of rows) {
202
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 });
203
186
  }
@@ -230,68 +213,15 @@ function searchPrompts(ctx) {
230
213
  const results = [];
231
214
 
232
215
  if (ftsQuery) {
233
- const rows = db.prepare(`
234
- SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch,
235
- bm25(user_prompts_fts, 1) as score
236
- FROM user_prompts_fts
237
- JOIN user_prompts p ON user_prompts_fts.rowid = p.id
238
- JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
239
- WHERE user_prompts_fts MATCH ?
240
- AND p.prompt_text NOT LIKE '<task-notification>%'
241
- AND (? IS NULL OR s.project = ?)
242
- AND (? IS NULL OR p.created_at_epoch >= ?)
243
- AND (? IS NULL OR p.created_at_epoch <= ?)
244
- ORDER BY score
245
- LIMIT ? OFFSET ?
246
- `).all(
247
- ftsQuery,
248
- args.project ?? null, args.project ?? null,
249
- epochFrom, epochFrom,
250
- epochTo, epochTo,
251
- perSourceLimit, perSourceOffset
252
- );
253
- // CJK precision filter: unicode61 FTS degrades CJK bigram queries to
254
- // single-char AND, letting any prose sharing common chars leak through.
255
- // Require ≥30% of query's CJK bigrams/keywords as contiguous substrings.
256
- const keptRows = args.query ? rows.filter(r => cjkPrecisionOk(args.query, r.prompt_text)) : rows;
257
- 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) {
258
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 });
259
224
  }
260
- // CJK LIKE fallback: FTS5 unicode61 can't tokenize CJK substrings in prompts
261
- if (keptRows.length === 0 && args.query) {
262
- const cjkPatterns = extractCjkLikePatterns(args.query);
263
- if (cjkPatterns.length > 0) {
264
- const likeConds = cjkPatterns.map(() => 'p.prompt_text LIKE ?');
265
- const likeParams = cjkPatterns.map(p => `%${p}%`);
266
- const fallbackRows = db.prepare(`
267
- SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch
268
- FROM user_prompts p
269
- JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
270
- WHERE (${likeConds.join(' OR ')})
271
- AND p.prompt_text NOT LIKE '<task-notification>%'
272
- AND (? IS NULL OR s.project = ?)
273
- AND (? IS NULL OR p.created_at_epoch >= ?)
274
- AND (? IS NULL OR p.created_at_epoch <= ?)
275
- ORDER BY p.created_at_epoch DESC
276
- LIMIT ? OFFSET ?
277
- `).all(
278
- ...likeParams,
279
- args.project ?? null, args.project ?? null,
280
- epochFrom, epochFrom,
281
- epochTo, epochTo,
282
- perSourceLimit, perSourceOffset
283
- );
284
- // Parity with mem-cli.mjs: the LIKE fallback is an OR'd bigram
285
- // substring scan with no scoring gate. The precision filter must
286
- // apply here too — without it, queries whose FTS set is empty
287
- // re-admit the full common-char noise band that FTS would have
288
- // dropped downstream anyway.
289
- const keptFallback = args.query ? fallbackRows.filter(r => cjkPrecisionOk(args.query, r.prompt_text)) : fallbackRows;
290
- for (const r of keptFallback) {
291
- 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 });
292
- }
293
- }
294
- }
295
225
  } else if (searchType === 'prompts') {
296
226
  const params = [];
297
227
  const wheres = [];
@@ -386,32 +316,20 @@ server.registerTool(
386
316
  // args.or (Batch A CLI↔MCP alignment): force OR from start, matching
387
317
  // CLI `search --or`. The default path still does AND with OR-fallback
388
318
  // inside searchObservations when AND returns 0.
389
- let ftsQuery = sanitizeFtsQuery(args.query);
390
- if (ftsQuery && args.or) {
391
- ftsQuery = relaxFtsQueryToOr(ftsQuery) || ftsQuery;
392
- }
319
+ const ftsQuery = buildSearchFtsQuery(args.query, { or: args.or });
393
320
  const searchType = args.type;
394
321
  const currentProject = inferProject();
395
322
 
396
- // Over-fetch from offset 0 for EVERY mode, then apply `offset` exactly once at
397
- // the merge slice below — identical to the CLI (mem-cli.mjs perSourceOffset=0).
398
- // The old single-source branch (perSourceLimit=limit, perSourceOffset=offset)
399
- // double-applied offset: it pushed offset into the per-source SQL AND re-sliced
400
- // by offset at merge, so explicit `type=observations` paging overlapped (page 0
401
- // == page 1) and gapped (oldest rows unreachable). It also fetched only `limit`
402
- // rows fewer than offset+limit so there was nothing to page into. (#8217)
403
- const perSourceLimit = Math.max(limit * 3, offset + limit + 10);
404
- const perSourceOffset = 0;
405
-
406
- // Parse date bounds to epoch (with validation)
407
- // date_to with date-only format (YYYY-MM-DD) extends to end-of-day (23:59:59.999Z)
408
- const epochFrom = args.date_from ? new Date(args.date_from).getTime() : null;
409
- let epochTo = args.date_to ? new Date(args.date_to).getTime() : null;
410
- if (epochTo !== null && args.date_to && /^\d{4}-\d{2}-\d{2}$/.test(args.date_to)) {
411
- epochTo += 86400000 - 1; // extend to 23:59:59.999
412
- }
413
- if (epochFrom !== null && isNaN(epochFrom)) throw new Error(`Invalid date_from: "${args.date_from}" (use ISO 8601 or YYYY-MM-DD)`);
414
- 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;
415
333
 
416
334
  // Early return when query was provided but sanitized to nothing (all FTS5 keywords/special chars)
417
335
  if (args.query && !ftsQuery && !epochFrom && !epochTo && !args.obs_type && !args.importance) {
@@ -448,19 +366,11 @@ server.registerTool(
448
366
  }
449
367
  }
450
368
 
451
- // Cross-source score normalization: normalize each source to [-1, 0] before merging
452
- // Prevents observations (BM25 scores can reach -40) from systematically outranking
453
- // 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).
454
372
  if (isCrossSource && results.length > 0 && ftsQuery) {
455
- for (const source of ['obs', 'session', 'prompt']) {
456
- const sourceResults = results.filter(r => r.source === source && r.score !== null && r.score !== undefined);
457
- // Skip normalization for single-result sources — avoids inflating a weak match to -1.0
458
- if (sourceResults.length < 2) continue;
459
- const maxAbs = Math.max(...sourceResults.map(r => Math.abs(r.score)));
460
- if (maxAbs > 0) {
461
- for (const r of sourceResults) r.score = r.score / maxAbs;
462
- }
463
- }
373
+ normalizeCrossSourceScores(results, 'source');
464
374
  }
465
375
 
466
376
  // Global sort (cross-source)
@@ -480,41 +390,17 @@ server.registerTool(
480
390
  results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
481
391
  }
482
392
 
483
- // 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.
484
396
  if (args.tier) {
485
- const obsIds = results.filter(r => r.source === 'obs').map(r => r.id);
486
- if (obsIds.length > 0) {
487
- const placeholders = obsIds.map(() => '?').join(',');
488
- const fullRows = db.prepare(
489
- `SELECT id, compressed_into, superseded_at, memory_session_id, project, importance, last_accessed_at, created_at_epoch, type FROM observations WHERE id IN (${placeholders})`
490
- ).all(...obsIds);
491
- const rowMap = new Map(fullRows.map(r => [r.id, r]));
492
- // Use the explicitly-requested project for tier classification, not the
493
- // CWD-inferred one — else computeTier's "obs.project === currentProject"
494
- // (working/active rules) fails for cross-project searches and the tier=
495
- // filter silently drops valid rows. mem_stats/mem_browse already resolve
496
- // args.project first; this restores parity.
497
- const tierCtx = { now: Date.now(), currentProject: args.project || currentProject, currentSessionId: '' };
498
- const filtered = results.filter(r => {
499
- if (r.source !== 'obs') return true;
500
- const full = rowMap.get(r.id);
501
- return full && computeTier(full, tierCtx) === args.tier;
502
- });
503
- results.length = 0;
504
- results.push(...filtered);
505
- } else if (args.tier !== 'archive') {
506
- // No obs results but tier filter set — keep non-obs results
507
- }
397
+ const filtered = applyTierFilter(db, results, { tier: args.tier, sourceKey: 'source', currentProject: args.project || currentProject });
398
+ results.length = 0;
399
+ results.push(...filtered);
508
400
  }
509
401
 
510
- // Apply user-requested sort (after relevance scoring)
511
- const sort = args.sort || 'relevance';
512
- if (sort === 'time') {
513
- results.sort((a, b) => (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
514
- } else if (sort === 'importance') {
515
- results.sort((a, b) => (b.importance ?? 1) - (a.importance ?? 1) || (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
516
- }
517
- // 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');
518
404
 
519
405
  // `total` must be the TRUE population, invariant to limit/offset. In cross-source
520
406
  // mode results is over-fetched (perSourceLimit scales with limit+offset), so
@@ -524,7 +410,7 @@ server.registerTool(
524
410
  const trueTotal = countSearchTotal(db, {
525
411
  effectiveSource: effectiveType || null,
526
412
  ftsQuery,
527
- obsFtsQuery: ctx.orFallbackFired === true ? (relaxFtsQueryToOr(ftsQuery) || ftsQuery) : ftsQuery,
413
+ obsFtsQuery: effectiveObsFtsQuery(ftsQuery, ctx.orFallbackFired === true),
528
414
  args: { project: args.project || null, obs_type: args.obs_type || null, importance: args.importance || null, branch: args.branch || null },
529
415
  project: args.project || null,
530
416
  epochFrom, epochTo,
@@ -594,95 +480,33 @@ server.registerTool(
594
480
 
595
481
  // Resolve prefixed-token anchor (e.g. "P#3462" / "S#53" / "#8121") — users pasting
596
482
  // from mem_search results expect the same routing as CLI `timeline --anchor`.
597
- // Prompt/session anchors resolve to the nearest-in-time observation so
598
- // before/after semantics still apply to the observations timeline.
599
- // Also covers bare numeric anchors so compressed-obs routing applies uniformly
600
- // 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
601
486
  // silently straddle a dead record.
602
487
  if (typeof anchorId === 'string' || typeof anchorId === 'number') {
603
- const parsed = parseIdToken(anchorId);
604
- if (!parsed) {
605
- return { content: [{ type: 'text', text: `Invalid anchor "${args.anchor}". Expected N, #N, P#N, or S#N.` }] };
606
- }
607
- if (parsed.source === 'prompt' || parsed.source === 'session') {
608
- const srcTable = parsed.source === 'prompt' ? 'user_prompts' : 'session_summaries';
609
- const srcPrefix = parsed.source === 'prompt' ? 'P#' : 'S#';
610
- const row = db.prepare(`SELECT created_at_epoch FROM ${srcTable} WHERE id = ?`).get(parsed.id);
611
- if (!row) return { content: [{ type: 'text', text: `${parsed.source === 'prompt' ? 'Prompt' : 'Session'} ${srcPrefix}${parsed.id} not found.` }] };
612
- const projArg = args.project;
613
- const nearest = db.prepare(`
614
- SELECT id FROM observations
615
- WHERE COALESCE(compressed_into, 0) = 0 ${projArg ? 'AND project = ?' : ''}
616
- ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
617
- `).get(...(projArg ? [projArg, row.created_at_epoch] : [row.created_at_epoch]));
618
- if (!nearest) return { content: [{ type: 'text', text: `No observations near ${srcPrefix}${parsed.id}.` }] };
619
- anchorId = nearest.id;
620
- anchorNote = `(anchored to #${nearest.id}, closest obs to ${srcPrefix}${parsed.id})`;
621
- } else {
622
- // Bare "#N" or "N" — resolve to obs, falling back to prompt/session like CLI bare-int path.
623
- // Route compressed obs to its parent so the before/after window (which filters compressed)
624
- // isn't shown around a dead anchor. Negative sentinels (-1 dropped, -2 pending purge) surface
625
- // an explicit error — they have no canonical parent.
626
- const obsRow = db.prepare('SELECT compressed_into FROM observations WHERE id = ?').get(parsed.id);
627
- if (obsRow) {
628
- const ci = obsRow.compressed_into;
629
- if (ci && ci > 0) {
630
- anchorId = ci;
631
- anchorNote = `(anchored to #${ci}, #${parsed.id} was compressed into it)`;
632
- } else if (ci && ci < 0) {
633
- return { content: [{ type: 'text', text: `Observation #${parsed.id} was compressed and pruned; no canonical anchor available.` }] };
634
- } else {
635
- anchorId = parsed.id;
636
- }
637
- } else {
638
- const promptRow = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
639
- const sessionRow = promptRow ? null : db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
640
- const hit = promptRow ? { row: promptRow, prefix: 'P#', name: 'prompt' }
641
- : sessionRow ? { row: sessionRow, prefix: 'S#', name: 'session' }
642
- : null;
643
- if (!hit) {
644
- return { content: [{ type: 'text', text: `Observation, prompt, or session with id ${parsed.id} not found.` }] };
645
- }
646
- const projArg = args.project;
647
- const nearest = db.prepare(`
648
- SELECT id FROM observations
649
- WHERE COALESCE(compressed_into, 0) = 0 ${projArg ? 'AND project = ?' : ''}
650
- ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
651
- `).get(...(projArg ? [projArg, hit.row.created_at_epoch] : [hit.row.created_at_epoch]));
652
- if (!nearest) return { content: [{ type: 'text', text: `No observations near ${hit.prefix}${parsed.id} (${hit.name}).` }] };
653
- anchorId = nearest.id;
654
- anchorNote = `(anchored to #${nearest.id}, closest obs to ${hit.prefix}${parsed.id})`;
655
- }
488
+ const resolved = resolveAnchorToken(db, anchorId, { project: args.project ?? null });
489
+ if (!resolved.ok) {
490
+ return { content: [{ type: 'text', text: formatAnchorError(resolved.error, 'mcp') }] };
656
491
  }
492
+ anchorId = resolved.anchorId;
493
+ anchorNote = resolved.anchorNote;
657
494
  }
658
495
 
659
- // Auto-find anchor via FTS (with recency decay). Routes through shared
660
- // findFtsAnchor so CLI `timeline --query` and MCP mem_timeline use
661
- // identical AND→OR fallback semantics (paired-path per #8217). When the
662
- // OR fallback fired, surface a hint so the caller knows the match was
663
- // 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.
664
499
  if (!anchorId && args.query) {
665
- const ftsQuery = sanitizeFtsQuery(args.query);
666
- const found = findFtsAnchor(db, { ftsQuery, project: args.project ?? null });
500
+ const found = resolveQueryAnchor(db, args.query, { project: args.project ?? null });
667
501
  if (found) {
668
- anchorId = found.id;
669
- if (found.relaxed && !anchorNote) {
670
- anchorNote = `(query "${args.query}" relaxed AND→OR — no row matched all terms)`;
671
- }
502
+ anchorId = found.anchorId;
503
+ if (found.anchorNote && !anchorNote) anchorNote = found.anchorNote;
672
504
  }
673
505
  }
674
506
 
675
507
  // No anchor: return most recent
676
508
  if (!anchorId) {
677
- const compressedFilter = 'COALESCE(compressed_into, 0) = 0';
678
- const projectFilter = args.project ? `WHERE ${compressedFilter} AND project = ?` : `WHERE ${compressedFilter}`;
679
- const params = args.project ? [args.project, before + after + 1] : [before + after + 1];
680
- const rows = db.prepare(`
681
- SELECT id, type, title, subtitle, project, created_at
682
- FROM observations ${projectFilter}
683
- ORDER BY created_at_epoch DESC
684
- LIMIT ?
685
- `).all(...params);
509
+ const rows = fetchRecentTimeline(db, { project: args.project ?? null, limit: before + after + 1 });
686
510
 
687
511
  if (rows.length === 0) {
688
512
  return { content: [{ type: 'text', text: 'No observations found.' }] };
@@ -695,45 +519,13 @@ server.registerTool(
695
519
  return { content: [{ type: 'text', text: lines.join('\n') }] };
696
520
  }
697
521
 
698
- // Get anchor epoch
699
- const anchorRow = db.prepare('SELECT created_at_epoch, project FROM observations WHERE id = ?').get(anchorId);
700
- 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) {
701
525
  return { content: [{ type: 'text', text: `Observation #${anchorId} not found.` }] };
702
526
  }
703
527
 
704
- // Update access_count for anchor (aligned with CLI timeline)
705
- try {
706
- db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?').run(Date.now(), anchorId);
707
- } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
708
-
709
- // Auto-scope to anchor's project when caller didn't pass one: "timeline around #N"
710
- // means same-project context by default; cross-project bleed breaks user mental model.
711
- const effectiveProject = args.project || anchorRow.project;
712
- const projectFilter = effectiveProject ? 'AND project = ?' : '';
713
- const baseParams = effectiveProject ? [effectiveProject] : [];
714
-
715
- // Before anchor
716
- const beforeRows = db.prepare(`
717
- SELECT id, type, title, subtitle, project, created_at
718
- FROM observations
719
- WHERE created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
720
- ORDER BY created_at_epoch DESC
721
- LIMIT ?
722
- `).all(anchorRow.created_at_epoch, ...baseParams, before);
723
-
724
- // After anchor
725
- const afterRows = db.prepare(`
726
- SELECT id, type, title, subtitle, project, created_at
727
- FROM observations
728
- WHERE created_at_epoch > ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
729
- ORDER BY created_at_epoch ASC
730
- LIMIT ?
731
- `).all(anchorRow.created_at_epoch, ...baseParams, after);
732
-
733
- // Anchor itself
734
- const anchor = db.prepare('SELECT id, type, title, subtitle, project, created_at FROM observations WHERE id = ?').get(anchorId);
735
-
736
- const all = [...beforeRows.reverse(), anchor, ...afterRows];
528
+ const all = [...win.beforeRows, win.anchor, ...win.afterRows];
737
529
  const lines = [`Timeline around #${anchorId}${anchorNote ? ' ' + anchorNote : ''}:\n`];
738
530
  for (const r of all) {
739
531
  const marker = r.id === anchorId ? ' ◀' : '';
@@ -1819,28 +1611,11 @@ server.registerTool(
1819
1611
 
1820
1612
  params.push(args.id);
1821
1613
 
1822
- // Atomic: update fields + rebuild FTS text + re-vectorize
1614
+ // Atomic: update fields + rebuild derived columns (FTS text + vector) via the
1615
+ // shared core — single source with CLI cmdUpdate (lib/observation-write.mjs).
1823
1616
  db.transaction(() => {
1824
1617
  db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
1825
-
1826
- // Rebuild FTS text field (must include CJK bigrams + search_aliases to match mem_save/hook-llm)
1827
- const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(args.id);
1828
- const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
1829
- const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
1830
- const textField = bigrams ? base + ' ' + bigrams : base;
1831
- db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, args.id);
1832
-
1833
- // Re-vectorize (non-critical — catch to avoid rollback)
1834
- try {
1835
- const vocab = getVocabulary(db);
1836
- if (vocab) {
1837
- const vec = computeVector(textField, vocab);
1838
- if (vec) {
1839
- db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
1840
- .run(args.id, Buffer.from(vec.buffer), vocab.version, Date.now());
1841
- }
1842
- }
1843
- } catch (e) { debugCatch(e, 'mem_update-vector'); }
1618
+ rebuildObservationDerived(db, args.id);
1844
1619
  })();
1845
1620
 
1846
1621
  return { content: [{ type: 'text', text: `Updated observation #${args.id}: ${updates.map(u => u.split(' =')[0]).join(', ')}` }] };
@@ -1906,35 +1681,16 @@ server.registerTool(
1906
1681
  inputSchema: memRecallSchema,
1907
1682
  },
1908
1683
  safeHandler(async (args) => {
1909
- const filename = basename(args.file);
1910
- const limit = args.limit ?? 10;
1911
- const includeNoise = args.include_noise === true;
1912
-
1913
- const escaped = filename.replace(/%/g, '\\%').replace(/_/g, '\\_');
1914
- const likePattern = `%${escaped}`;
1915
- const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
1916
- const rows = db.prepare(`
1917
- SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.created_at, o.project
1918
- FROM observations o
1919
- JOIN observation_files of2 ON of2.obs_id = o.id
1920
- WHERE COALESCE(o.compressed_into, 0) = 0
1921
- AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
1922
- ${noiseClause}
1923
- ORDER BY o.created_at_epoch DESC
1924
- LIMIT ?
1925
- `).all(filename, likePattern, limit);
1684
+ // Shared core with CLI cmdRecall: query + escaping + access bump (lib/recall-core.mjs)
1685
+ const { filename, rows } = recallByFile(db, args.file, {
1686
+ limit: args.limit ?? 10,
1687
+ includeNoise: args.include_noise === true,
1688
+ });
1926
1689
 
1927
1690
  if (rows.length === 0) {
1928
1691
  return { content: [{ type: 'text', text: `No history for "${filename}". This file hasn't been observed yet.` }] };
1929
1692
  }
1930
1693
 
1931
- // Update access_count for recalled observations
1932
- const recalledIds = rows.map(r => r.id);
1933
- const ph = recalledIds.map(() => '?').join(',');
1934
- try {
1935
- db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...recalledIds);
1936
- } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
1937
-
1938
1694
  const lines = [`History for ${filename} (${rows.length} observation${rows.length !== 1 ? 's' : ''}):\n`];
1939
1695
  for (const r of rows) {
1940
1696
  const lesson = r.lesson_learned ? `\n Lesson: ${truncate(r.lesson_learned, 100)}` : '';
package/source-files.mjs CHANGED
@@ -82,6 +82,14 @@ export const SOURCE_FILES = [
82
82
  // entry-point-reachable); missing it from the manifest would break ALL saves on
83
83
  // auto-update. Same single-source-of-truth pattern (see #8217).
84
84
  'lib/observation-write.mjs',
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',
85
93
  // Shared "compress old low-value observations into weekly summaries" core.
86
94
  // Statically imported by mem-cli.mjs (cmdCompress), server.mjs (mem_compress),
87
95
  // and hook.mjs (handleAutoCompress) — same single-source-of-truth pattern as
package/tool-schemas.mjs CHANGED
@@ -218,11 +218,15 @@ export const memMaintainSchema = {
218
218
 
219
219
  export const memUpdateSchema = {
220
220
  id: coerceInt.pipe(z.number().int().positive()).describe('Observation ID to update'),
221
- title: z.string().optional().describe('New title'),
221
+ // CLI parity (cmdUpdate): empty/whitespace title would render as `(untitled)`
222
+ // in every listing — reject here like the CLI does, instead of persisting it.
223
+ title: z.string().refine(s => s.trim() !== '', 'title cannot be empty').optional().describe('New title'),
222
224
  narrative: z.string().optional().describe('New narrative/content'),
223
225
  type: OBS_TYPE_ENUM.optional().describe('New observation type'),
224
226
  importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('New importance (1-3)'),
225
- lesson_learned: z.string().optional().describe('Add or update lesson learned'),
227
+ // 500-char cap mirrors memSaveSchema + cmdUpdate — update was the one path
228
+ // that let overlong lessons leak into the DB via MCP.
229
+ lesson_learned: z.string().max(500).optional().describe('Add or update lesson learned'),
226
230
  concepts: z.string().optional().describe('Space-separated concept tags'),
227
231
  };
228
232