clawmem 0.20.0 → 0.21.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/src/mcp.ts CHANGED
@@ -114,6 +114,38 @@ function splitIntoWindows(text: string, windowSize: number, overlap = 200): stri
114
114
  }
115
115
 
116
116
  /** Classify query into retrieval mode based on signal patterns */
117
+ // =============================================================================
118
+ // Retrieval visibility policy (VSEARCH-TRUST-HARDENING (b))
119
+ // =============================================================================
120
+
121
+ // System-internal collections excluded from MCP retrieval by default. Hook precedent:
122
+ // FILTERED_PATHS in context-surfacing. Opt-ins: the includeInternal param, or an explicit
123
+ // collection filter naming an internal collection.
124
+ const INTERNAL_COLLECTIONS = ["_clawmem"];
125
+
126
+ function resolveExcludedCollections(includeInternal: boolean | undefined, collections?: string[]): string[] | undefined {
127
+ if (includeInternal) return undefined;
128
+ if (collections && collections.some(c => INTERNAL_COLLECTIONS.includes(c))) return undefined;
129
+ return INTERNAL_COLLECTIONS;
130
+ }
131
+
132
+ type DegradedLeg = { leg: string; reason: "excluded-dominant" | "cap-truncation" };
133
+
134
+ // Only excluded-dominant carries the includeInternal advice — cap-truncation must stay
135
+ // truthful when the shortfall is dedup-driven (T5-M2).
136
+ function degradedGuidanceText(legs: DegradedLeg[]): string {
137
+ const anyExcludedDominant = legs.some(l => l.reason === "excluded-dominant");
138
+ return anyExcludedDominant
139
+ ? "Note: nearest-neighbor region dominated by excluded internal docs — pass includeInternal:true or refine the query."
140
+ : "Note: vector results truncated at the scan cap.";
141
+ }
142
+
143
+ // Option-B weights knob (design (a)): default-off; flips search/vsearch/memory_retrieve
144
+ // non-recency scoring to the retrieval-tuned weights only when explicitly enabled.
145
+ function mcpDirectWeightsOption(): { weights: typeof QUERY_WEIGHTS } | undefined {
146
+ return loadVaultConfig().retrieval?.mcp_direct_tuned_weights ? { weights: QUERY_WEIGHTS } : undefined;
147
+ }
148
+
117
149
  function classifyRetrievalMode(query: string): "keyword" | "semantic" | "causal" | "timeline" | "discovery" | "complex" | "hybrid" {
118
150
  const q = query.toLowerCase();
119
151
 
@@ -161,7 +193,13 @@ function addLineNumbers(text: string, startLine: number = 1): string {
161
193
  // MCP Server
162
194
  // =============================================================================
163
195
 
164
- export async function startMcpServer(): Promise<void> {
196
+ /**
197
+ * Build the fully-registered MCP server WITHOUT connecting a transport.
198
+ * Extracted from startMcpServer so tests can drive the real tool handlers over an
199
+ * in-memory transport (route-level regressions for visibility exclusion + degraded
200
+ * markers). Returns the server plus a close() that releases every store handle.
201
+ */
202
+ export function buildMcpServer(): { server: McpServer; store: Store; closeAllStores: () => void } {
165
203
  const store = createStore(undefined, { busyTimeout: 5000 });
166
204
 
167
205
  // Vault store cache: prevents connection churn, closed on shutdown
@@ -251,13 +289,16 @@ This is the recommended entry point for ALL memory queries.`,
251
289
  mode: z.enum(["auto", "keyword", "semantic", "causal", "timeline", "discovery", "complex", "hybrid"]).optional().default("auto").describe("Override auto-detection: keyword=BM25, semantic=vector, causal=graph traversal, timeline=session history, discovery=similar docs, complex=multi-topic, hybrid=full pipeline"),
252
290
  limit: z.number().optional().default(10),
253
291
  compact: z.boolean().optional().default(true),
292
+ includeInternal: z.boolean().optional().default(false).describe("Include system-internal _clawmem docs (observations/deductions) — excluded by default"),
254
293
  vault: z.string().optional().describe("Named vault (omit for default vault)"),
255
294
  },
256
295
  },
257
- async ({ query, mode, limit, compact, vault }) => {
296
+ async ({ query, mode, limit, compact, includeInternal, vault }) => {
258
297
  const store = getStore(vault);
259
298
  const effectiveMode = mode === "auto" ? classifyRetrievalMode(query) : mode;
260
299
  const lim = limit || 10;
300
+ const excl = resolveExcludedCollections(includeInternal);
301
+ const degradedLegs: DegradedLeg[] = [];
261
302
 
262
303
  // --- Timeline mode → session log ---
263
304
  if (effectiveMode === "timeline") {
@@ -282,9 +323,16 @@ This is the recommended entry point for ALL memory queries.`,
282
323
  if (effectiveMode === "causal") {
283
324
  const llm = getDefaultLlamaCpp();
284
325
  const intent = await classifyIntent(query, llm, store.db);
285
- const bm25Results = store.searchFTS(query, 30);
326
+ const bm25Results = store.searchFTS(query, 30, undefined, undefined, undefined, excl);
286
327
  let vecResults: SearchResult[] = [];
287
- try { vecResults = await store.searchVec(query, DEFAULT_EMBED_MODEL, 30); } catch (e) { rethrowIfFatalVectorError(e); /* else: no vectors */ }
328
+ let causalDegraded: DegradedLeg | undefined;
329
+ try {
330
+ const det = await store.searchVecDetailed(query, DEFAULT_EMBED_MODEL, 30, { excludeCollections: excl });
331
+ vecResults = det.results;
332
+ // Causal mode runs ONE vector call — flat single-vector shape per the documented
333
+ // contract (T8-M5), not the multi-leg degradedLegs aggregate.
334
+ if (det.degraded && det.degradedReason) causalDegraded = { leg: "causal:vector", reason: det.degradedReason };
335
+ } catch (e) { rethrowIfFatalVectorError(e); /* else: no vectors */ }
288
336
  const rrfWeights = intent.intent === 'WHY' ? [1.0, 1.5] : intent.intent === 'WHEN' ? [1.5, 1.0] : [1.0, 1.0];
289
337
  const fusedRanked = reciprocalRankFusion([bm25Results.map(toRanked), vecResults.map(toRanked)], rrfWeights);
290
338
  const allSearch = [...bm25Results, ...vecResults];
@@ -300,6 +348,9 @@ This is the recommended entry point for ALL memory queries.`,
300
348
  const traversed = adaptiveTraversal(store.db, fused.slice(0, 10).map(r => ({ hash: r.hash, score: r.score })), {
301
349
  maxDepth: 2, beamWidth: 5, budget: 30,
302
350
  intent: intent.intent, queryEmbedding: anchorEmb.embedding,
351
+ // Visibility policy threaded INTO traversal (T3-H2): excluded nodes are pruned
352
+ // before beam selection and score normalization, not just hidden at hydration.
353
+ excludeCollections: excl,
303
354
  });
304
355
  const merged = mergeTraversalResults(store.db, fused.map(r => ({ hash: r.hash, score: r.score })), traversed);
305
356
  // Hydrate merged results back to SearchResult format
@@ -307,7 +358,8 @@ This is the recommended entry point for ALL memory queries.`,
307
358
  fused = merged.map(m => {
308
359
  const orig = fusedMap.get(m.hash);
309
360
  if (orig) return { ...orig, score: m.score };
310
- // Graph-discovered node — hydrate from DB
361
+ // Graph-discovered node — hydrate from DB (defense-in-depth visibility guard;
362
+ // traversal-level pruning is the primary barrier)
311
363
  const doc = store.db.prepare(`
312
364
  SELECT d.collection, d.path, d.title, d.hash, c.doc as body, d.modified_at
313
365
  FROM documents d
@@ -315,6 +367,7 @@ This is the recommended entry point for ALL memory queries.`,
315
367
  WHERE d.hash = ? AND d.active = 1 AND d.invalidated_at IS NULL LIMIT 1
316
368
  `).get(m.hash) as { collection: string; path: string; title: string; hash: string; body: string | null; modified_at: string } | undefined;
317
369
  if (!doc) return null;
370
+ if (excl && excl.includes(doc.collection)) return null;
318
371
  return {
319
372
  filepath: `clawmem://${doc.collection}/${doc.path}`,
320
373
  displayPath: `${doc.collection}/${doc.path}`,
@@ -335,56 +388,77 @@ This is the recommended entry point for ALL memory queries.`,
335
388
  }
336
389
 
337
390
  const enriched = enrichResults(store, fused, query);
338
- const scored = applyCompositeScoring(enriched, query).slice(0, lim);
391
+ const scored = applyCompositeScoring(enriched, query, undefined, mcpDirectWeightsOption()).slice(0, lim);
339
392
  const items = scored.map(r => ({
340
393
  docid: `#${r.docid}`, path: r.displayPath, title: r.title,
341
394
  score: Math.round(r.compositeScore * 100) / 100,
342
395
  snippet: (r.body || "").substring(0, 150), content_type: r.contentType,
343
396
  }));
397
+ const causalDegradedFields = causalDegraded ? { degraded: true as const, degradedReason: causalDegraded.reason } : {};
398
+ const degradedNote = causalDegraded ? `\n${degradedGuidanceText([causalDegraded])}` : "";
344
399
  return {
345
- content: [{ type: "text", text: `[routed: causal, intent: ${intent.intent}] ${formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query)}` }],
346
- structuredContent: { mode: effectiveMode, intent, results: items },
400
+ content: [{ type: "text", text: `[routed: causal, intent: ${intent.intent}] ${formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query)}${degradedNote}` }],
401
+ structuredContent: { mode: effectiveMode, intent, results: items, ...causalDegradedFields },
347
402
  };
348
403
  }
349
404
 
350
- // --- Complex mode → query decomposition ---
405
+ // --- Complex mode → query decomposition (multi-leg: per-clause degraded aggregation, T6-M1) ---
351
406
  if (effectiveMode === "complex") {
352
407
  const llm = getDefaultLlamaCpp();
353
408
  const clauses = await decomposeQuery(query, llm, store.db);
354
409
  const allResults: SearchResult[] = [];
410
+ let clauseIdx = 0;
355
411
  for (const clause of clauses.sort((a, b) => a.priority - b.priority)) {
412
+ const clauseExcl = resolveExcludedCollections(includeInternal, clause.collections);
356
413
  let results: SearchResult[] = [];
357
- if (clause.type === 'bm25') results = store.searchFTS(clause.query, 20, undefined, clause.collections);
358
- else if (clause.type === 'vector') { try { results = await store.searchVec(clause.query, DEFAULT_EMBED_MODEL, 20, undefined, clause.collections); } catch (e) { rethrowIfFatalVectorError(e); /* */ } }
359
- else if (clause.type === 'graph') { results = store.searchFTS(clause.query, 15, undefined, clause.collections); }
414
+ if (clause.type === 'bm25') results = store.searchFTS(clause.query, 20, undefined, clause.collections, undefined, clauseExcl);
415
+ else if (clause.type === 'vector') {
416
+ try {
417
+ const det = await store.searchVecDetailed(clause.query, DEFAULT_EMBED_MODEL, 20, { collections: clause.collections, excludeCollections: clauseExcl });
418
+ results = det.results;
419
+ if (det.degraded && det.degradedReason) degradedLegs.push({ leg: `complex:clause${clauseIdx}:vector`, reason: det.degradedReason });
420
+ } catch (e) { rethrowIfFatalVectorError(e); /* */ }
421
+ }
422
+ else if (clause.type === 'graph') { results = store.searchFTS(clause.query, 15, undefined, clause.collections, undefined, clauseExcl); }
360
423
  allResults.push(...results);
424
+ clauseIdx++;
361
425
  }
362
426
  const seen = new Set<string>();
363
427
  const deduped = allResults.filter(r => { if (seen.has(r.filepath)) return false; seen.add(r.filepath); return true; });
364
428
  const enriched = enrichResults(store, deduped, query);
365
- const scored = applyCompositeScoring(enriched, query).slice(0, lim);
429
+ const scored = applyCompositeScoring(enriched, query, undefined, mcpDirectWeightsOption()).slice(0, lim);
366
430
  const items = scored.map(r => ({
367
431
  docid: `#${r.docid}`, path: r.displayPath, title: r.title,
368
432
  score: Math.round(r.compositeScore * 100) / 100,
369
433
  snippet: (r.body || "").substring(0, 150), content_type: r.contentType,
370
434
  }));
435
+ const degradedNote = degradedLegs.length > 0 ? `\n${degradedGuidanceText(degradedLegs)}` : "";
371
436
  return {
372
- content: [{ type: "text", text: `[routed: complex, ${clauses.length} clauses] ${formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query)}` }],
373
- structuredContent: { mode: effectiveMode, clauses: clauses.length, results: items },
437
+ content: [{ type: "text", text: `[routed: complex, ${clauses.length} clauses] ${formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query)}${degradedNote}` }],
438
+ structuredContent: { mode: effectiveMode, clauses: clauses.length, results: items, ...(degradedLegs.length > 0 ? { degraded: true, degradedLegs } : {}) },
374
439
  };
375
440
  }
376
441
 
377
442
  // --- Keyword / Semantic / Discovery / Hybrid modes ---
378
443
  let results: SearchResult[] = [];
444
+ let singleLegDegraded: DegradedLeg | undefined;
379
445
  if (effectiveMode === "keyword") {
380
- results = store.searchFTS(query, lim);
446
+ results = store.searchFTS(query, lim, undefined, undefined, undefined, excl);
381
447
  } else if (effectiveMode === "semantic" || effectiveMode === "discovery") {
382
- try { results = await store.searchVec(query, DEFAULT_EMBED_MODEL, lim); } catch (e) { rethrowIfFatalVectorError(e); results = store.searchFTS(query, lim); }
448
+ try {
449
+ const det = await store.searchVecDetailed(query, DEFAULT_EMBED_MODEL, lim, { excludeCollections: excl });
450
+ results = det.results;
451
+ if (det.degraded && det.degradedReason) singleLegDegraded = { leg: `${effectiveMode}:vector`, reason: det.degradedReason };
452
+ } catch (e) { rethrowIfFatalVectorError(e); results = store.searchFTS(query, lim, undefined, undefined, undefined, excl); }
383
453
  } else {
384
454
  // Hybrid: BM25 + vector + RRF
385
- const bm25 = store.searchFTS(query, 30);
455
+ const bm25 = store.searchFTS(query, 30, undefined, undefined, undefined, excl);
386
456
  let vec: SearchResult[] = [];
387
- try { vec = await store.searchVec(query, DEFAULT_EMBED_MODEL, 30); } catch (e) { rethrowIfFatalVectorError(e); /* */ }
457
+ try {
458
+ const det = await store.searchVecDetailed(query, DEFAULT_EMBED_MODEL, 30, { excludeCollections: excl });
459
+ vec = det.results;
460
+ if (det.degraded && det.degradedReason) singleLegDegraded = { leg: "hybrid:vector", reason: det.degradedReason };
461
+ } catch (e) { rethrowIfFatalVectorError(e); /* */ }
388
462
  if (vec.length > 0) {
389
463
  const fusedRanked = reciprocalRankFusion([bm25.map(toRanked), vec.map(toRanked)], [1.0, 1.0]);
390
464
  const allSearch = [...bm25, ...vec];
@@ -398,7 +472,9 @@ This is the recommended entry point for ALL memory queries.`,
398
472
  }
399
473
 
400
474
  const enriched = enrichResults(store, results, query);
401
- const scored = applyCompositeScoring(enriched, query).slice(0, lim);
475
+ const scored = applyCompositeScoring(enriched, query, undefined, mcpDirectWeightsOption()).slice(0, lim);
476
+ const modeDegraded = singleLegDegraded ? { degraded: true as const, degradedReason: singleLegDegraded.reason } : {};
477
+ const modeDegradedNote = singleLegDegraded ? `\n${degradedGuidanceText([singleLegDegraded])}` : "";
402
478
  if (compact) {
403
479
  const items = scored.map(r => ({
404
480
  docid: `#${r.docid}`, path: r.displayPath, title: r.title,
@@ -406,8 +482,8 @@ This is the recommended entry point for ALL memory queries.`,
406
482
  snippet: (r.body || "").substring(0, 150), content_type: r.contentType,
407
483
  }));
408
484
  return {
409
- content: [{ type: "text", text: `[routed: ${effectiveMode}] ${formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query)}` }],
410
- structuredContent: { mode: effectiveMode, results: items },
485
+ content: [{ type: "text", text: `[routed: ${effectiveMode}] ${formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query)}${modeDegradedNote}` }],
486
+ structuredContent: { mode: effectiveMode, results: items, ...modeDegraded },
411
487
  };
412
488
  }
413
489
  const items: SearchResultItem[] = scored.map(r => {
@@ -420,8 +496,8 @@ This is the recommended entry point for ALL memory queries.`,
420
496
  };
421
497
  });
422
498
  return {
423
- content: [{ type: "text", text: `[routed: ${effectiveMode}] ${formatSearchSummary(items, query)}` }],
424
- structuredContent: { mode: effectiveMode, results: items },
499
+ content: [{ type: "text", text: `[routed: ${effectiveMode}] ${formatSearchSummary(items, query)}${modeDegradedNote}` }],
500
+ structuredContent: { mode: effectiveMode, results: items, ...modeDegraded },
425
501
  };
426
502
  }
