clawmem 0.20.2 → 0.22.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
@@ -34,6 +34,7 @@ import {
34
34
  type CoActivationFn,
35
35
  } from "./memory.ts";
36
36
  import { enrichResults, reciprocalRankFusion, toRanked, blendRerank, type RankedResult } from "./search-utils.ts";
37
+ import { selectScoringRegime, rankRawPrimary, VECTOR_SCORE_BASIS, COMPOSITE_SCORE_BASIS } from "./scoring-regime.ts";
37
38
  import { applyMMRDiversity } from "./mmr.ts";
38
39
  import { indexCollection, type IndexStats } from "./indexer.ts";
39
40
  import { listCollections } from "./collections.ts";
@@ -114,6 +115,32 @@ function splitIntoWindows(text: string, windowSize: number, overlap = 200): stri
114
115
  }
115
116
 
116
117
  /** Classify query into retrieval mode based on signal patterns */
118
+ // =============================================================================
119
+ // Retrieval visibility policy (VSEARCH-TRUST-HARDENING (b))
120
+ // =============================================================================
121
+
122
+ // System-internal collections excluded from MCP retrieval by default. Hook precedent:
123
+ // FILTERED_PATHS in context-surfacing. Opt-ins: the includeInternal param, or an explicit
124
+ // collection filter naming an internal collection.
125
+ const INTERNAL_COLLECTIONS = ["_clawmem"];
126
+
127
+ function resolveExcludedCollections(includeInternal: boolean | undefined, collections?: string[]): string[] | undefined {
128
+ if (includeInternal) return undefined;
129
+ if (collections && collections.some(c => INTERNAL_COLLECTIONS.includes(c))) return undefined;
130
+ return INTERNAL_COLLECTIONS;
131
+ }
132
+
133
+ type DegradedLeg = { leg: string; reason: "excluded-dominant" | "cap-truncation" };
134
+
135
+ // Only excluded-dominant carries the includeInternal advice — cap-truncation must stay
136
+ // truthful when the shortfall is dedup-driven (T5-M2).
137
+ function degradedGuidanceText(legs: DegradedLeg[]): string {
138
+ const anyExcludedDominant = legs.some(l => l.reason === "excluded-dominant");
139
+ return anyExcludedDominant
140
+ ? "Note: nearest-neighbor region dominated by excluded internal docs — pass includeInternal:true or refine the query."
141
+ : "Note: vector results truncated at the scan cap.";
142
+ }
143
+
117
144
  function classifyRetrievalMode(query: string): "keyword" | "semantic" | "causal" | "timeline" | "discovery" | "complex" | "hybrid" {
118
145
  const q = query.toLowerCase();
119
146
 
@@ -161,7 +188,13 @@ function addLineNumbers(text: string, startLine: number = 1): string {
161
188
  // MCP Server
162
189
  // =============================================================================
163
190
 
164
- export async function startMcpServer(): Promise<void> {
191
+ /**
192
+ * Build the fully-registered MCP server WITHOUT connecting a transport.
193
+ * Extracted from startMcpServer so tests can drive the real tool handlers over an
194
+ * in-memory transport (route-level regressions for visibility exclusion + degraded
195
+ * markers). Returns the server plus a close() that releases every store handle.
196
+ */
197
+ export function buildMcpServer(): { server: McpServer; store: Store; closeAllStores: () => void } {
165
198
  const store = createStore(undefined, { busyTimeout: 5000 });
166
199
 
167
200
  // Vault store cache: prevents connection churn, closed on shutdown
@@ -251,13 +284,16 @@ This is the recommended entry point for ALL memory queries.`,
251
284
  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
285
  limit: z.number().optional().default(10),
253
286
  compact: z.boolean().optional().default(true),
287
+ includeInternal: z.boolean().optional().default(false).describe("Include system-internal _clawmem docs (observations/deductions) — excluded by default"),
254
288
  vault: z.string().optional().describe("Named vault (omit for default vault)"),
255
289
  },
256
290
  },
257
- async ({ query, mode, limit, compact, vault }) => {
291
+ async ({ query, mode, limit, compact, includeInternal, vault }) => {
258
292
  const store = getStore(vault);
259
293
  const effectiveMode = mode === "auto" ? classifyRetrievalMode(query) : mode;
260
294
  const lim = limit || 10;
295
+ const excl = resolveExcludedCollections(includeInternal);
296
+ const degradedLegs: DegradedLeg[] = [];
261
297
 
262
298
  // --- Timeline mode → session log ---
263
299
  if (effectiveMode === "timeline") {
@@ -282,9 +318,16 @@ This is the recommended entry point for ALL memory queries.`,
282
318
  if (effectiveMode === "causal") {
283
319
  const llm = getDefaultLlamaCpp();
284
320
  const intent = await classifyIntent(query, llm, store.db);
285
- const bm25Results = store.searchFTS(query, 30);
321
+ const bm25Results = store.searchFTS(query, 30, undefined, undefined, undefined, excl);
286
322
  let vecResults: SearchResult[] = [];
287
- try { vecResults = await store.searchVec(query, DEFAULT_EMBED_MODEL, 30); } catch (e) { rethrowIfFatalVectorError(e); /* else: no vectors */ }
323
+ let causalDegraded: DegradedLeg | undefined;
324
+ try {
325
+ const det = await store.searchVecDetailed(query, DEFAULT_EMBED_MODEL, 30, { excludeCollections: excl });
326
+ vecResults = det.results;
327
+ // Causal mode runs ONE vector call — flat single-vector shape per the documented
328
+ // contract (T8-M5), not the multi-leg degradedLegs aggregate.
329
+ if (det.degraded && det.degradedReason) causalDegraded = { leg: "causal:vector", reason: det.degradedReason };
330
+ } catch (e) { rethrowIfFatalVectorError(e); /* else: no vectors */ }
288
331
  const rrfWeights = intent.intent === 'WHY' ? [1.0, 1.5] : intent.intent === 'WHEN' ? [1.5, 1.0] : [1.0, 1.0];
289
332
  const fusedRanked = reciprocalRankFusion([bm25Results.map(toRanked), vecResults.map(toRanked)], rrfWeights);
290
333
  const allSearch = [...bm25Results, ...vecResults];
@@ -300,6 +343,9 @@ This is the recommended entry point for ALL memory queries.`,
300
343
  const traversed = adaptiveTraversal(store.db, fused.slice(0, 10).map(r => ({ hash: r.hash, score: r.score })), {
301
344
  maxDepth: 2, beamWidth: 5, budget: 30,
302
345
  intent: intent.intent, queryEmbedding: anchorEmb.embedding,
346
+ // Visibility policy threaded INTO traversal (T3-H2): excluded nodes are pruned
347
+ // before beam selection and score normalization, not just hidden at hydration.
348
+ excludeCollections: excl,
303
349
  });
304
350
  const merged = mergeTraversalResults(store.db, fused.map(r => ({ hash: r.hash, score: r.score })), traversed);
305
351
  // Hydrate merged results back to SearchResult format
@@ -307,7 +353,8 @@ This is the recommended entry point for ALL memory queries.`,
307
353
  fused = merged.map(m => {
308
354
  const orig = fusedMap.get(m.hash);
309
355
  if (orig) return { ...orig, score: m.score };
310
- // Graph-discovered node — hydrate from DB
356
+ // Graph-discovered node — hydrate from DB (defense-in-depth visibility guard;
357
+ // traversal-level pruning is the primary barrier)
311
358
  const doc = store.db.prepare(`
312
359
  SELECT d.collection, d.path, d.title, d.hash, c.doc as body, d.modified_at
313
360
  FROM documents d
@@ -315,6 +362,7 @@ This is the recommended entry point for ALL memory queries.`,
315
362
  WHERE d.hash = ? AND d.active = 1 AND d.invalidated_at IS NULL LIMIT 1
316
363
  `).get(m.hash) as { collection: string; path: string; title: string; hash: string; body: string | null; modified_at: string } | undefined;
317
364
  if (!doc) return null;
365
+ if (excl && excl.includes(doc.collection)) return null;
318
366
  return {
319
367
  filepath: `clawmem://${doc.collection}/${doc.path}`,
320
368
  displayPath: `${doc.collection}/${doc.path}`,
@@ -341,23 +389,34 @@ This is the recommended entry point for ALL memory queries.`,
341
389
  score: Math.round(r.compositeScore * 100) / 100,
342
390
  snippet: (r.body || "").substring(0, 150), content_type: r.contentType,
343
391
  }));
392
+ const causalDegradedFields = causalDegraded ? { degraded: true as const, degradedReason: causalDegraded.reason } : {};
393
+ const degradedNote = causalDegraded ? `\n${degradedGuidanceText([causalDegraded])}` : "";
344
394
  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 },
395
+ content: [{ type: "text", text: `[routed: causal, intent: ${intent.intent}] ${formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query)}${degradedNote}` }],
396
+ structuredContent: { mode: effectiveMode, intent, results: items, ...causalDegradedFields },
347
397
  };
348
398
  }
349
399
 
350
- // --- Complex mode → query decomposition ---
400
+ // --- Complex mode → query decomposition (multi-leg: per-clause degraded aggregation, T6-M1) ---
351
401
  if (effectiveMode === "complex") {
352
402
  const llm = getDefaultLlamaCpp();
353
403
  const clauses = await decomposeQuery(query, llm, store.db);
354
404
  const allResults: SearchResult[] = [];
405
+ let clauseIdx = 0;
355
406
  for (const clause of clauses.sort((a, b) => a.priority - b.priority)) {
407
+ const clauseExcl = resolveExcludedCollections(includeInternal, clause.collections);
356
408
  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); }
409
+ if (clause.type === 'bm25') results = store.searchFTS(clause.query, 20, undefined, clause.collections, undefined, clauseExcl);
410
+ else if (clause.type === 'vector') {
411
+ try {
412
+ const det = await store.searchVecDetailed(clause.query, DEFAULT_EMBED_MODEL, 20, { collections: clause.collections, excludeCollections: clauseExcl });
413
+ results = det.results;
414
+ if (det.degraded && det.degradedReason) degradedLegs.push({ leg: `complex:clause${clauseIdx}:vector`, reason: det.degradedReason });
415
+ } catch (e) { rethrowIfFatalVectorError(e); /* */ }
416
+ }
417
+ else if (clause.type === 'graph') { results = store.searchFTS(clause.query, 15, undefined, clause.collections, undefined, clauseExcl); }
360
418
  allResults.push(...results);
419
+ clauseIdx++;
361
420
  }
362
421
  const seen = new Set<string>();
363
422
  const deduped = allResults.filter(r => { if (seen.has(r.filepath)) return false; seen.add(r.filepath); return true; });
@@ -368,23 +427,37 @@ This is the recommended entry point for ALL memory queries.`,
368
427
  score: Math.round(r.compositeScore * 100) / 100,
369
428
  snippet: (r.body || "").substring(0, 150), content_type: r.contentType,
370
429
  }));
430
+ const degradedNote = degradedLegs.length > 0 ? `\n${degradedGuidanceText(degradedLegs)}` : "";
371
431
  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 },
432
+ content: [{ type: "text", text: `[routed: complex, ${clauses.length} clauses] ${formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query)}${degradedNote}` }],
433
+ structuredContent: { mode: effectiveMode, clauses: clauses.length, results: items, ...(degradedLegs.length > 0 ? { degraded: true, degradedLegs } : {}) },
374
434
  };
375
435
  }
376
436
 
377
437
  // --- Keyword / Semantic / Discovery / Hybrid modes ---
378
438
  let results: SearchResult[] = [];
439
+ let singleLegDegraded: DegradedLeg | undefined;
440
+ // semantic/discovery: the raw regime applies ONLY to results the vector leg actually
441
+ // served — the FTS fallback's scores are not cosine, so it keeps composite scoring.
442
+ let vectorLegServed = false;
379
443
  if (effectiveMode === "keyword") {
380
- results = store.searchFTS(query, lim);
444
+ results = store.searchFTS(query, lim, undefined, undefined, undefined, excl);
381
445
  } 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); }
