@syndash/research-vault-mcp 1.1.4 → 1.1.6

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.6 — 2026-06-16
6
+
7
+ ### Fixed
8
+
9
+ - Aligned npm package license metadata with the monorepo root `LICENSE`.
10
+ - Added `LICENSE` to the published package artifact so npm installs include the governing license text.
11
+ - Updated README package mechanics and license language to match the packaged artifact.
12
+
13
+ ## 1.1.5 — 2026-06-08
14
+
15
+ ### Changed
16
+
17
+ - `vault_search` now separates read/index/analysis state with `readability_verdict`, `index_verdict`, and `analysis_verdict`.
18
+ - Readable search hits without `lastAnalyzedAt` now return `analysis_verdict: "NOT_ANALYZED"` without turning the overall search guidance into a scary `FLAG`.
19
+ - Exact-id search hits are marked with `matched_fields: ["id_exact"]`.
20
+ - Slash-heavy titles/categories are easier to find through punctuation-normalized matching, such as `Music/Rhythm` via `music rhythm`.
21
+
22
+ ### Verified
23
+
24
+ - `bun test packages/research-vault-mcp/__tests__/vault_evidence_metadata.test.ts`
25
+
5
26
  ## 1.1.4 — 2026-05-10
6
27
 
7
28
  ### Changed
package/LICENSE ADDED
@@ -0,0 +1,30 @@
1
+ # CC BY-NC-ND 4.0 — Attribution-NonCommercial-NoDerivatives
2
+
3
+ **Creative Commons License — Non-Commercial Use Only**
4
+
5
+ ## TL;DR
6
+
7
+ You MAY:
8
+ - Study this research privately
9
+ - Share it with attribution (keep this license intact)
10
+ - Build on it for personal/research purposes
11
+
12
+ You MAY NOT:
13
+ - **Commercialize** the content or any derivative works
14
+ - Create derivative works (remixes, modifications, extensions)
15
+ - Remove this license from any distribution
16
+ - Claim authorship of original work
17
+
18
+ ## Full Legal Notice
19
+
20
+ This work may not be used for commercial purposes. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
21
+
22
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
23
+
24
+ 2. The name "ProjectAlpha" and the author's name may not be used to endorse or promote products derived from this software without specific prior written permission.
25
+
26
+ 3. This software is provided by the copyright holders "as is" and any express or implied warranties are disclaimed, including but not limited to implied warranties of merchantability and fitness for a particular purpose.
27
+
28
+ ## Jurisdiction
29
+
30
+ This license is governed by the laws of the jurisdiction in which the original author resides.
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:
@@ -119,6 +144,7 @@ Published packages include:
119
144
  - `dist/server.js`
120
145
  - `src/**/*.ts` for source inspection
121
146
  - `README.md`
147
+ - `LICENSE`
122
148
  - `CHANGELOG.md`
123
149
  - `package.json`
124
150
 
@@ -144,8 +170,8 @@ The package is intentionally Bun-native today because the server uses Bun APIs.
144
170
 
145
171
  ## License
146
172
 
147
- Apache-2.0 for package code. Research artifacts in the repository root may use separate licenses; check the repository root license files.
173
+ Released under the same license as the monorepo root: [CC BY-NC-ND 4.0](./LICENSE). You may share unmodified copies with attribution for non-commercial use. For commercial licensing or research collaboration, open an issue in the source repository.
148
174
 
149
175
  ## Releases
150
176
 
151
- See [CHANGELOG.md](./CHANGELOG.md). Current npm release: `1.1.4`.
177
+ See [CHANGELOG.md](./CHANGELOG.md). Current npm release: `1.1.6`.
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.6",
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": {
@@ -21,7 +21,7 @@
21
21
  "bugs": {
22
22
  "url": "https://github.com/Fearvox/dash-research-vault/issues"
23
23
  },
24
- "license": "Apache-2.0",
24
+ "license": "CC-BY-NC-ND-4.0",
25
25
  "publishConfig": {
26
26
  "access": "public"
27
27
  },
@@ -30,6 +30,7 @@
30
30
  "dist/**/*.js",
31
31
  "bin/**/*.mjs",
32
32
  "README.md",
33
+ "LICENSE",
33
34
  "CHANGELOG.md",
34
35
  "package.json"
35
36
  ],
@@ -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
  )