427
503
  );
@@ -495,19 +571,21 @@ This is the recommended entry point for ALL memory queries.`,
495
571
  minScore: z.number().optional().default(0),
496
572
  collection: z.string().optional().describe("Filter to collection (single name or comma-separated)"),
497
573
  compact: z.boolean().optional().default(false).describe("Return compact results (id, path, title, score, snippet) instead of full content"),
574
+ includeInternal: z.boolean().optional().default(false).describe("Include system-internal _clawmem docs (observations/deductions) — excluded by default"),
498
575
  vault: z.string().optional().describe("Named vault (omit for default vault)"),
499
576
  },
500
577
  },
501
- async ({ query, limit, minScore, collection, compact, vault }) => {
578
+ async ({ query, limit, minScore, collection, compact, includeInternal, vault }) => {
502
579
  const store = getStore(vault);
503
580
  const collections = collection
504
581
  ? collection.split(",").map(c => c.trim()).filter(Boolean)
505
582
  : undefined;
506
- const results = store.searchFTS(query, limit || 10, undefined, collections);
583
+ const excl = resolveExcludedCollections(includeInternal, collections);
584
+ const results = store.searchFTS(query, limit || 10, undefined, collections, undefined, excl);
507
585
 
508
586
  const coFn = (path: string) => store.getCoActivated(path);
509
587
  const enriched = enrichResults(store, results, query);
510
- const scored = applyCompositeScoring(enriched, query, coFn)
588
+ const scored = applyCompositeScoring(enriched, query, coFn, mcpDirectWeightsOption())
511
589
  .filter(r => r.compositeScore >= (minScore || 0));
512
590
 
513
591
  if (compact) {
@@ -556,10 +634,11 @@ This is the recommended entry point for ALL memory queries.`,
556
634
  minScore: z.number().optional().default(0.3),
