@syndash/research-vault-mcp 1.1.2 → 1.1.3

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/vault.ts CHANGED
@@ -5,32 +5,49 @@
5
5
  import { readFileSync, readdirSync, existsSync, statSync } from 'fs'
6
6
  import { join, basename } from 'path'
7
7
  import { homedir } from 'os'
8
+ import { getVaultEntry } from './vault_get.ts'
9
+ import type { DecayScore, VaultEntry, VaultGetInput } from './types.ts'
10
+ import { flagGuidance, passGuidance } from './guidance.ts'
11
+ import { okEnvelope } from './response.ts'
12
+ import {
13
+ coverageMetadata,
14
+ itemFreshness,
15
+ matchedFields,
16
+ queueFreshness,
17
+ releaseMetadata,
18
+ snippetFromContent,
19
+ staleVerdict,
20
+ whyMatched,
21
+ } from './evidence_metadata.ts'
22
+
23
+ const DEFAULT_VAULT_ROOT = `${homedir()}/Documents/Evensong/research-vault`
24
+
25
+ function getVaultRoot(): string {
26
+ return process.env.VAULT_ROOT ?? DEFAULT_VAULT_ROOT
27
+ }
28
+
29
+ function getKnowledgeDir(): string {
30
+ return join(getVaultRoot(), 'knowledge')
31
+ }
32
+
33
+ function getRawDir(): string {
34
+ return join(getVaultRoot(), 'raw')
35
+ }
8
36
 
