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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/lib/search-core.mjs +200 -0
- package/lib/timeline-core.mjs +195 -0
- package/mem-cli.mjs +54 -257
- package/package.json +3 -1
- package/server.mjs +63 -273
- package/source-files.mjs +7 -0
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,
|
|
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,
|
|
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 {
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
|
399
|
-
// the merge slice below —
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
const
|
|
406
|
-
|
|
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.999Z — shared 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
|
|
454
|
-
//
|
|
455
|
-
// sessions (-6) and prompts (-1)
|
|
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
|
-
|
|
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
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
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
|
|
606
|
-
if (!
|
|
607
|
-
return { content: [{ type: 'text', text:
|
|
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).
|
|
662
|
-
//
|
|
663
|
-
//
|
|
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
|
|
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.
|
|
671
|
-
if (found.
|
|
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
|
|
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
|
-
//
|
|
701
|
-
const
|
|
702
|
-
if (!
|
|
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
|
-
|
|
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
|