446
+ try {
447
+ const det = await store.searchVecDetailed(query, DEFAULT_EMBED_MODEL, lim, { excludeCollections: excl });
448
+ results = det.results;
449
+ vectorLegServed = true;
450
+ if (det.degraded && det.degradedReason) singleLegDegraded = { leg: `${effectiveMode}:vector`, reason: det.degradedReason };
451
+ } catch (e) { rethrowIfFatalVectorError(e); results = store.searchFTS(query, lim, undefined, undefined, undefined, excl); }
383
452
  } else {
384
453
  // Hybrid: BM25 + vector + RRF
385
- const bm25 = store.searchFTS(query, 30);
454
+ const bm25 = store.searchFTS(query, 30, undefined, undefined, undefined, excl);
386
455
  let vec: SearchResult[] = [];
387
- try { vec = await store.searchVec(query, DEFAULT_EMBED_MODEL, 30); } catch (e) { rethrowIfFatalVectorError(e); /* */ }
456
+ try {
457
+ const det = await store.searchVecDetailed(query, DEFAULT_EMBED_MODEL, 30, { excludeCollections: excl });
458
+ vec = det.results;
459
+ if (det.degraded && det.degradedReason) singleLegDegraded = { leg: "hybrid:vector", reason: det.degradedReason };
460
+ } catch (e) { rethrowIfFatalVectorError(e); /* */ }
388
461
  if (vec.length > 0) {
389
462
  const fusedRanked = reciprocalRankFusion([bm25.map(toRanked), vec.map(toRanked)], [1.0, 1.0]);
390
463
  const allSearch = [...bm25, ...vec];
@@ -398,7 +471,19 @@ This is the recommended entry point for ALL memory queries.`,
398
471
  }
399
472
 
400
473
  const enriched = enrichResults(store, results, query);
401
- const scored = applyCompositeScoring(enriched, query).slice(0, lim);
474
+ // v0.22.0 two-regime scoring (VSEARCH-RAW-PRIMARY-DESIGN.md R1/R4): vector-served
475
+ // semantic/discovery results on non-recency queries rank by raw cosine (metadata
476
+ // breaks exact ties only). Keyword/hybrid modes and the FTS fallback keep composite —
477
+ // their scores are not cosine, so the raw contract cannot hold there.
478
+ const rawRegime = vectorLegServed
479
+ && (effectiveMode === "semantic" || effectiveMode === "discovery")
480
+ && selectScoringRegime(query) === "raw";
481
+ const retrieveScoreBasis = rawRegime ? VECTOR_SCORE_BASIS : COMPOSITE_SCORE_BASIS;
482
+ const scored = rawRegime
483
+ ? rankRawPrimary(enriched, query).slice(0, lim)
484
+ : applyCompositeScoring(enriched, query).slice(0, lim);
485
+ const modeDegraded = singleLegDegraded ? { degraded: true as const, degradedReason: singleLegDegraded.reason } : {};
486
+ const modeDegradedNote = singleLegDegraded ? `\n${degradedGuidanceText([singleLegDegraded])}` : "";
402
487
  if (compact) {
403
488
  const items = scored.map(r => ({
404
489
  docid: `#${r.docid}`, path: r.displayPath, title: r.title,
@@ -406,8 +491,8 @@ This is the recommended entry point for ALL memory queries.`,
406
491
  snippet: (r.body || "").substring(0, 150), content_type: r.contentType,
407
492
  }));
408
493
  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 },
494
+ content: [{ type: "text", text: `[routed: ${effectiveMode}] ${formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query)}${modeDegradedNote}` }],
495
+ structuredContent: { mode: effectiveMode, results: items, scoreBasis: retrieveScoreBasis, ...modeDegraded },
411
496
  };
412
497
  }
413
498
  const items: SearchResultItem[] = scored.map(r => {
@@ -420,8 +505,8 @@ This is the recommended entry point for ALL memory queries.`,
420
505
  };
