@syndash/research-vault-mcp 1.1.4 → 1.1.5

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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.1.5 — 2026-06-08
6
+
7
+ ### Changed
8
+
9
+ - `vault_search` now separates read/index/analysis state with `readability_verdict`, `index_verdict`, and `analysis_verdict`.
10
+ - Readable search hits without `lastAnalyzedAt` now return `analysis_verdict: "NOT_ANALYZED"` without turning the overall search guidance into a scary `FLAG`.
11
+ - Exact-id search hits are marked with `matched_fields: ["id_exact"]`.
12
+ - Slash-heavy titles/categories are easier to find through punctuation-normalized matching, such as `Music/Rhythm` via `music rhythm`.
13
+
14
+ ### Verified
15
+
16
+ - `bun test packages/research-vault-mcp/__tests__/vault_evidence_metadata.test.ts`
17
+
5
18
  ## 1.1.4 — 2026-05-10
6
19
 
7
20
  ### Changed
package/README.md CHANGED
@@ -95,6 +95,31 @@ Mutation tools are hidden and blocked in `readonly`. `MCP_PROFILE=full` enables
95
95
 
96
96
  `vault_get` is bounded by default: it returns an excerpt unless the operator approves `include_content:true`, and even full-content requests are capped by `max_chars`. Search, status, and batch responses include `agent_guidance` plus evidence metadata for provenance, freshness, profile, and public-safety state.
97
97
 
98
+ ## Search and read semantics
99
+
100
+ `vault_search` is an index/readability surface. It answers whether the query matched saved knowledge entries and whether those entries are readable. It does not treat missing analysis metadata as missing content.
101
+
102
+ Search matching covers:
103
+
104
+ - exact note IDs, marked with `matched_fields: ["id_exact"]`
105
+ - titles, note content, IDs, and categories
106
+ - punctuation-normalized text, so slash-heavy categories such as `software-engineering/game-design` can be found with `software engineering game design`
107
+
108
+ Search results separate the relevant states:
109
+
110
+ ```json
111
+ {
112
+ "readability_verdict": "PASS",
113
+ "index_verdict": "PASS",
114
+ "analysis_verdict": "NOT_ANALYZED",
115
+ "freshness_verdict": "PASS"
116
+ }
117
+ ```
118
+
119
+ That means the note matched and is readable, but has no `lastAnalyzedAt` yet. Treat `NOT_ANALYZED` as an analysis caveat, not a broken read. Stale or invalid analysis timestamps still produce a `FLAG`.
120
+
121
+ `vault_get` is the authoritative exact-ID read path for follow-up evidence. Call `vault_search` first, then pass the exact returned `id` to `vault_get`.
122
+
98
123
  ## Tools exposed
99
124
 
100
125
  Current MCP contract:
@@ -148,4 +173,4 @@ Apache-2.0 for package code. Research artifacts in the repository root may use s
148
173
 
149
174
  ## Releases
150
175
 