557
635
  collection: z.string().optional().describe("Filter to collection (single name or comma-separated)"),
558
636
  compact: z.boolean().optional().default(false).describe("Return compact results (id, path, title, score, snippet) instead of full content"),
637
+ includeInternal: z.boolean().optional().default(false).describe("Include system-internal _clawmem docs (observations/deductions) — excluded by default"),
559
638
  vault: z.string().optional().describe("Named vault (omit for default vault)"),
560
639
  },
561
640
  },
562
- async ({ query, limit, minScore, collection, compact, vault }) => {
641
+ async ({ query, limit, minScore, collection, compact, includeInternal, vault }) => {
563
642
  const store = getStore(vault);
564
643
  const tableExists = store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
565
644
  if (!tableExists) {
@@ -569,11 +648,15 @@ This is the recommended entry point for ALL memory queries.`,
569
648
  const collections = collection
570
649
  ? collection.split(",").map(c => c.trim()).filter(Boolean)
571
650
  : undefined;
572
- const results = await store.searchVec(query, DEFAULT_EMBED_MODEL, limit || 10, undefined, collections);
651
+ const excl = resolveExcludedCollections(includeInternal, collections);
652
+ const det = await store.searchVecDetailed(query, DEFAULT_EMBED_MODEL, limit || 10, { collections, excludeCollections: excl });
653
+ const results = det.results;
654
+ const vsDegraded = det.degraded && det.degradedReason ? { degraded: true as const, degradedReason: det.degradedReason } : {};
655
+ const vsDegradedNote = det.degraded && det.degradedReason ? `\n${degradedGuidanceText([{ leg: "vsearch", reason: det.degradedReason }])}` : "";
573
656
 
574
657
  const coFn = (path: string) => store.getCoActivated(path);
575
658
  const enriched = enrichResults(store, results, query);
576
- const scored = applyCompositeScoring(enriched, query, coFn)
659
+ const scored = applyCompositeScoring(enriched, query, coFn, mcpDirectWeightsOption())
577
660
  .filter(r => r.compositeScore >= (minScore || 0.3));
578
661
 
579
662
  if (compact) {
@@ -583,7 +666,7 @@ This is the recommended entry point for ALL memory queries.`,
583
666
  snippet: (r.body || "").substring(0, 150), content_type: r.contentType, modified_at: r.modifiedAt,
584
667
  fragment: r.fragmentType ? { type: r.fragmentType, label: r.fragmentLabel } : undefined,
585
668
  }));