421
506
  });
422
507
  return {
423
- content: [{ type: "text", text: `[routed: ${effectiveMode}] ${formatSearchSummary(items, query)}` }],
424
- structuredContent: { mode: effectiveMode, results: items },
508
+ content: [{ type: "text", text: `[routed: ${effectiveMode}] ${formatSearchSummary(items, query)}${modeDegradedNote}` }],
509
+ structuredContent: { mode: effectiveMode, results: items, scoreBasis: retrieveScoreBasis, ...modeDegraded },
425
510
  };
426
511
  }
427
512
  );
@@ -495,15 +580,17 @@ This is the recommended entry point for ALL memory queries.`,
495
580
  minScore: z.number().optional().default(0),
496
581
  collection: z.string().optional().describe("Filter to collection (single name or comma-separated)"),
497
582
  compact: z.boolean().optional().default(false).describe("Return compact results (id, path, title, score, snippet) instead of full content"),
583
+ includeInternal: z.boolean().optional().default(false).describe("Include system-internal _clawmem docs (observations/deductions) — excluded by default"),
498
584
  vault: z.string().optional().describe("Named vault (omit for default vault)"),
499
585
  },
500
586
  },
501
- async ({ query, limit, minScore, collection, compact, vault }) => {
587
+ async ({ query, limit, minScore, collection, compact, includeInternal, vault }) => {
502
588
  const store = getStore(vault);
503
589
  const collections = collection
504
590
  ? collection.split(",").map(c => c.trim()).filter(Boolean)
505
591
  : undefined;
506
- const results = store.searchFTS(query, limit || 10, undefined, collections);
592
+ const excl = resolveExcludedCollections(includeInternal, collections);
593
+ const results = store.searchFTS(query, limit || 10, undefined, collections, undefined, excl);
507
594
 
508
595
  const coFn = (path: string) => store.getCoActivated(path);
509
596
  const enriched = enrichResults(store, results, query);
@@ -553,13 +640,14 @@ This is the recommended entry point for ALL memory queries.`,
553
640
  inputSchema: {
554
641
  query: z.string().describe("Natural language query"),
555
642
  limit: z.number().optional().default(10),
556
- minScore: z.number().optional().default(0.3),
643
+ minScore: z.number().optional().describe("Score floor. Non-recency queries filter the RAW cosine score (default: no filter; explicit 0 honored; cosine values are embedding-model-specific). Recency-intent queries keep the composite-scale default 0.3."),
557
644
  collection: z.string().optional().describe("Filter to collection (single name or comma-separated)"),
558
645
  compact: z.boolean().optional().default(false).describe("Return compact results (id, path, title, score, snippet) instead of full content"),
646
+ includeInternal: z.boolean().optional().default(false).describe("Include system-internal _clawmem docs (observations/deductions) — excluded by default"),
559
647
  vault: z.string().optional().describe("Named vault (omit for default vault)"),
560
648
  },