151
- See [CHANGELOG.md](./CHANGELOG.md). Current npm release: `1.1.4`.
176
+ See [CHANGELOG.md](./CHANGELOG.md). Current npm release: `1.1.5`.
package/dist/server.js CHANGED
@@ -270,6 +270,9 @@ function clamp(value, min, max) {
270
270
  function lower(value) {
271
271
  return (value ?? "").toLowerCase();
272
272
  }
273
+ function normalized(value) {
274
+ return lower(value).replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim();
275
+ }
273
276
  function queryTerms(query) {
274
277
  return lower(query).split(/\s+/).map((term) => term.trim()).filter(Boolean);
275
278
  }
@@ -278,12 +281,17 @@ function includesQuery(value, query) {
278
281
  if (terms.length === 0)
279
282
  return false;
280
283
  const haystack = lower(value);
281
- return terms.some((term) => haystack.includes(term));
284
+ const normalizedHaystack = normalized(value);
285
+ return terms.some((term) => haystack.includes(term)) || normalized(queryTerms(query).join(" ")).split(/\s+/).some((term) => normalizedHaystack.includes(term));
286
+ }
287
+ function equalsQuery(value, query) {
288
+ return Boolean(query?.trim()) && lower(value).trim() === lower(query).trim();
282
289
  }
283
290
  function matchedFields(entry, query) {
284
291
  if (!query?.trim())
285
292
  return [];
286
293
  const candidates = [
294
+ ["id_exact", equalsQuery(entry.id, query) ? entry.id : undefined],
287
295
  ["title", entry.title],
288
296
  ["content", entry.content],
289
297
  ["id", entry.id],
@@ -297,6 +305,8 @@ function whyMatched(entry, query, fields) {
297
305
  if (fields.length === 0)
298
306
  return "Result is included after filters; no direct field match was detected.";
299
307
  const labels = fields.map((field) => {
308
+ if (field === "id_exact")
309
+ return "exact id";
300
310
  if (field === "title")
301
311
  return "title";
302
312
  if (field === "content")
@@ -311,21 +321,21 @@ function snippetFromContent(content, query, maxChars = 240) {
311
321
  const limit = Math.max(0, Math.floor(maxChars));
312
322
  if (limit === 0)
313
323
  return "";
314
- const normalized = content.replace(/\s+/g, " ").trim();
315
- if (normalized.length <= limit)
316
- return normalized;
324
+ const normalized2 = content.replace(/\s+/g, " ").trim();
325
+ if (normalized2.length <= limit)
326
+ return normalized2;
317
327
  const terms = queryTerms(query);
318
- const lowerContent = lower(normalized);
328
+ const lowerContent = lower(normalized2);
319
329
  const hitIndex = terms.map((term) => lowerContent.indexOf(term)).filter((index) => index >= 0).sort((a, b) => a - b)[0];
320
330
  if (hitIndex === undefined)
321
- return normalized.slice(0, limit).trimEnd();
331
+ return normalized2.slice(0, limit).trimEnd();
322
332
  const halfWindow = Math.floor(limit / 2);
323
- const start = Math.max(0, Math.min(hitIndex - halfWindow, normalized.length - limit));
324
- const end = Math.min(normalized.length, start + limit);
333
+ const start = Math.max(0, Math.min(hitIndex - halfWindow, normalized2.length - limit));
334
+ const end = Math.min(normalized2.length, start + limit);
325
335
  const prefix = start > 0 ? "..." : "";
326
- const suffix = end < normalized.length ? "..." : "";
336
+ const suffix = end < normalized2.length ? "..." : "";
327
337
  const available = Math.max(0, limit - prefix.length - suffix.length);
328
- return `${prefix}${normalized.slice(start, start + available).trim()}${suffix}`;
338
+ return `${prefix}${normalized2.slice(start, start + available).trim()}${suffix}`;
329
339
  }
330
340
  function staleVerdict(lastAnalyzedAt) {
331
341
  if (!lastAnalyzedAt) {
@@ -341,14 +351,46 @@ function staleVerdict(lastAnalyzedAt) {
341
351
  }
342
352
  return { verdict: "PASS", reason: "Analysis is fresh enough for the read surface." };
343
353
  }
354
+ function analysisVerdict(lastAnalyzedAt) {
355
+ if (!lastAnalyzedAt) {
356
+ return {
357
+ verdict: "NOT_ANALYZED",
358
+ freshness_verdict: "PASS",
359
+ reason: "No analysis timestamp was provided; content can still be readable."
360
+ };
361
+ }
362
+ const freshness = staleVerdict(lastAnalyzedAt);
363
+ if (freshness.verdict === "PASS") {
364
+ return {
365
+ verdict: "PASS",
366
+ freshness_verdict: "PASS",
367
+ reason: freshness.reason
368
+ };
369
+ }
370
+ if (freshness.reason.includes("could not be parsed")) {
371
+ return {
372
+ verdict: "INVALID",
373
+ freshness_verdict: "FLAG",
374
+ reason: freshness.reason
375
+ };
376
+ }
377
+ return {
378
+ verdict: "STALE",
379
+ freshness_verdict: "FLAG",
380
+ reason: freshness.reason
381
+ };
382
+ }
344
383
  function itemFreshness(entry) {
345
384
  const lastAnalyzedAt = entry.score?.lastAnalyzedAt ?? null;
346
- const verdict = staleVerdict(lastAnalyzedAt);
385
+ const analysis = analysisVerdict(lastAnalyzedAt);
347
386
  return {
348
387
  last_analyzed_at: lastAnalyzedAt,
349
388
  source_mtime: entry.modified || null,
350
- freshness_verdict: verdict.verdict,
351
- freshness_reason: verdict.reason
389
+ readability_verdict: "PASS",
390
+ index_verdict: "PASS",
391
+ analysis_verdict: analysis.verdict,
392
+ freshness_verdict: analysis.freshness_verdict,
393
+ freshness_reason: analysis.reason
352
394
  };
353
395
  }
354
396
  function queueFreshness(queueItems) {
@@ -672,9 +714,10 @@ var vaultTools = [
672
714
  ...freshness
673
715
  };
674
716
  });
675
- const hasStale = results.some((result) => result.freshness_verdict === "FLAG");
676
- const envelope = okEnvelope({ query, category, results, total: results.length }, hasStale ? flagGuidance("Search completed, but one or more results lack fresh analysis metadata.", "Use source_ref for readonly follow-up and refresh analysis metadata in the operator lane if needed.", "vault_get") : passGuidance("Search completed with provenance and freshness metadata.", "Use source_ref or vault_get for bounded follow-up evidence.", "vault_get"), {
677
- freshness: hasStale ? "Some search results are missing or stale analysis timestamps." : "Search result analysis metadata is fresh.",
717
+ const hasAnalysisRisk = results.some((result) => ["STALE", "INVALID"].includes(result.analysis_verdict));
718
+ const hasNotAnalyzed = results.some((result) => result.analysis_verdict === "NOT_ANALYZED");
719
+ const envelope = okEnvelope({ query, category, results, total: results.length }, hasAnalysisRisk ? flagGuidance("Search completed, but one or more results have stale or invalid analysis metadata.", "Use source_ref for readonly follow-up and refresh stale analysis metadata in the operator lane if needed.", "vault_get") : passGuidance(hasNotAnalyzed ? "Search completed; one or more readable results have not been analyzed yet." : "Search completed with provenance and freshness metadata.", hasNotAnalyzed ? "Use source_ref or vault_get for bounded follow-up evidence; treat NOT_ANALYZED as an analysis caveat, not missing content." : "Use source_ref or vault_get for bounded follow-up evidence.", "vault_get"), {
720
+ freshness: hasAnalysisRisk ? "Some search results have stale or invalid analysis timestamps." : hasNotAnalyzed ? "Some readable search results do not have analysis timestamps yet." : "Search result analysis metadata is fresh.",
678
721
  provenance: "vault_search result source_ref values are vault:// references without local paths."
679
722
  });
680
723
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syndash/research-vault-mcp",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "description": "DASH Research Vault MCP server — local-first semantic search, note persistence, and public-safe knowledge-base tools for MCP-compatible agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,12 +1,21 @@
1
1
  import type { DecayScore, VaultEntry } from './types.ts'
2
2
 
3
3
  export type FreshnessVerdict = 'PASS' | 'FLAG'
4
+ export type ReadabilityVerdict = 'PASS' | 'MISSING'
5
+ export type IndexVerdict = 'PASS' | 'NO_MATCH'
6
+ export type AnalysisVerdict = 'PASS' | 'STALE' | 'NOT_ANALYZED' | 'INVALID'
4
7
 
5
8
  export interface FreshnessShape {
6
9
  verdict: FreshnessVerdict
7
10
  reason: string
8
11
  }
9
12
 
13
+ export interface AnalysisShape {
14
+ verdict: AnalysisVerdict
15
+ freshness_verdict: FreshnessVerdict
16
+ reason: string
17
+ }
18
+
10
19
  const STALE_AFTER_DAYS = 7
11
20
  const DAY_MS = 24 * 60 * 60 * 1000
12
21
 
@@ -18,6 +27,13 @@ function lower(value: string | undefined | null): string {
18
27
  return (value ?? '').toLowerCase()
19
28
  }
20
29
 
30
+ function normalized(value: string | undefined | null): string {
31
+ return lower(value)
32
+ .replace(/[^a-z0-9]+/g, ' ')
33
+ .replace(/\s+/g, ' ')
34
+ .trim()
35
+ }
36
+
21
37
  function queryTerms(query?: string): string[] {
22
38
  return lower(query)
23
39
  .split(/\s+/)
@@ -29,13 +45,20 @@ function includesQuery(value: string | undefined, query?: string): boolean {
29
45
  const terms = queryTerms(query)
30
46
  if (terms.length === 0) return false
31
47
  const haystack = lower(value)
48
+ const normalizedHaystack = normalized(value)
32
49
  return terms.some(term => haystack.includes(term))
50
+ || normalized(queryTerms(query).join(' ')).split(/\s+/).some(term => normalizedHaystack.includes(term))
51
+ }
52
+
53
+ function equalsQuery(value: string | undefined, query?: string): boolean {
54
+ return Boolean(query?.trim()) && lower(value).trim() === lower(query).trim()
33
55
  }
34
56
 
35
57
  export function matchedFields(entry: VaultEntry & { content?: string }, query?: string): string[] {
36
58
  if (!query?.trim()) return []
37
59
 
38
60
  const candidates: Array<[string, string | undefined]> = [
61
+ ['id_exact', equalsQuery(entry.id, query) ? entry.id : undefined],
39
62
  ['title', entry.title],
40
63
  ['content', entry.content],
41
64
  ['id', entry.id],
@@ -52,6 +75,7 @@ export function whyMatched(entry: VaultEntry & { content?: string }, query: stri
52
75
  if (fields.length === 0) return 'Result is included after filters; no direct field match was detected.'
53
76
 
54
77
  const labels = fields.map(field => {
78
+ if (field === 'id_exact') return 'exact id'
55
79
  if (field === 'title') return 'title'
56
80
  if (field === 'content') return 'note content'
57
81
  if (field === 'category') return 'category'
@@ -104,15 +128,51 @@ export function staleVerdict(lastAnalyzedAt?: string | null): FreshnessShape {
104
128
  return { verdict: 'PASS', reason: 'Analysis is fresh enough for the read surface.' }
105
129
  }
106
130
 
131
+ export function analysisVerdict(lastAnalyzedAt?: string | null): AnalysisShape {
132
+ if (!lastAnalyzedAt) {
133
+ return {
134
+ verdict: 'NOT_ANALYZED',
135
+ freshness_verdict: 'PASS',
136
+ reason: 'No analysis timestamp was provided; content can still be readable.',
137
+ }
138
+ }
139
+
140
+ const freshness = staleVerdict(lastAnalyzedAt)
141
+ if (freshness.verdict === 'PASS') {
142
+ return {
143
+ verdict: 'PASS',
144
+ freshness_verdict: 'PASS',
145
+ reason: freshness.reason,
146
+ }
147
+ }
148
+
149
+ if (freshness.reason.includes('could not be parsed')) {
150
+ return {
151
+ verdict: 'INVALID',
152
+ freshness_verdict: 'FLAG',
153
+ reason: freshness.reason,
154
+ }
155
+ }
156
+
157
+ return {
158
+ verdict: 'STALE',
159
+ freshness_verdict: 'FLAG',
160
+ reason: freshness.reason,
161
+ }
162
+ }
163
+
107
164
  export function itemFreshness(entry: VaultEntry & { score?: DecayScore & { lastAnalyzedAt?: string } }) {
108
165
  const lastAnalyzedAt = entry.score?.lastAnalyzedAt ?? null
109
- const verdict = staleVerdict(lastAnalyzedAt)
166
+ const analysis = analysisVerdict(lastAnalyzedAt)
110
167
 
111
168
  return {
112
169
  last_analyzed_at: lastAnalyzedAt,
113
170
  source_mtime: entry.modified || null,
114
- freshness_verdict: verdict.verdict,
115
- freshness_reason: verdict.reason,
171
+ readability_verdict: 'PASS' as ReadabilityVerdict,
172
+ index_verdict: 'PASS' as IndexVerdict,
173
+ analysis_verdict: analysis.verdict,
174
+ freshness_verdict: analysis.freshness_verdict,
175
+ freshness_reason: analysis.reason,
116
176
  }
117
177
  }
118
178
 
package/src/vault.ts CHANGED
@@ -330,22 +330,31 @@ const vaultTools = [
330
330
  }
331
331
  })
332
332
 
333
- const hasStale = results.some(result => result.freshness_verdict === 'FLAG')
333
+ const hasAnalysisRisk = results.some(result => ['STALE', 'INVALID'].includes(result.analysis_verdict))
334
+ const hasNotAnalyzed = results.some(result => result.analysis_verdict === 'NOT_ANALYZED')
334
335
  const envelope = okEnvelope(
335
336
  { query, category, results, total: results.length },
336
- hasStale
337
+ hasAnalysisRisk
337
338
  ? flagGuidance(
338
- 'Search completed, but one or more results lack fresh analysis metadata.',
339
- 'Use source_ref for readonly follow-up and refresh analysis metadata in the operator lane if needed.',
339
+ 'Search completed, but one or more results have stale or invalid analysis metadata.',
340
+ 'Use source_ref for readonly follow-up and refresh stale analysis metadata in the operator lane if needed.',
340
341
  'vault_get',
341
342
  )
342
343
  : passGuidance(
343
- 'Search completed with provenance and freshness metadata.',
344
- 'Use source_ref or vault_get for bounded follow-up evidence.',
344
+ hasNotAnalyzed
345
+ ? 'Search completed; one or more readable results have not been analyzed yet.'
346
+ : 'Search completed with provenance and freshness metadata.',
347
+ hasNotAnalyzed
348
+ ? 'Use source_ref or vault_get for bounded follow-up evidence; treat NOT_ANALYZED as an analysis caveat, not missing content.'
349
+ : 'Use source_ref or vault_get for bounded follow-up evidence.',
345
350
  'vault_get',
346
351
  ),
347
352
  {
348
- freshness: hasStale ? 'Some search results are missing or stale analysis timestamps.' : 'Search result analysis metadata is fresh.',
353
+ freshness: hasAnalysisRisk
354
+ ? 'Some search results have stale or invalid analysis timestamps.'
355
+ : hasNotAnalyzed
356
+ ? 'Some readable search results do not have analysis timestamps yet.'
357
+ : 'Search result analysis metadata is fresh.',
349
358
  provenance: 'vault_search result source_ref values are vault:// references without local paths.',
350
359
  },
351
360
  )