586
- return { content: [{ type: "text", text: formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query) }], structuredContent: { results: items } };
669
+ return { content: [{ type: "text", text: `${formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query)}${vsDegradedNote}` }], structuredContent: { results: items, ...vsDegraded } };
587
670
  }
588
671
 
589
672
  const items: SearchResultItem[] = scored.map(r => {
@@ -601,8 +684,8 @@ This is the recommended entry point for ALL memory queries.`,
601
684
  });
602
685
 
603
686
  return {
604
- content: [{ type: "text", text: formatSearchSummary(items, query) }],
605
- structuredContent: { results: items },
687
+ content: [{ type: "text", text: `${formatSearchSummary(items, query)}${vsDegradedNote}` }],
688
+ structuredContent: { results: items, ...vsDegraded },
606
689
  };
607
690
  }
608
691
  );
@@ -625,14 +708,16 @@ This is the recommended entry point for ALL memory queries.`,
625
708
  diverse: z.boolean().optional().default(true).describe("Apply MMR diversity filter to reduce near-duplicate results"),
626
709
  intent: z.string().optional().describe("Domain intent hint for disambiguation — steers expansion, reranking, chunk selection, and snippet extraction"),
627
710
  candidateLimit: z.number().optional().default(30).describe("Max candidates reaching the reranker (default 30)"),
711
+ includeInternal: z.boolean().optional().default(false).describe("Include system-internal _clawmem docs (observations/deductions) — excluded by default"),
628
712
  vault: z.string().optional().describe("Named vault (omit for default vault)"),
629
713
  },
630
714
  },