561
649
  },
562
- async ({ query, limit, minScore, collection, compact, vault }) => {
650
+ async ({ query, limit, minScore, collection, compact, includeInternal, vault }) => {
563
651
  const store = getStore(vault);
564
652
  const tableExists = store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
565
653
  if (!tableExists) {
@@ -569,12 +657,25 @@ This is the recommended entry point for ALL memory queries.`,
569
657
  const collections = collection
570
658
  ? collection.split(",").map(c => c.trim()).filter(Boolean)
571
659
  : undefined;
572
- const results = await store.searchVec(query, DEFAULT_EMBED_MODEL, limit || 10, undefined, collections);
660
+ const excl = resolveExcludedCollections(includeInternal, collections);
661
+ const det = await store.searchVecDetailed(query, DEFAULT_EMBED_MODEL, limit || 10, { collections, excludeCollections: excl });
662
+ const results = det.results;
663
+ const vsDegraded = det.degraded && det.degradedReason ? { degraded: true as const, degradedReason: det.degradedReason } : {};
664
+ const vsDegradedNote = det.degraded && det.degradedReason ? `\n${degradedGuidanceText([{ leg: "vsearch", reason: det.degradedReason }])}` : "";
573
665
 
574
666
  const coFn = (path: string) => store.getCoActivated(path);
575
667
  const enriched = enrichResults(store, results, query);
576
- const scored = applyCompositeScoring(enriched, query, coFn)
577
- .filter(r => r.compositeScore >= (minScore || 0.3));
668
+ // v0.22.0 two-regime scoring (VSEARCH-RAW-PRIMARY-DESIGN.md R1–R4): non-recency
669
+ // queries rank by raw cosine — metadata (incl. pin) breaks exact score ties only,
670
+ // and minScore (if given) filters the raw score with NO default floor. Recency-intent
671
+ // queries keep the pre-v0.22.0 composite behavior including its 0.3 default.
672
+ const regime = selectScoringRegime(query);
673
+ const vsScoreBasis = regime === "raw" ? VECTOR_SCORE_BASIS : COMPOSITE_SCORE_BASIS;
674
+ const scored = regime === "raw"
675
+ ? rankRawPrimary(enriched, query, coFn).filter(r => minScore === undefined || r.compositeScore >= minScore)
676
+ // Recency branch preserves v0.21 semantics EXACTLY, including `||`: an explicit
677
+ // minScore of 0 still applies the 0.3 composite floor (R4 unchanged-recency contract).
678
+ : applyCompositeScoring(enriched, query, coFn).filter(r => r.compositeScore >= (minScore || 0.3));
578
679
 
579
680
  if (compact) {
580
681
  const items = scored.map(r => ({
@@ -583,7 +684,7 @@ This is the recommended entry point for ALL memory queries.`,
583
684
  snippet: (r.body || "").substring(0, 150), content_type: r.contentType, modified_at: r.modifiedAt,
584
685
  fragment: r.fragmentType ? { type: r.fragmentType, label: r.fragmentLabel } : undefined,
585
686
  }));
