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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/lib/observation-write.mjs +18 -1
- package/lib/recall-core.mjs +43 -0
- package/lib/search-core.mjs +200 -0
- package/lib/timeline-core.mjs +195 -0
- package/mem-cli.mjs +63 -304
- package/package.json +4 -1
- package/scripts/pre-tool-recall.js +50 -2
- package/server.mjs +75 -319
- package/source-files.mjs +8 -0
- package/tool-schemas.mjs +6 -2
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
|
|
|
@@ -32,18 +33,20 @@ function descriptionOf(name) {
|
|
|
32
33
|
return d;
|
|
33
34
|
}
|
|
34
35
|
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
|
|
35
|
-
import {
|
|
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,
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
|
397
|
-
// the merge slice below —
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
const
|
|
404
|
-
|
|
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.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;
|
|
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
|
|
452
|
-
//
|
|
453
|
-
// 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).
|
|
454
372
|
if (isCrossSource && results.length > 0 && ftsQuery) {
|
|
455
|
-
|
|
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
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
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
|
|
604
|
-
if (!
|
|
605
|
-
return { content: [{ type: 'text', text:
|
|
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).
|
|
660
|
-
//
|
|
661
|
-
//
|
|
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
|
|
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.
|
|
669
|
-
if (found.
|
|
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
|
|
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
|
-
//
|
|
699
|
-
const
|
|
700
|
-
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) {
|
|
701
525
|
return { content: [{ type: 'text', text: `Observation #${anchorId} not found.` }] };
|
|
702
526
|
}
|
|
703
527
|
|
|
704
|
-
|
|
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 +
|
|
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
|
-
|
|
1910
|
-
const
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|