@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/CHANGELOG.md +35 -0
- package/README.md +34 -7
- package/dist/server.js +1114 -323
- package/package.json +6 -5
- package/src/amplify.ts +32 -41
- package/src/evidence_metadata.ts +191 -0
- package/src/guidance.ts +57 -0
- package/src/ingest/html.ts +129 -19
- package/src/profile.ts +15 -0
- package/src/public_safety.ts +110 -0
- package/src/response.ts +73 -0
- package/src/server.ts +304 -108
- package/src/tool_policy.ts +58 -0
- package/src/types.ts +4 -3
- package/src/vault.ts +300 -75
- package/src/vault_get.ts +109 -0
- package/src/vault_write.ts +78 -112
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
107
|
+
const knowledgeDir = getKnowledgeDir()
|
|
108
|
+
if (!existsSync(knowledgeDir)) return entries
|
|
84
109
|
|
|
85
|
-
const categories = readdirSync(
|
|
110
|
+
const categories = readdirSync(knowledgeDir)
|
|
86
111
|
for (const cat of categories) {
|
|
87
112
|
if (cat.startsWith('_')) continue
|
|
88
|
-
const catPath = join(
|
|
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
|
-
|
|
153
|
+
const rawDir = getRawDir()
|
|
154
|
+
if (!existsSync(rawDir)) return pending
|
|
129
155
|
|
|
130
156
|
try {
|
|
131
|
-
const entries = readdirSync(
|
|
157
|
+
const entries = readdirSync(rawDir)
|
|
132
158
|
for (const entry of entries) {
|
|
133
159
|
if (entry === '_inbox') {
|
|
134
|
-
const inbox = join(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
item
|
|
183
|
-
|
|
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
|
|
190
|
-
const score = scoreMap
|
|
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(
|
|
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
|
|
220
|
-
const
|
|
221
|
-
const
|
|
222
|
-
const
|
|
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
|
|
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
|
|
267
|
-
const
|
|
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
|
-
|
|
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(
|
|
528
|
+
text: JSON.stringify(envelope, null, 2)
|
|
304
529
|
}]
|
|
305
530
|
}
|
|
306
531
|
}
|
package/src/vault_get.ts
ADDED
|
@@ -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
|
+
}
|