586
- return { content: [{ type: "text", text: formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query) }], structuredContent: { results: items } };
687
+ return { content: [{ type: "text", text: `${formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query)}${vsDegradedNote}` }], structuredContent: { results: items, scoreBasis: vsScoreBasis, ...vsDegraded } };
587
688
  }
588
689
 
589
690
  const items: SearchResultItem[] = scored.map(r => {
@@ -601,8 +702,8 @@ This is the recommended entry point for ALL memory queries.`,
601
702
  });
602
703
 
603
704
  return {
604
- content: [{ type: "text", text: formatSearchSummary(items, query) }],
605
- structuredContent: { results: items },
705
+ content: [{ type: "text", text: `${formatSearchSummary(items, query)}${vsDegradedNote}` }],
706
+ structuredContent: { results: items, scoreBasis: vsScoreBasis, ...vsDegraded },
606
707
  };
607
708
  }
608
709
  );
@@ -625,14 +726,16 @@ This is the recommended entry point for ALL memory queries.`,
625
726
  diverse: z.boolean().optional().default(true).describe("Apply MMR diversity filter to reduce near-duplicate results"),
626
727
  intent: z.string().optional().describe("Domain intent hint for disambiguation — steers expansion, reranking, chunk selection, and snippet extraction"),
627
728
  candidateLimit: z.number().optional().default(30).describe("Max candidates reaching the reranker (default 30)"),
729
+ includeInternal: z.boolean().optional().default(false).describe("Include system-internal _clawmem docs (observations/deductions) — excluded by default"),
628
730
  vault: z.string().optional().describe("Named vault (omit for default vault)"),
629
731
  },
630
732
  },
631
- async ({ query, limit, minScore, collection, compact, diverse, intent, candidateLimit, vault }) => {
733
+ async ({ query, limit, minScore, collection, compact, diverse, intent, candidateLimit, includeInternal, vault }) => {
632
734
  const store = getStore(vault);
633
735
  const candLimit = candidateLimit || 30;
634
736
  const rankedLists: RankedResult[][] = [];
635
737
  const docidMap = new Map<string, string>();
738
+ const degradedLegs: DegradedLeg[] = [];
636
739
  const hasVectors = !!store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
637
740
 
638
741
  // Step 0: Temporal constraint extraction (pure regex, ~0ms)
@@ -642,7 +745,8 @@ This is the recommended entry point for ALL memory queries.`,
642
745
  const collections = collection
643
746
  ? collection.split(",").map(c => c.trim()).filter(Boolean)
644
747
  : undefined;
645
- const initialFts = store.searchFTS(query, 20, undefined, collections, dateRange);
748
+ const excl = resolveExcludedCollections(includeInternal, collections);
749
+ const initialFts = store.searchFTS(query, 20, undefined, collections, dateRange, excl);
646
750
  const topScore = initialFts.length > 0 ? Math.abs(initialFts[0]!.score) : 0;
647
751
  const secondScore = initialFts.length > 1 ? Math.abs(initialFts[1]!.score) : 0;
648
752
  // When intent is provided, disable strong-signal bypass — the obvious BM25
@@ -663,30 +767,34 @@ This is the recommended entry point for ALL memory queries.`,
663
767
  rankedLists.push(initialFts.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
664
768
  }
665
769
  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 })));
770
+ const det = await store.searchVecDetailed(query, DEFAULT_EMBED_MODEL, 20, { collections, dateRange, excludeCollections: excl });
771
+ if (det.degraded && det.degradedReason) degradedLegs.push({ leg: "vector:original", reason: det.degradedReason });
772
+ if (det.results.length > 0) {
773
+ for (const r of det.results) docidMap.set(r.filepath, r.docid);
774
+ rankedLists.push(det.results.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
670
775
  }
671
776
  }
672
777
  // Lists contributed by the original query — these get the 2× RRF weight.
673
778
  const numOriginalLists = rankedLists.length;
674
779
 
675
780
  // Typed expansions — route by type: lex → FTS, vec/hyde → vector.
781
+ let expansionIdx = 0;
676
782
  for (const eq of expanded) {
677
783
  if (eq.type === 'lex') {
678
- const ftsResults = store.searchFTS(eq.query, 20, undefined, collections, dateRange);
784
+ const ftsResults = store.searchFTS(eq.query, 20, undefined, collections, dateRange, excl);
679
785
  if (ftsResults.length > 0) {
680
786
  for (const r of ftsResults) docidMap.set(r.filepath, r.docid);
681
787
  rankedLists.push(ftsResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
682
788
  }
683
789
  } 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 })));