9
- const VAULT_ROOT = process.env.VAULT_ROOT ?? `${homedir()}/Documents/Evensong/research-vault`
10
- const KNOWLEDGE_DIR = join(VAULT_ROOT, 'knowledge')
11
- const RAW_DIR = join(VAULT_ROOT, 'raw')
12
- const DECAY_PATH = join(VAULT_ROOT, '.meta', 'decay-scores.json')
13
- const TAXONOMY_PATH = join(VAULT_ROOT, 'knowledge', '_taxonomy.md')
14
-
15
- // ─── Types ───────────────────────────────────────────────────────────────────
16
-
17
- interface VaultEntry {
18
- id: string
19
- title: string
20
- category: string
21
- path: string
22
- modified: string
23
- size: number
37
+ function getDecayPath(): string {
38
+ return join(getVaultRoot(), '.meta', 'decay-scores.json')
24
39
  }
25
40
 
26
- interface DecayScore {
27
- itemId: string
28
- score: number
29
- lastAccess: string
30
- accessCount: number
31
- summaryLevel: 'deep' | 'shallow' | 'none'
32
- nextReviewAt: string
33
- difficulty: number
41
+ function getTaxonomyPath(): string {
42
+ return join(getVaultRoot(), 'knowledge', '_taxonomy.md')
43
+ }
44
+
45
+ function getPackageJson(): { name?: string; version?: string } {
46
+ try {
47
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'))
48
+ } catch {
49
+ return {}
50
+ }
34
51
  }
35
52
 
36
53
  // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -42,9 +59,16 @@ function normalizeId(raw: string): string {
42
59
  .replace(/\.md$/, '')
43
60
  }
44
61
 
62
+ export function normalizeDecayScoresStore(parsed: unknown): DecayScore[] {
63
+ if (Array.isArray(parsed)) return parsed as DecayScore[]
64
+ if (parsed && typeof parsed === 'object') return Object.values(parsed) as DecayScore[]
65
+ return []
66
+ }
67
+
45
68
  function loadDecayScores(): DecayScore[] {
46
69
  try {
47
- return JSON.parse(readFileSync(DECAY_PATH, 'utf-8'))
70
+ const parsed = JSON.parse(readFileSync(getDecayPath(), 'utf-8'))
71
+ return normalizeDecayScoresStore(parsed)
48
72
  } catch {
49
73
  return []
50
74
  }
@@ -52,7 +76,7 @@ function loadDecayScores(): DecayScore[] {
52
76
 
53
77
  function loadTaxonomy(): string {
54
78
  try {
55
- return readFileSync(TAXONOMY_PATH, 'utf-8')
79
+ return readFileSync(getTaxonomyPath(), 'utf-8')
56
80
  } catch {
57
81
  return ''
58
82
  }
@@ -80,12 +104,13 @@ function loadFileMeta(filePath: string): { title: string; modified: string; size
80
104
 
81
105
  function scanKnowledge(): VaultEntry[] {
82
106
  const entries: VaultEntry[] = []
83
- if (!existsSync(KNOWLEDGE_DIR)) return entries
107
+ const knowledgeDir = getKnowledgeDir()
108
+ if (!existsSync(knowledgeDir)) return entries
84
109
 
85
- const categories = readdirSync(KNOWLEDGE_DIR)
110
+ const categories = readdirSync(knowledgeDir)
86
111
  for (const cat of categories) {
87
112
  if (cat.startsWith('_')) continue
88
- const catPath = join(KNOWLEDGE_DIR, cat)
113
+ const catPath = join(knowledgeDir, cat)
89
114
  if (!existsSync(catPath) || !statSync(catPath).isDirectory()) continue
90
115
 
91
116
  const subEntries = readdirSync(catPath)
@@ -125,18 +150,19 @@ function scanKnowledge(): VaultEntry[] {
125
150
 
126
151
  function scanRaw(): string[] {
127
152
  const pending: string[] = []
128
- if (!existsSync(RAW_DIR)) return pending
153
+ const rawDir = getRawDir()
154
+ if (!existsSync(rawDir)) return pending
129
155
 
130
156
  try {
131
- const entries = readdirSync(RAW_DIR)
157
+ const entries = readdirSync(rawDir)
132
158
  for (const entry of entries) {
133
159
  if (entry === '_inbox') {
134
- const inbox = join(RAW_DIR, entry)
160
+ const inbox = join(rawDir, entry)
135
161
  if (existsSync(inbox)) {
136
162
  pending.push(...readdirSync(inbox).filter(f => /\.(md|pdf|txt)$/.test(f)))
137
163
  }
138
164
  } else if (/^\d{4}-\d{2}$/.test(entry)) {
139
- const monthDir = join(RAW_DIR, entry)
165
+ const monthDir = join(rawDir, entry)
140
166
  if (existsSync(monthDir)) {
141
167
  pending.push(
142
168
  ...readdirSync(monthDir)
@@ -151,24 +177,121 @@ function scanRaw(): string[] {
151
177
  return pending
152
178
  }
153
179
 
180
+ function scanRawQueueItems() {
181
+ const rawDir = getRawDir()
182
+ return scanRaw().map(item => {
183
+ let filePath = join(rawDir, item)
184
+ if (!existsSync(filePath)) filePath = join(rawDir, '_inbox', item)
185
+ let sourceMtime: string | null = null
186
+ try {
187
+ sourceMtime = statSync(filePath).mtime.toISOString()
188
+ } catch {}
189
+
190
+ const title = normalizeId(basename(item)).replace(/\.[^.]+$/, '')
191
+ return {
192
+ id: normalizeId(basename(item)).replace(/\.[^.]+$/, ''),
193
+ title,
194
+ source_mtime: sourceMtime,
195
+ }
196
+ })
197
+ }
198
+
199
+ function entryScoreAliases(entry: VaultEntry): Set<string> {
200
+ const stem = basename(entry.path).replace(/\.md$/, '')
201
+ const aliases = new Set([
202
+ entry.id,
203
+ stem,
204
+ normalizeId(entry.id),
205
+ normalizeId(stem),
206
+ entry.id.replace(/--/g, '-'),
207
+ stem.replace(/--/g, '-'),
208
+ ])
209
+
210
+ const datePrefixed = stem.match(/^\d{8}--(.+)$/)
211
+ if (datePrefixed) aliases.add(datePrefixed[1])
212
+
213
+ return aliases
214
+ }
215
+
216
+ function scoreAliases(score: DecayScore): Set<string> {
217
+ return new Set([
218
+ score.itemId,
219
+ normalizeId(score.itemId),
220
+ score.itemId.replace(/--/g, '-'),
221
+ ])
222
+ }
223
+
224
+ function scoreMatchesEntry(score: DecayScore, entry: VaultEntry): boolean {
225
+ const entryAliases = entryScoreAliases(entry)
226
+ return [...scoreAliases(score)].some(alias => entryAliases.has(alias))
227
+ }
228
+
229
+ function matchedScoresForEntries<T extends DecayScore>(scores: T[], entries: VaultEntry[]): T[] {
230
+ return scores.filter(score => entries.some(entry => scoreMatchesEntry(score, entry)))
231
+ }
232
+
233
+ function pendingRawQueueItems(entries: VaultEntry[]) {
234
+ const analyzedIds = new Set(entries.flatMap(entry => [...entryScoreAliases(entry)]))
235
+ return scanRawQueueItems().filter(item => !analyzedIds.has(normalizeId(item.id)))
236
+ }
237
+
238
+ function sourceRef(entry: VaultEntry): string {
239
+ return `vault://knowledge/${entry.category}/${entry.id}`
240
+ }
241
+
242
+ function readEntryContent(entry: VaultEntry): string {
243
+ try {
244
+ return readFileSync(entry.path, 'utf-8')
245
+ } catch {
246
+ return ''
247
+ }
248
+ }
249
+
250
+ function scoreForEntry(scoreMap: Map<string, DecayScore & { lastAnalyzedAt?: string }>, item: VaultEntry) {
251
+ return [...entryScoreAliases(item)]
252
+ .map(alias => scoreMap.get(alias))
253
+ .find(Boolean)
254
+ }
255
+
154
256
  // ─── MCP Tools ───────────────────────────────────────────────────────────────
155
257
 
156
258
  const vaultTools = [
259
+ {
260
+ name: 'vault_get',
261
+ description: 'Read a bounded public-safe excerpt or capped content from a Research Vault note by id.',
262
+ inputSchema: {
263
+ type: 'object',
264
+ properties: {
265
+ id: { type: 'string', description: 'Research Vault note id returned by vault_search' },
266
+ include_content: { type: 'boolean', description: 'Return capped content instead of the default excerpt' },
267
+ max_chars: { type: 'number', description: 'Maximum returned content characters, capped at 12000' }
268
+ },
269
+ required: ['id']
270
+ },
271
+ call: async (args: VaultGetInput) => {
272
+ const result = getVaultEntry(args, scanKnowledge())
273
+ return {
274
+ content: [{ type: 'text', text: JSON.stringify(result.envelope, null, 2) }],
275
+ ...(result.isError ? { isError: true } : {})
276
+ }
277
+ }
278
+ },
279
+
157
280
  {
158
281
  name: 'vault_search',
159
282
  description: 'Search the Research Vault knowledge base. Returns analyzed papers with retention scores.',
160
283
  inputSchema: {
161
284
  type: 'object',
162
285
  properties: {
163
- query: { type: 'string', description: 'Search query (matches title, category)' },
286
+ query: { type: 'string', description: 'Search query (matches title, content, id, and category)' },
164
287
  category: { type: 'string', description: 'Filter by category (e.g., "ai-agents/benchmarking")' },
165
288
  limit: { type: 'number', description: 'Max results (default 10)' }
166
289
  }
167
290
  },
168
291
  call: async ({ query, category, limit = 10 }: { query?: string; category?: string; limit?: number }) => {
169
292
  let items = scanKnowledge()
170
- const scores = loadDecayScores()
171
- const scoreMap = new Map(scores.map(s => [normalizeId(s.itemId), s]))
293
+ const scores = loadDecayScores() as Array<DecayScore & { lastAnalyzedAt?: string }>
294
+ const scoreMap = new Map(scores.flatMap(s => [...scoreAliases(s)].map(alias => [alias, s] as const)))
172
295
 
173
296
  if (category) {
174
297
  items = items.filter(item =>
@@ -177,17 +300,17 @@ const vaultTools = [
177
300
  }
178
301
 
179
302
  if (query) {
180
- const q = query.toLowerCase()
181
- items = items.filter(item =>
182
- item.title.toLowerCase().includes(q) ||
183
- item.id.toLowerCase().includes(q) ||
184
- item.category.toLowerCase().includes(q)
185
- )
303
+ items = items.filter(item => {
304
+ const content = readEntryContent(item)
305
+ return matchedFields({ ...item, content }, query).length > 0
306
+ })
186
307
  }
187
308
 
188
309
  const results = items.slice(0, limit).map(item => {
189
- const sid = item.id.replace(/--/g, '-')
190
- const score = scoreMap.get(item.id) || scoreMap.get(sid)
310
+ const content = readEntryContent(item)
311
+ const score = scoreForEntry(scoreMap, item)
312
+ const fields = matchedFields({ ...item, content }, query)
313
+ const freshness = itemFreshness({ ...item, score })
191
314
  return {
192
315
  id: item.id,
193
316
  title: item.title,
@@ -196,14 +319,41 @@ const vaultTools = [
196
319
  summaryLevel: score?.summaryLevel ?? null,
197
320
  nextReview: score?.nextReviewAt ?? null,
198
321
  accessCount: score?.accessCount ?? 0,
199
- modified: item.modified
322
+ modified: item.modified,
323
+ matched_fields: fields,
324
+ why_matched: whyMatched({ ...item, content }, query, fields),
325
+ snippet: snippetFromContent(content, query, 280),
326
+ source_ref: sourceRef(item),
327
+ section_anchor: undefined,
328
+ canonical_group: undefined,
329
+ ...freshness,
200
330
  }
201
331
  })
202
332
 
333
+ const hasStale = results.some(result => result.freshness_verdict === 'FLAG')
334
+ const envelope = okEnvelope(
335
+ { query, category, results, total: results.length },
336
+ hasStale
337
+ ? 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.',
340
+ 'vault_get',
341
+ )
342
+ : passGuidance(
343
+ 'Search completed with provenance and freshness metadata.',
344
+ 'Use source_ref or vault_get for bounded follow-up evidence.',
345
+ 'vault_get',
346
+ ),
347
+ {
348
+ freshness: hasStale ? 'Some search results are missing or stale analysis timestamps.' : 'Search result analysis metadata is fresh.',
349
+ provenance: 'vault_search result source_ref values are vault:// references without local paths.',
350
+ },
351
+ )
352
+
203
353
  return {
204
354
  content: [{
205
355
  type: 'text',
206
- text: JSON.stringify({ query, category, results, total: results.length }, null, 2)
356
+ text: JSON.stringify(envelope, null, 2)
207
357
  }]
208
358
  }
209
359
  }
@@ -214,12 +364,21 @@ const vaultTools = [
214
364
  description: 'Get Research Vault health — item counts by decay level, top/bottom retention.',
215
365
  inputSchema: { type: 'object', properties: {} },
216
366
  call: async () => {
217
- const scores = loadDecayScores()
367
+ const scores = loadDecayScores() as Array<DecayScore & { lastAnalyzedAt?: string }>
218
368
  const entries = scanKnowledge()
219
- const deep = scores.filter(s => s.summaryLevel === 'deep')
220
- const shallow = scores.filter(s => s.summaryLevel === 'shallow')
221
- const none = scores.filter(s => s.summaryLevel === 'none')
222
- const sorted = [...scores].sort((a, b) => b.score - a.score)
369
+ const matchedScores = matchedScoresForEntries(scores, entries)
370
+ const deep = matchedScores.filter(s => s.summaryLevel === 'deep')
371
+ const shallow = matchedScores.filter(s => s.summaryLevel === 'shallow')
372
+ const none = matchedScores.filter(s => s.summaryLevel === 'none')
373
+ const sorted = [...matchedScores].sort((a, b) => b.score - a.score)
374
+ const queueItems = pendingRawQueueItems(entries)
375
+ const coverage = coverageMetadata({
376
+ total: entries.length,
377
+ analyzed: matchedScores.length,
378
+ scores: matchedScores,
379
+ queueItems,
380
+ })
381
+ const release = releaseMetadata(process.env, getPackageJson())
223
382
 
224
383
  const top5 = sorted.slice(0, 5).map(s => {
225
384
  const sid = s.itemId.replace(/--/g, '-')
@@ -232,20 +391,46 @@ const vaultTools = [
232
391
  return { itemId: s.itemId, score: s.score, lastAccess: s.lastAccess.slice(0, 10), title: entry?.title || s.itemId }
233
392
  })
234
393
 
235
- const pending = scanRaw()
394
+ const statusData = {
395
+ total: entries.length,
396
+ analyzed: matchedScores.length,
397
+ deep: deep.length,
398
+ shallow: shallow.length,
399
+ dormant: none.length,
400
+ pending_raw: queueItems.length,
401
+ top5,
402
+ bottom5,
403
+ ...coverage,
404
+ release,
405
+ }
406
+ const releaseFlag = release.freshness_verdict === 'FLAG'
407
+ const analysisFlag = staleVerdict(coverage.last_analyzed_at).verdict === 'FLAG'
408
+ const envelope = okEnvelope(
409
+ statusData,
410
+ releaseFlag || analysisFlag
411
+ ? flagGuidance(
412
+ releaseFlag
413
+ ? 'Vault status is available, but release freshness was not fully provided by the runtime environment.'
414
+ : 'Vault status is available, but analysis freshness is stale or missing.',
415
+ releaseFlag
416
+ ? 'Set RESEARCH_VAULT_NPM_LATEST_VERSION, RESEARCH_VAULT_NPM_MODIFIED_AT, and RESEARCH_VAULT_PUBLIC_REPO_URL in the runtime environment.'
417
+ : 'Run the operator analysis lane before relying on freshness-sensitive claims.',
418
+ )
419
+ : passGuidance(
420
+ 'Vault status includes coverage, queue freshness, and release metadata.',
421
+ 'Use pending_raw and oldest_pending_age to decide whether operator analysis is needed.',
422
+ ),
423
+ {
424
+ as_of: coverage.as_of,
425
+ freshness: analysisFlag ? 'Analysis freshness is stale or missing.' : 'Analysis freshness is within the accepted window.',
426
+ release: release.freshness_verdict,
427
+ },
428
+ )
429
+
236
430
  return {
237
431
  content: [{
238
432
  type: 'text',
239
- text: JSON.stringify({
240
- total: entries.length,
241
- analyzed: scores.length,
242
- deep: deep.length,
243
- shallow: shallow.length,
244
- dormant: none.length,
245
- pending_raw: pending.length,
246
- top5,
247
- bottom5
248
- }, null, 2)
433
+ text: JSON.stringify(envelope, null, 2)
249
434
  }]
250
435
  }
251
436
  }
@@ -261,27 +446,56 @@ const vaultTools = [
261
446
  }
262
447
  },
263
448
  call: async ({ count = 5 }: { count?: number } = {}) => {
264
- const pending = scanRaw()
265
449
  const entries = scanKnowledge()
266
- const analyzedIds = new Set(entries.map(e => normalizeId(e.id)))
267
- const unanalyzed = pending.filter(p => {
268
- const id = normalizeId(p)
269
- return !analyzedIds.has(id)
270
- })
450
+ const unanalyzed = pendingRawQueueItems(entries)
451
+ const freshness = queueFreshness(unanalyzed)
271
452
 
272
453
  if (unanalyzed.length === 0) {
273
- return { content: [{ type: 'text', text: JSON.stringify({ message: 'Queue empty — all papers analyzed', analyzed: entries.length }) }] }
454
+ const envelope = okEnvelope(
455
+ {
456
+ message: 'Queue empty; all visible raw papers are analyzed.',
457
+ analyzed: entries.length,
458
+ pending: 0,
459
+ preview: [],
460
+ oldest_pending_age: null,
461
+ next_action: 'none',
462
+ },
463
+ passGuidance(
464
+ 'Batch analysis queue is empty.',
465
+ 'No operator batch analysis action is needed.',
466
+ ),
467
+ )
468
+ return { content: [{ type: 'text', text: JSON.stringify(envelope, null, 2) }] }
274
469
  }
275
470
 
471
+ const envelope = okEnvelope(
472
+ {
473
+ message: `${unanalyzed.length} papers pending analysis`,
474
+ pending: unanalyzed.length,
475
+ preview: unanalyzed.slice(0, count).map(item => ({
476
+ id: item.id,
477
+ title: item.title,
478
+ })),
479
+ oldest_pending_age: freshness.oldest_pending_age,
480
+ oldest_pending_at: freshness.oldest_pending_at,
481
+ next_action: 'operator_run_batch_analysis',
482
+ },
483
+ flagGuidance(
484
+ 'Raw queue has pending items that require operator-side batch analysis.',
485
+ 'Run the private operator batch analysis lane; this public response intentionally omits shell commands and local paths.',
486
+ ),
487
+ {
488
+ freshness: freshness.oldest_pending_age === null
489
+ ? 'Pending queue age could not be calculated.'
490
+ : `Oldest pending item is ${freshness.oldest_pending_age} days old.`,
491
+ provenance: 'Queue preview exposes ids and titles only.',
492
+ },
493
+ )
494
+
276
495
  return {
277
496
  content: [{
278
497
  type: 'text',
279
- text: JSON.stringify({
280
- message: `${unanalyzed.length} papers pending analysis`,
281
- pending: unanalyzed.length,
282
- preview: unanalyzed.slice(0, count),
283
- hint: 'cd ~/Desktop/research-vault && bun run scripts/batch-analyze.ts --count N'
284
- }, null, 2)
498
+ text: JSON.stringify(envelope, null, 2)
285
499
  }]
286
500
  }
287
501
  }
@@ -296,11 +510,22 @@ const vaultTools = [
296
510
  const entries = scanKnowledge()
297
511
  const catCounts: Record<string, number> = {}
298
512
  for (const e of entries) catCounts[e.category] = (catCounts[e.category] || 0) + 1
513
+ const envelope = okEnvelope(
514
+ { taxonomy, categories: catCounts },
515
+ passGuidance(
516
+ 'Taxonomy loaded with public-safe evidence metadata.',
517
+ 'Use category counts for readonly routing and vault_search for bounded follow-up evidence.',
518
+ 'vault_search',
519
+ ),
520
+ {
521
+ provenance: 'Taxonomy response is sanitized through the public-safe envelope before serialization.',
522
+ },
523
+ )
299
524
 
300
525
  return {
301
526
  content: [{
302
527
  type: 'text',
303
- text: JSON.stringify({ taxonomy, categories: catCounts }, null, 2)
528
+ text: JSON.stringify(envelope, null, 2)
304
529
  }]
305
530
  }
306
531
  }
@@ -0,0 +1,109 @@
1
+ import { readFileSync, statSync } from 'fs'
2
+ import { basename } from 'path'
3
+ import { passGuidance } from './guidance.ts'
4
+ import { errorEnvelope, okEnvelope } from './response.ts'
5
+ import type { VaultEntry, VaultGetInput } from './types.ts'
6
+
7
+ const DEFAULT_EXCERPT_CHARS = 1200
8
+ const HARD_MAX_CHARS = 12000
9
+
10
+ function fileStem(entry: VaultEntry): string {
11
+ return basename(entry.path).replace(/\.md$/, '')
12
+ }
13
+
14
+ function sourceRef(entry: VaultEntry): string {
15
+ return `vault://knowledge/${entry.category}/${entry.id}`
16
+ }
17
+
18
+ function requestedLimit(input: VaultGetInput): number {
19
+ const fallback = input.include_content ? HARD_MAX_CHARS : DEFAULT_EXCERPT_CHARS
20
+ const ceiling = input.include_content ? HARD_MAX_CHARS : DEFAULT_EXCERPT_CHARS
21
+ const requested = input.max_chars === undefined
22
+ ? fallback
23
+ : Number.isFinite(input.max_chars)
24
+ ? Math.max(0, Math.floor(input.max_chars))
25
+ : fallback
26
+ return Math.min(requested, ceiling)
27
+ }
28
+
29
+ function resolveEntry(
30
+ id: string,
31
+ entries: VaultEntry[],
32
+ ): { entry?: VaultEntry; reason?: string } {
33
+ const exact = entries.filter(entry => entry.id === id)
34
+ if (exact.length === 1) return { entry: exact[0] }
35
+ if (exact.length > 1) return { reason: 'Multiple Research Vault notes matched the requested id.' }
36
+
37
+ const stemMatches = entries.filter(entry => fileStem(entry) === id)
38
+ if (stemMatches.length === 1) return { entry: stemMatches[0] }
39
+ if (stemMatches.length > 1) return { reason: 'Multiple Research Vault notes matched the requested file stem.' }
40
+
41
+ return { reason: 'No Research Vault note matched the requested id.' }
42
+ }
43
+
44
+ export function getVaultEntry(
45
+ input: VaultGetInput,
46
+ entries: VaultEntry[],
47
+ ) {
48
+ const id = input.id?.trim()
49
+ if (!id) {
50
+ return {
51
+ envelope: errorEnvelope(
52
+ 'vault_get requires a non-empty id.',
53
+ 'Call vault_search first, then retry vault_get with an exact id from the search results.',
54
+ {},
55
+ ),
56
+ isError: true,
57
+ }
58
+ }
59
+
60
+ const resolved = resolveEntry(id, entries)
61
+ if (!resolved.entry) {
62
+ return {
63
+ envelope: errorEnvelope(
64
+ resolved.reason ?? 'No Research Vault note matched the requested id.',
65
+ 'Call vault_search first, then retry vault_get with an exact id from the search results.',
66
+ {},
67
+ ),
68
+ isError: true,
69
+ }
70
+ }
71
+
72
+ const entry = resolved.entry
73
+ const content = readFileSync(entry.path, 'utf-8')
74
+ const stat = statSync(entry.path)
75
+ const limit = requestedLimit(input)
76
+ const body = content.slice(0, limit)
77
+ const truncated = content.length > body.length
78
+ const contentKind = input.include_content ? 'full' : 'excerpt'
79
+
80
+ return {
81
+ envelope: okEnvelope(
82
+ {
83
+ id: entry.id,
84
+ title: entry.title,
85
+ category: entry.category,
86
+ source_ref: sourceRef(entry),
87
+ content: body,
88
+ content_kind: contentKind,
89
+ truncated,
90
+ chars_returned: body.length,
91
+ total_chars: content.length,
92
+ max_chars: limit,
93
+ modified: stat.mtime.toISOString(),
94
+ size: stat.size,
95
+ },
96
+ passGuidance(
97
+ input.include_content
98
+ ? 'Returned bounded note content from the Research Vault read surface.'
99
+ : 'Returned a bounded excerpt from the Research Vault read surface.',
100
+ truncated
101
+ ? 'Use include_content with a larger max_chars only if this excerpt is insufficient.'
102
+ : 'Use the returned public-safe note reference for follow-up.',
103
+ truncated ? 'vault_get' : undefined,
104
+ ),
105
+ {},
106
+ ),
107
+ isError: false,
108
+ }
109
+ }