@syndash/research-vault-mcp 1.1.3 → 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,27 @@
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
+
18
+ ## 1.1.4 — 2026-05-10
19
+
20
+ ### Changed
21
+
22
+ - Published the package source under the canonical Dash Research Vault repo path: `packages/research-vault-mcp`.
23
+ - Updated README language to treat Dash Research Vault as the package home and Evensong as research provenance, not the npm package repository.
24
+ - Updated package metadata description and release docs to match the public npm source path.
25
+
5
26
  ## 1.1.3 — 2026-05-10
6
27
 
7
28
  ### Added
@@ -24,8 +45,8 @@
24
45
  - Default MCP transport is now `stdio`, matching command-launched MCP clients.
25
46
  - The npm bin is a Node-compatible launcher that delegates server execution to Bun.
26
47
  - Published package includes `dist/server.js` via `prepack` build and `files` allowlist.
27
- - README now documents Evensong hub vs Research Vault module, install commands, Claude config, Bun runtime requirement, and explicit SSE mode.
28
- - Package metadata now uses Evensong module wording and Apache-2.0 package license.
48
+ - README now documents install commands, Claude config, Bun runtime requirement, and explicit SSE mode.
49
+ - Package metadata now includes public repository information and Apache-2.0 package license.
29
50
 
30
51
  ### Verified
31
52
 
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # @syndash/research-vault-mcp
2
2
 
3
- Research Vault is the memory/search module inside **Evensong**. This package exposes it as an MCP server for agents that speak the Model Context Protocol.
3
+ Research Vault MCP is the installable server for **Dash Research Vault**. It gives MCP-compatible agents a public-safe way to search, inspect, and operate a markdown knowledge vault.
4
4
 
5
- It is not the whole Evensong product. Evensong is the hub: runtime, benchmark evidence, handoff pages, and modules. Research Vault MCP is the installable knowledge-base module.
5
+ It is not the whole vault template or the whole Evensong research project. This package is the runtime adapter: MCP tools, read-only defaults, bounded reads, public-surface redaction, and optional mutation profiles for operator-approved sessions.
6
+
7
+ Source: <https://github.com/Fearvox/dash-research-vault/tree/main/packages/research-vault-mcp>
6
8
 
7
9
  ## Install
8
10
 
@@ -77,7 +79,7 @@ Set the vault location with an environment variable before launching your MCP cl
77
79
  export VAULT_ROOT=/path/to/research-vault
78
80
  ```
79
81
 
80
- The package is designed for markdown-based knowledge bases. Keep private vault contents outside the public Evensong repo.
82
+ The package is designed for markdown-based knowledge bases. Keep private vault contents outside the public Dash Research Vault repo.
81
83
 
82
84
  ## MCP Profiles
83
85
 
@@ -93,6 +95,31 @@ Mutation tools are hidden and blocked in `readonly`. `MCP_PROFILE=full` enables
93
95
 
94
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.
95
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
+
96
123
  ## Tools exposed
97
124
 
98
125
  Current MCP contract:
@@ -117,6 +144,7 @@ Published packages include:
117
144
  - `dist/server.js`
118
145
  - `src/**/*.ts` for source inspection
119
146
  - `README.md`
147
+ - `CHANGELOG.md`
120
148
  - `package.json`
121
149
 
122
150
  The bin prefers `dist/server.js`. In a monorepo checkout without `dist`, it falls back to `bun run src/server.ts` so development remains fast without a separate compile step.
@@ -133,16 +161,16 @@ score(d, q, t) = lexical(q,d)
133
161
  + summary-level weight(d)
134
162
  ```
135
163
 
136
- The Evensong benchmark evidence for hybrid retrieval and Dense RAR lives in the parent repo under `benchmarks/`.
164
+ The research papers and figures in the repository root document the memory-causation evidence behind the vault template. The MCP package stays focused on the installable server surface.
137
165
 
138
166
  ## Node compatibility status
139
167
 
140
- The package is intentionally Bun-native today because the server uses Bun APIs and the parent Evensong repo is Bun-only. The npm bin is Node-compatible only as a launcher: it locates `dist/server.js` or `src/server.ts`, then delegates execution to `bun`. This keeps package installation convenient while avoiding a misleading claim that the MCP server itself runs under plain Node.js.
168
+ The package is intentionally Bun-native today because the server uses Bun APIs. The npm bin is Node-compatible only as a launcher: it locates `dist/server.js` or `src/server.ts`, then delegates execution to `bun`. This keeps package installation convenient while avoiding a misleading claim that the MCP server itself runs under plain Node.js.
141
169
 
142
170
  ## License
143
171
 
144
- Apache-2.0 for package code. Research artifacts in the parent repo may use separate licenses; check the repository root license files.
172
+ Apache-2.0 for package code. Research artifacts in the repository root may use separate licenses; check the repository root license files.
145
173
 
146
174
  ## Releases
147
175
 
148
- See [CHANGELOG.md](./CHANGELOG.md). Current npm release: `1.1.2`.
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,7 +1,7 @@
1
1
  {
2
2
  "name": "@syndash/research-vault-mcp",
3
- "version": "1.1.3",
4
- "description": "Evensong Research Vault MCP module — local-first semantic search, note persistence, and knowledge-base tools for MCP-compatible agents.",
3
+ "version": "1.1.5",
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": {
7
7
  "research-vault-mcp": "bin/research-vault-mcp.mjs"
@@ -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
  )