790
+ const det = await store.searchVecDetailed(eq.query, DEFAULT_EMBED_MODEL, 20, { collections, dateRange, excludeCollections: excl });
791
+ if (det.degraded && det.degradedReason) degradedLegs.push({ leg: `vector:expansion${expansionIdx}`, reason: det.degradedReason });
792
+ if (det.results.length > 0) {
793
+ for (const r of det.results) docidMap.set(r.filepath, r.docid);
794
+ rankedLists.push(det.results.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
688
795
  }
689
796
  }
797
+ expansionIdx++;
690
798
  }
691
799
 
692
800
  // Step 2b: Temporal proximity channel (if dateRange detected)
@@ -694,14 +802,17 @@ This is the recommended entry point for ALL memory queries.`,
694
802
  if (dateRange) {
695
803
  const centerMs = (new Date(dateRange.start).getTime() + new Date(dateRange.end).getTime()) / 2;
696
804
  const rangeMs = Math.max(new Date(dateRange.end).getTime() - new Date(dateRange.start).getTime(), 86400000);
805
+ const temporalExclSql = excl && excl.length > 0
806
+ ? ` AND d.collection NOT IN (${excl.map(() => '?').join(',')})`
807
+ : "";
697
808
  const temporalDocs = store.db.prepare(`
698
809
  SELECT 'clawmem://' || d.collection || '/' || d.path as filepath,
699
810
  d.collection || '/' || d.path as displayPath,
700
811
  d.title, d.modified_at
701
812
  FROM documents d
702
- WHERE d.active = 1 AND d.invalidated_at IS NULL AND d.modified_at >= ? AND d.modified_at <= ?
813
+ WHERE d.active = 1 AND d.invalidated_at IS NULL AND d.modified_at >= ? AND d.modified_at <= ?${temporalExclSql}
703
814
  ORDER BY d.modified_at DESC LIMIT 30
704
- `).all(dateRange.start, dateRange.end) as { filepath: string; displayPath: string; title: string; modified_at: string }[];
815
+ `).all(dateRange.start, dateRange.end, ...(excl ?? [])) as { filepath: string; displayPath: string; title: string; modified_at: string }[];
705
816
 
706
817
  if (temporalDocs.length > 0) {
707
818
  const temporalRanked: RankedResult[] = temporalDocs.map(d => {
@@ -732,6 +843,7 @@ This is the recommended entry point for ALL memory queries.`,
732
843
  WHERE d.id = ? AND d.active = 1 AND d.invalidated_at IS NULL LIMIT 1
733
844
  `).get(en.docId) as { collection: string; path: string; title: string; body: string | null } | undefined;
734
845
  if (!doc) return null;
846
+ if (excl && excl.includes(doc.collection)) return null;
735
847
  return {
736
848
  file: `clawmem://${doc.collection}/${doc.path}`,
737
849
  displayPath: `${doc.collection}/${doc.path}`,
@@ -836,6 +948,10 @@ This is the recommended entry point for ALL memory queries.`,
836
948
  if (diverse !== false) scored = applyMMRDiversity(scored);
837
949
  scored = scored.slice(0, limit || 10);
838
950
 
951
+ // Multi-leg degraded aggregation (T5-M1): route marker = any(leg), per-leg reasons retained.
952
+ const queryDegraded = degradedLegs.length > 0 ? { degraded: true as const, degradedLegs } : {};
953
+ const queryDegradedNote = degradedLegs.length > 0 ? `\n${degradedGuidanceText(degradedLegs)}` : "";
954
+
839
955
  if (compact) {
840
956
  const items = scored.map(r => ({
841
957
  docid: `#${docidMap.get(r.filepath) || r.docid}`, path: r.displayPath, title: r.title,
@@ -843,7 +959,7 @@ This is the recommended entry point for ALL memory queries.`,
843
959
  snippet: (r.body || "").substring(0, 150), content_type: r.contentType, modified_at: r.modifiedAt,
844
960
  fragment: r.fragmentType ? { type: r.fragmentType, label: r.fragmentLabel } : undefined,
845
961
  }));
846
- return { content: [{ type: "text", text: formatSearchSummary(items.map(i => ({ ...i, file: i.path, compositeScore: i.score, context: null })), query) }], structuredContent: { results: items } };
962
+ 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
963
  }
848
964
 
849
965
  const items: SearchResultItem[] = scored.map(r => {
@@ -861,8 +977,8 @@ This is the recommended entry point for ALL memory queries.`,
861
977
  });
862
978
 
863
979
  return {
864
- content: [{ type: "text", text: formatSearchSummary(items, query) }],
865
- structuredContent: { results: items },
980
+ content: [{ type: "text", text: `${formatSearchSummary(items, query)}${queryDegradedNote}` }],
981
+ structuredContent: { results: items, ...queryDegraded },
866
982
  };
867
983
  }
868
984
  );
@@ -1272,10 +1388,11 @@ This is the recommended entry point for ALL memory queries.`,
1272
1388
  inputSchema: {
1273
1389
  file: z.string().describe("Path of reference document"),
1274
1390
  limit: z.number().optional().default(5),
1391
+ 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
1392
  vault: z.string().optional().describe("Named vault (omit for default vault)"),
1276
1393
  },
1277
1394
  },
1278
- async ({ file, limit, vault }) => {
1395
+ async ({ file, limit, includeInternal, vault }) => {
1279
1396
  const store = getStore(vault);
1280
1397
  const tableExists = store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
1281
1398
  if (!tableExists) {
@@ -1291,14 +1408,21 @@ This is the recommended entry point for ALL memory queries.`,
1291
1408
  const body = store.getDocumentBody(result) ?? "";