631
- async ({ query, limit, minScore, collection, compact, diverse, intent, candidateLimit, vault }) => {
715
+ async ({ query, limit, minScore, collection, compact, diverse, intent, candidateLimit, includeInternal, vault }) => {
632
716
  const store = getStore(vault);
633
717
  const candLimit = candidateLimit || 30;
634
718
  const rankedLists: RankedResult[][] = [];
635
719
  const docidMap = new Map<string, string>();
720
+ const degradedLegs: DegradedLeg[] = [];
636
721
  const hasVectors = !!store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
637
722
 
638
723
  // Step 0: Temporal constraint extraction (pure regex, ~0ms)
@@ -642,7 +727,8 @@ This is the recommended entry point for ALL memory queries.`,
642
727
  const collections = collection
643
728
  ? collection.split(",").map(c => c.trim()).filter(Boolean)
644
729
  : undefined;
645
- const initialFts = store.searchFTS(query, 20, undefined, collections, dateRange);
730
+ const excl = resolveExcludedCollections(includeInternal, collections);
731
+ const initialFts = store.searchFTS(query, 20, undefined, collections, dateRange, excl);
646
732
  const topScore = initialFts.length > 0 ? Math.abs(initialFts[0]!.score) : 0;
647
733
  const secondScore = initialFts.length > 1 ? Math.abs(initialFts[1]!.score) : 0;
648
734
  // When intent is provided, disable strong-signal bypass — the obvious BM25
@@ -663,30 +749,34 @@ This is the recommended entry point for ALL memory queries.`,
663
749
  rankedLists.push(initialFts.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
664
750
  }
665
751
  if (hasVectors) {
666
- const vecResults = await store.searchVec(query, DEFAULT_EMBED_MODEL, 20, undefined, collections, dateRange);
667
- if (vecResults.length > 0) {
668
- for (const r of vecResults) docidMap.set(r.filepath, r.docid);
669
- rankedLists.push(vecResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
752
+ const det = await store.searchVecDetailed(query, DEFAULT_EMBED_MODEL, 20, { collections, dateRange, excludeCollections: excl });
753
+ if (det.degraded && det.degradedReason) degradedLegs.push({ leg: "vector:original", reason: det.degradedReason });
754
+ if (det.results.length > 0) {
755
+ for (const r of det.results) docidMap.set(r.filepath, r.docid);
756
+ rankedLists.push(det.results.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
670
757
  }
671
758
  }
672
759
  // Lists contributed by the original query — these get the 2× RRF weight.
673
760
  const numOriginalLists = rankedLists.length;
674
761
 
675
762
  // Typed expansions — route by type: lex → FTS, vec/hyde → vector.
763
+ let expansionIdx = 0;
676
764
  for (const eq of expanded) {
677
765
  if (eq.type === 'lex') {
678
- const ftsResults = store.searchFTS(eq.query, 20, undefined, collections, dateRange);
766
+ const ftsResults = store.searchFTS(eq.query, 20, undefined, collections, dateRange, excl);
679
767
  if (ftsResults.length > 0) {
680
768
  for (const r of ftsResults) docidMap.set(r.filepath, r.docid);
681
769
  rankedLists.push(ftsResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
682
770
  }
683
771
  } else if (hasVectors) {
684
- const vecResults = await store.searchVec(eq.query, DEFAULT_EMBED_MODEL, 20, undefined, collections, dateRange);
685
- if (vecResults.length > 0) {
686
- for (const r of vecResults) docidMap.set(r.filepath, r.docid);
687
- rankedLists.push(vecResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
772
+ const det = await store.searchVecDetailed(eq.query, DEFAULT_EMBED_MODEL, 20, { collections, dateRange, excludeCollections: excl });
773
+ if (det.degraded && det.degradedReason) degradedLegs.push({ leg: `vector:expansion${expansionIdx}`, reason: det.degradedReason });
774
+ if (det.results.length > 0) {
775
+ for (const r of det.results) docidMap.set(r.filepath, r.docid);
776
+ rankedLists.push(det.results.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
688
777
  }
689
778
  }
779
+ expansionIdx++;
690
780
  }
691
781
 
692
782
  // Step 2b: Temporal proximity channel (if dateRange detected)
@@ -694,14 +784,17 @@ This is the recommended entry point for ALL memory queries.`,
694
784
  if (dateRange) {
695
785
  const centerMs = (new Date(dateRange.start).getTime() + new Date(dateRange.end).getTime()) / 2;
696
786
  const rangeMs = Math.max(new Date(dateRange.end).getTime() - new Date(dateRange.start).getTime(), 86400000);
787
+ const temporalExclSql = excl && excl.length > 0
788
+ ? ` AND d.collection NOT IN (${excl.map(() => '?').join(',')})`
789
+ : "";
697
790
  const temporalDocs = store.db.prepare(`
698
791
  SELECT 'clawmem://' || d.collection || '/' || d.path as filepath,
699
792
  d.collection || '/' || d.path as displayPath,
700
793
  d.title, d.modified_at
701
794
  FROM documents d
702
- WHERE d.active = 1 AND d.invalidated_at IS NULL AND d.modified_at >= ? AND d.modified_at <= ?
795
+ WHERE d.active = 1 AND d.invalidated_at IS NULL AND d.modified_at >= ? AND d.modified_at <= ?${temporalExclSql}
703
796
  ORDER BY d.modified_at DESC LIMIT 30
704
- `).all(dateRange.start, dateRange.end) as { filepath: string; displayPath: string; title: string; modified_at: string }[];
797
+ `).all(dateRange.start, dateRange.end, ...(excl ?? [])) as { filepath: string; displayPath: string; title: string; modified_at: string }[];
705
798
 
706
799
  if (temporalDocs.length > 0) {
707
800
  const temporalRanked: RankedResult[] = temporalDocs.map(d => {
@@ -732,6 +825,7 @@ This is the recommended entry point for ALL memory queries.`,
732
825
  WHERE d.id = ? AND d.active = 1 AND d.invalidated_at IS NULL LIMIT 1
733
826
  `).get(en.docId) as { collection: string; path: string; title: string; body: string | null } | undefined;
734
827
  if (!doc) return null;
828
+ if (excl && excl.includes(doc.collection)) return null;
735
829
  return {
736
830
  file: `clawmem://${doc.collection}/${doc.path}`,
737
831
  displayPath: `${doc.collection}/${doc.path}`,
@@ -836,6 +930,10 @@ This is the recommended entry point for ALL memory queries.`,
836
930
  if (diverse !== false) scored = applyMMRDiversity(scored);
837
931
  scored = scored.slice(0, limit || 10);
838
932
 
933
+ // Multi-leg degraded aggregation (T5-M1): route marker = any(leg), per-leg reasons retained.
934
+ const queryDegraded = degradedLegs.length > 0 ? { degraded: true as const, degradedLegs } : {};
935
+ const queryDegradedNote = degradedLegs.length > 0 ? `\n${degradedGuidanceText(degradedLegs)}` : "";
936
+
839
937
  if (compact) {
840
938
  const items = scored.map(r => ({
841
939
  docid: `#${docidMap.get(r.filepath) || r.docid}`, path: r.displayPath, title: r.title,
@@ -843,7 +941,7 @@ This is the recommended entry point for ALL memory queries.`,
843
941
  snippet: (r.body || "").substring(0, 150), content_type: r.contentType, modified_at: r.modifiedAt,
844
942
  fragment: r.fragmentType ? { type: r.fragmentType, label: r.fragmentLabel } : undefined,
845
943
  }));
846
- return { content: [{ type: "text", text: formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query) }], structuredContent: { results: items } };
944
+ return { content: [{ type: "text", text: `${formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query)}${queryDegradedNote}` }], structuredContent: { results: items, ...queryDegraded } };
847
945
  }
848
946
 
849
947
  const items: SearchResultItem[] = scored.map(r => {
@@ -861,8 +959,8 @@ This is the recommended entry point for ALL memory queries.`,
861
959
  });
862
960
 
863
961
  return {
864
- content: [{ type: "text", text: formatSearchSummary(items, query) }],
865
- structuredContent: { results: items },
962
+ content: [{ type: "text", text: `${formatSearchSummary(items, query)}${queryDegradedNote}` }],
963
+ structuredContent: { results: items, ...queryDegraded },
866
964
  };
867
965
  }
868
966
  );
@@ -1272,10 +1370,11 @@ This is the recommended entry point for ALL memory queries.`,
1272
1370
  inputSchema: {
1273
1371
  file: z.string().describe("Path of reference document"),
1274
1372
  limit: z.number().optional().default(5),
1373
+ includeInternal: z.boolean().optional().default(false).describe("Include system-internal _clawmem docs — excluded by default (auto-included when the REFERENCE doc is itself internal)"),
1275
1374
  vault: z.string().optional().describe("Named vault (omit for default vault)"),
1276
1375
  },
1277
1376
  },
1278
- async ({ file, limit, vault }) => {
1377
+ async ({ file, limit, includeInternal, vault }) => {
1279
1378
  const store = getStore(vault);
1280
1379
  const tableExists = store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
1281
1380
  if (!tableExists) {
@@ -1291,14 +1390,21 @@ This is the recommended entry point for ALL memory queries.`,
1291
1390
  const body = store.getDocumentBody(result) ?? "";
1292
1391
  const title = result.title || file;
1293
1392
 
1393
+ // Internal-reference auto-exception (T2-M7): exploring FROM an internal doc is an
1394
+ // explicit ask for the internal space — include internal neighbors.
1395
+ const refIsInternal = INTERNAL_COLLECTIONS.includes(result.collectionName);
1396
+ const excl = resolveExcludedCollections(includeInternal || refIsInternal);
1397
+
1294
1398
  // Use the document's content as the search query
1295
1399
  const queryText = `${title}\n${body.slice(0, 1000)}`;
1296
- const vecResults = await store.searchVec(queryText, DEFAULT_EMBED_MODEL, (limit || 5) + 1);
1400
+ const det = await store.searchVecDetailed(queryText, DEFAULT_EMBED_MODEL, (limit || 5) + 1, { excludeCollections: excl });
1297
1401
 
1298
1402
  // Filter out the reference document itself
1299
- const similar = vecResults
1403
+ const similar = det.results
1300
1404
  .filter(r => r.filepath !== result.filepath)
1301
1405
  .slice(0, limit || 5);
1406
+ const fsDegraded = det.degraded && det.degradedReason ? { degraded: true as const, degradedReason: det.degradedReason } : {};
1407
+ const fsDegradedNote = det.degraded && det.degradedReason ? `\n${degradedGuidanceText([{ leg: "find_similar", reason: det.degradedReason }])}` : "";
1302
1408
 
1303
1409
  const items: SearchResultItem[] = similar.map(r => {
1304
1410
  const { line, snippet } = extractSnippet(r.body || "", title, 200);
@@ -1313,8 +1419,8 @@ This is the recommended entry point for ALL memory queries.`,
1313
1419
  });
1314
1420
 
1315
1421
  return {
1316
- content: [{ type: "text", text: `${items.length} similar to "${title}":\n${items.map(i => ` ${i.file} (${Math.round(i.score * 100)}%)`).join('\n')}` }],
1317
- structuredContent: { reference: file, results: items },
1422
+ content: [{ type: "text", text: `${items.length} similar to "${title}":\n${items.map(i => ` ${i.file} (${Math.round(i.score * 100)}%)`).join('\n')}${fsDegradedNote}` }],
1423
+ structuredContent: { reference: file, results: items, ...fsDegraded },
1318
1424
  };
1319
1425
  }
1320
1426
  );
@@ -1763,10 +1869,11 @@ This is the recommended entry point for ALL memory queries.`,
1763
1869
  query: z.string().describe("Complex or multi-topic query"),
1764
1870
  limit: z.number().optional().default(10),
1765
1871
  compact: z.boolean().optional().default(true).describe("Return compact results"),
1872
+ includeInternal: z.boolean().optional().default(false).describe("Include system-internal _clawmem docs (observations/deductions) — excluded by default"),
1766
1873
  vault: z.string().optional().describe("Named vault (omit for default vault)"),
1767
1874
  },
1768
1875
  },
1769
- async ({ query, limit, compact, vault }) => {
1876
+ async ({ query, limit, compact, includeInternal, vault }) => {
1770
1877
  const store = getStore(vault);
1771
1878
  const llm = getDefaultLlamaCpp();
1772
1879
 
@@ -1777,18 +1884,25 @@ This is the recommended entry point for ALL memory queries.`,
1777
1884
  const sortedClauses = [...clauses].sort((a, b) => a.priority - b.priority);
1778
1885
  const allResults: SearchResult[] = [];
1779
1886
  const clauseDetails: { type: string; query: string; priority: number; resultCount: number }[] = [];
1887
+ const degradedLegs: DegradedLeg[] = [];
1780
1888
 
1889
+ let clauseIdx = 0;
1781
1890
  for (const clause of sortedClauses) {
1891
+ const clauseExcl = resolveExcludedCollections(includeInternal, clause.collections);
1782
1892
  let results: SearchResult[] = [];
1783
1893
  if (clause.type === 'bm25') {
1784
- results = store.searchFTS(clause.query, 20, undefined, clause.collections);
1894
+ results = store.searchFTS(clause.query, 20, undefined, clause.collections, undefined, clauseExcl);
1785
1895
  } else if (clause.type === 'vector') {
1786
- results = await store.searchVec(clause.query, DEFAULT_EMBED_MODEL, 20, undefined, clause.collections);
1896
+ const det = await store.searchVecDetailed(clause.query, DEFAULT_EMBED_MODEL, 20, { collections: clause.collections, excludeCollections: clauseExcl });
1897
+ results = det.results;
1898
+ if (det.degraded && det.degradedReason) degradedLegs.push({ leg: `clause${clauseIdx}:vector`, reason: det.degradedReason });
1787
1899
  } else if (clause.type === 'graph') {
1788
1900
  // Graph clause: run intent_search-style retrieval
1789
1901
  const intent = await classifyIntent(clause.query, llm, store.db);
1790
- const bm25 = store.searchFTS(clause.query, 15, undefined, clause.collections);
1791
- const vec = await store.searchVec(clause.query, DEFAULT_EMBED_MODEL, 15, undefined, clause.collections);
1902
+ const bm25 = store.searchFTS(clause.query, 15, undefined, clause.collections, undefined, clauseExcl);
1903
+ const detGraph = await store.searchVecDetailed(clause.query, DEFAULT_EMBED_MODEL, 15, { collections: clause.collections, excludeCollections: clauseExcl });
1904
+ const vec = detGraph.results;
1905
+ if (detGraph.degraded && detGraph.degradedReason) degradedLegs.push({ leg: `clause${clauseIdx}:graph:vector`, reason: detGraph.degradedReason });
1792
1906
  const fused = reciprocalRankFusion([bm25.map(toRanked), vec.map(toRanked)], [1.0, 1.0]);
1793
1907
  const searchMap = new Map([...bm25, ...vec].map(r => [r.filepath, r]));
1794
1908
  results = fused
@@ -1801,13 +1915,15 @@ This is the recommended entry point for ALL memory queries.`,
1801
1915
  if (anchorEmb) {
1802
1916
  const traversed = adaptiveTraversal(store.db, results.slice(0, 5).map(r => ({ hash: r.hash, score: r.score })), {
1803
1917
  maxDepth: 2, beamWidth: 3, budget: 15, intent: intent.intent, queryEmbedding: anchorEmb.embedding,
1918
+ // Visibility policy threaded INTO traversal (T6-H1): parity with memory_retrieve causal
1919
+ excludeCollections: clauseExcl,
1804
1920
  });
1805
1921
  const merged = mergeTraversalResults(store.db, results.map(r => ({ hash: r.hash, score: r.score })), traversed);
1806
1922
  const expandedMap = new Map(results.map(r => [r.hash, r]));
1807
1923
  results = merged.map(m => {
1808
1924
  const existing = expandedMap.get(m.hash);
1809
1925
  if (existing) return { ...existing, score: m.score };
1810
- // Graph-discovered node — hydrate from DB
1926
+ // Graph-discovered node — hydrate from DB (defense-in-depth visibility guard)
1811
1927
  const doc = store.db.prepare(`
1812
1928
  SELECT d.collection, d.path, d.title, d.hash, c.doc as body, d.modified_at
1813
1929
  FROM documents d
@@ -1815,6 +1931,7 @@ This is the recommended entry point for ALL memory queries.`,
1815
1931
  WHERE d.hash = ? AND d.active = 1 AND d.invalidated_at IS NULL LIMIT 1
1816
1932
  `).get(m.hash) as { collection: string; path: string; title: string; hash: string; body: string | null; modified_at: string } | undefined;
1817
1933
  if (!doc) return null;
1934
+ if (clauseExcl && clauseExcl.includes(doc.collection)) return null;
1818
1935
  return {
1819
1936
  filepath: `clawmem://${doc.collection}/${doc.path}`,
1820
1937
  displayPath: `${doc.collection}/${doc.path}`,
@@ -1835,6 +1952,7 @@ This is the recommended entry point for ALL memory queries.`,
1835
1952
  }
1836
1953
  clauseDetails.push({ type: clause.type, query: clause.query, priority: clause.priority, resultCount: results.length });
1837
1954
  allResults.push(...results);
1955
+ clauseIdx++;
1838
1956
  }
1839
1957
 
1840
1958
  // Deduplicate by filepath, keeping highest score
@@ -1863,6 +1981,8 @@ This is the recommended entry point for ALL memory queries.`,
1863
1981
  const scored = applyCompositeScoring(enriched, query, coFn).slice(0, limit || 10);
1864
1982
 
1865
1983
  const planSummary = clauseDetails.map(c => ` ${c.type}(p${c.priority}): "${c.query}" → ${c.resultCount} results`).join("\n");
1984
+ const planDegraded = degradedLegs.length > 0 ? { degraded: true as const, degradedLegs } : {};
1985
+ const planDegradedNote = degradedLegs.length > 0 ? `\n${degradedGuidanceText(degradedLegs)}` : "";
1866
1986
 
1867
1987
  if (compact) {
1868
1988
  const items = scored.map(r => ({
@@ -1871,8 +1991,8 @@ This is the recommended entry point for ALL memory queries.`,
1871
1991
  snippet: (r.body || "").substring(0, 150), content_type: r.contentType, modified_at: r.modifiedAt,
1872
1992
  }));
1873
1993
  return {
1874
- content: [{ type: "text", text: `Query Plan (${sortedClauses.length} clauses):\n${planSummary}\n\n${formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query)}` }],
1875
- structuredContent: { plan: clauseDetails, results: items },
1994
+ content: [{ type: "text", text: `Query Plan (${sortedClauses.length} clauses):\n${planSummary}\n\n${formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query)}${planDegradedNote}` }],
1995
+ structuredContent: { plan: clauseDetails, results: items, ...planDegraded },
1876
1996
  };
1877
1997
  }
1878
1998
 
@@ -1881,8 +2001,8 @@ This is the recommended entry point for ALL memory queries.`,
1881
2001
  compositeScore: r.compositeScore, context: r.context, snippet: r.body?.slice(0, 300) || '', contentType: r.contentType,
1882
2002
  }));
1883
2003
  return {
1884
- content: [{ type: "text", text: `Query Plan (${sortedClauses.length} clauses):\n${planSummary}\n\n${formatSearchSummary(items, query)}` }],
1885
- structuredContent: { plan: clauseDetails, results: items },
2004
+ content: [{ type: "text", text: `Query Plan (${sortedClauses.length} clauses):\n${planSummary}\n\n${formatSearchSummary(items, query)}${planDegradedNote}` }],
2005
+ structuredContent: { plan: clauseDetails, results: items, ...planDegraded },
1886
2006
  };
1887
2007
  }
1888
2008
  );
@@ -2650,6 +2770,12 @@ This is the recommended entry point for ALL memory queries.`,
2650
2770
  }
2651
2771
  );
2652
2772
 
2773
+ return { server, store, closeAllStores };
2774
+ }
2775
+
2776
+ export async function startMcpServer(): Promise<void> {
2777
+ const { server, store, closeAllStores } = buildMcpServer();
2778
+
2653
2779
  // ---------------------------------------------------------------------------
2654
2780
  // Connect
2655
2781
  // ---------------------------------------------------------------------------
package/src/memory.ts CHANGED
@@ -199,6 +199,7 @@ export const QUERY_WEIGHTS: CompositeWeights = { search: 0.7, recency: 0.15, con
199
199
 
200
200
  const RECENCY_PATTERNS = [
201
201
  /\brecent(ly)?\b/i,
202
+ /\blatest\b/i,
202
203
  /\blast\s+(session|time|week|month|few\s+days)\b/i,
203
204
  /\bleft\s+off\b/i,
204
205
  /\bwhere\s+(was|were)\s+(we|i)\b/i,