1292
1409
  const title = result.title || file;
1293
1410
 
1411
+ // Internal-reference auto-exception (T2-M7): exploring FROM an internal doc is an
1412
+ // explicit ask for the internal space — include internal neighbors.
1413
+ const refIsInternal = INTERNAL_COLLECTIONS.includes(result.collectionName);
1414
+ const excl = resolveExcludedCollections(includeInternal || refIsInternal);
1415
+
1294
1416
  // Use the document's content as the search query
1295
1417
  const queryText = `${title}\n${body.slice(0, 1000)}`;
1296
- const vecResults = await store.searchVec(queryText, DEFAULT_EMBED_MODEL, (limit || 5) + 1);
1418
+ const det = await store.searchVecDetailed(queryText, DEFAULT_EMBED_MODEL, (limit || 5) + 1, { excludeCollections: excl });
1297
1419
 
1298
1420
  // Filter out the reference document itself
1299
- const similar = vecResults
1421
+ const similar = det.results
1300
1422
  .filter(r => r.filepath !== result.filepath)
1301
1423
  .slice(0, limit || 5);
1424
+ const fsDegraded = det.degraded && det.degradedReason ? { degraded: true as const, degradedReason: det.degradedReason } : {};
1425
+ const fsDegradedNote = det.degraded && det.degradedReason ? `\n${degradedGuidanceText([{ leg: "find_similar", reason: det.degradedReason }])}` : "";
1302
1426
 
1303
1427
  const items: SearchResultItem[] = similar.map(r => {
1304
1428
  const { line, snippet } = extractSnippet(r.body || "", title, 200);
@@ -1313,8 +1437,8 @@ This is the recommended entry point for ALL memory queries.`,
1313
1437
  });
1314
1438
 
1315
1439
  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 },
1440
+ content: [{ type: "text", text: `${items.length} similar to "${title}":\n${items.map(i => ` ${i.file} (${Math.round(i.score * 100)}%)`).join('\n')}${fsDegradedNote}` }],
1441
+ structuredContent: { reference: file, results: items, ...fsDegraded },
1318
1442
  };
1319
1443
  }
1320
1444
  );
@@ -1763,10 +1887,11 @@ This is the recommended entry point for ALL memory queries.`,
1763
1887
  query: z.string().describe("Complex or multi-topic query"),
1764
1888
  limit: z.number().optional().default(10),
1765
1889
  compact: z.boolean().optional().default(true).describe("Return compact results"),
1890
+ includeInternal: z.boolean().optional().default(false).describe("Include system-internal _clawmem docs (observations/deductions) — excluded by default"),
1766
1891
  vault: z.string().optional().describe("Named vault (omit for default vault)"),
1767
1892
  },
1768
1893
  },
1769
- async ({ query, limit, compact, vault }) => {
1894
+ async ({ query, limit, compact, includeInternal, vault }) => {
1770
1895
  const store = getStore(vault);
1771
1896
  const llm = getDefaultLlamaCpp();
1772
1897
 
@@ -1777,18 +1902,25 @@ This is the recommended entry point for ALL memory queries.`,
1777
1902
  const sortedClauses = [...clauses].sort((a, b) => a.priority - b.priority);
1778
1903
  const allResults: SearchResult[] = [];
1779
1904
  const clauseDetails: { type: string; query: string; priority: number; resultCount: number }[] = [];
1905
+ const degradedLegs: DegradedLeg[] = [];
1780
1906
 
1907
+ let clauseIdx = 0;
1781
1908
  for (const clause of sortedClauses) {
1909
+ const clauseExcl = resolveExcludedCollections(includeInternal, clause.collections);
1782
1910
  let results: SearchResult[] = [];
1783
1911
  if (clause.type === 'bm25') {
1784
- results = store.searchFTS(clause.query, 20, undefined, clause.collections);
1912
+ results = store.searchFTS(clause.query, 20, undefined, clause.collections, undefined, clauseExcl);
1785
1913
  } else if (clause.type === 'vector') {
1786
- results = await store.searchVec(clause.query, DEFAULT_EMBED_MODEL, 20, undefined, clause.collections);
1914
+ const det = await store.searchVecDetailed(clause.query, DEFAULT_EMBED_MODEL, 20, { collections: clause.collections, excludeCollections: clauseExcl });
1915
+ results = det.results;
1916
+ if (det.degraded && det.degradedReason) degradedLegs.push({ leg: `clause${clauseIdx}:vector`, reason: det.degradedReason });
1787
1917
  } else if (clause.type === 'graph') {
1788
1918
  // Graph clause: run intent_search-style retrieval
1789
1919
  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);
1920
+ const bm25 = store.searchFTS(clause.query, 15, undefined, clause.collections, undefined, clauseExcl);
1921
+ const detGraph = await store.searchVecDetailed(clause.query, DEFAULT_EMBED_MODEL, 15, { collections: clause.collections, excludeCollections: clauseExcl });
1922
+ const vec = detGraph.results;
1923
+ if (detGraph.degraded && detGraph.degradedReason) degradedLegs.push({ leg: `clause${clauseIdx}:graph:vector`, reason: detGraph.degradedReason });
1792
1924
  const fused = reciprocalRankFusion([bm25.map(toRanked), vec.map(toRanked)], [1.0, 1.0]);
1793
1925
  const searchMap = new Map([...bm25, ...vec].map(r => [r.filepath, r]));
1794
1926
  results = fused
@@ -1801,13 +1933,15 @@ This is the recommended entry point for ALL memory queries.`,
1801
1933
  if (anchorEmb) {
1802
1934
  const traversed = adaptiveTraversal(store.db, results.slice(0, 5).map(r => ({ hash: r.hash, score: r.score })), {
1803
1935
  maxDepth: 2, beamWidth: 3, budget: 15, intent: intent.intent, queryEmbedding: anchorEmb.embedding,
1936
+ // Visibility policy threaded INTO traversal (T6-H1): parity with memory_retrieve causal
1937
+ excludeCollections: clauseExcl,
1804
1938
  });
1805
1939
  const merged = mergeTraversalResults(store.db, results.map(r => ({ hash: r.hash, score: r.score })), traversed);
1806
1940
  const expandedMap = new Map(results.map(r => [r.hash, r]));
1807
1941
  results = merged.map(m => {
1808
1942
  const existing = expandedMap.get(m.hash);
1809
1943
  if (existing) return { ...existing, score: m.score };
1810
- // Graph-discovered node — hydrate from DB
1944
+ // Graph-discovered node — hydrate from DB (defense-in-depth visibility guard)
1811
1945
  const doc = store.db.prepare(`
1812
1946
  SELECT d.collection, d.path, d.title, d.hash, c.doc as body, d.modified_at
1813
1947
  FROM documents d
@@ -1815,6 +1949,7 @@ This is the recommended entry point for ALL memory queries.`,
1815
1949
  WHERE d.hash = ? AND d.active = 1 AND d.invalidated_at IS NULL LIMIT 1
1816
1950
  `).get(m.hash) as { collection: string; path: string; title: string; hash: string; body: string | null; modified_at: string } | undefined;
1817
1951
  if (!doc) return null;
1952
+ if (clauseExcl && clauseExcl.includes(doc.collection)) return null;
1818
1953
  return {
1819
1954
  filepath: `clawmem://${doc.collection}/${doc.path}`,
1820
1955
  displayPath: `${doc.collection}/${doc.path}`,
@@ -1835,6 +1970,7 @@ This is the recommended entry point for ALL memory queries.`,
1835
1970
  }
1836
1971
  clauseDetails.push({ type: clause.type, query: clause.query, priority: clause.priority, resultCount: results.length });
1837
1972
  allResults.push(...results);
1973
+ clauseIdx++;
1838
1974
  }
1839
1975
 
1840
1976
  // Deduplicate by filepath, keeping highest score
@@ -1863,6 +1999,8 @@ This is the recommended entry point for ALL memory queries.`,
1863
1999
  const scored = applyCompositeScoring(enriched, query, coFn).slice(0, limit || 10);
1864
2000
 
1865
2001
  const planSummary = clauseDetails.map(c => ` ${c.type}(p${c.priority}): "${c.query}" → ${c.resultCount} results`).join("\n");
2002
+ const planDegraded = degradedLegs.length > 0 ? { degraded: true as const, degradedLegs } : {};
2003
+ const planDegradedNote = degradedLegs.length > 0 ? `\n${degradedGuidanceText(degradedLegs)}` : "";
1866
2004
 
1867
2005
  if (compact) {
1868
2006
  const items = scored.map(r => ({
@@ -1871,8 +2009,8 @@ This is the recommended entry point for ALL memory queries.`,
1871
2009
  snippet: (r.body || "").substring(0, 150), content_type: r.contentType, modified_at: r.modifiedAt,
1872
2010
  }));
1873
2011
  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 },
2012
+ 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}` }],
2013
+ structuredContent: { plan: clauseDetails, results: items, ...planDegraded },
1876
2014
  };
1877
2015
  }
1878
2016
 
@@ -1881,8 +2019,8 @@ This is the recommended entry point for ALL memory queries.`,
1881
2019
  compositeScore: r.compositeScore, context: r.context, snippet: r.body?.slice(0, 300) || '', contentType: r.contentType,
1882
2020
  }));
1883
2021
  return {
1884
- content: [{ type: "text", text: `Query Plan (${sortedClauses.length} clauses):\n${planSummary}\n\n${formatSearchSummary(items, query)}` }],
1885
- structuredContent: { plan: clauseDetails, results: items },
2022
+ content: [{ type: "text", text: `Query Plan (${sortedClauses.length} clauses):\n${planSummary}\n\n${formatSearchSummary(items, query)}${planDegradedNote}` }],
2023
+ structuredContent: { plan: clauseDetails, results: items, ...planDegraded },
1886
2024
  };
1887
2025
  }
1888
2026
  );
@@ -2650,6 +2788,12 @@ This is the recommended entry point for ALL memory queries.`,
2650
2788
  }
2651
2789
  );
2652
2790
 
2791
+ return { server, store, closeAllStores };
2792
+ }
2793
+
2794
+ export async function startMcpServer(): Promise<void> {
2795
+ const { server, store, closeAllStores } = buildMcpServer();
2796
+
2653
2797
  // ---------------------------------------------------------------------------
2654
2798
  // Connect
2655
2799
  // ---------------------------------------------------------------------------
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,