@syndash/research-vault-mcp 1.1.0
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/README.md +75 -0
- package/bin/research-vault-mcp.mjs +49 -0
- package/package.json +46 -0
- package/src/amplify.ts +245 -0
- package/src/ingest/arxiv.ts +64 -0
- package/src/ingest/html.ts +46 -0
- package/src/ingest/pdf.ts +30 -0
- package/src/server.ts +301 -0
- package/src/types.ts +77 -0
- package/src/vault.ts +310 -0
- package/src/vault_jobs.ts +88 -0
- package/src/vault_write.ts +347 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Research Vault MCP Server — Standard MCP SSE Transport
|
|
2
|
+
// MCP Protocol: JSON-RPC 2.0 over SSE (server→client) + HTTP POST (client→server)
|
|
3
|
+
//
|
|
4
|
+
// Flow:
|
|
5
|
+
// 1. Client connects GET /sse
|
|
6
|
+
// 2. Server sends: event: endpoint\ndata: /messages?sessionId=<uuid>
|
|
7
|
+
// 3. Client POSTs JSON-RPC to /messages?sessionId=<uuid>
|
|
8
|
+
// 4. Server sends JSON-RPC response via SSE: event: message\ndata: {...}
|
|
9
|
+
|
|
10
|
+
import { vaultTools } from './vault'
|
|
11
|
+
import { vaultWriteTools } from './vault_write.js'
|
|
12
|
+
import { amplifyTools, configureAmplify } from './amplify'
|
|
13
|
+
|
|
14
|
+
const HOST = '0.0.0.0'
|
|
15
|
+
const TRANSPORT = process.env.MCP_TRANSPORT ?? 'sse'
|
|
16
|
+
const PORT = parseInt(process.env.MCP_PORT ?? '8765')
|
|
17
|
+
|
|
18
|
+
// ─── MCP Protocol Types ──────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
interface MCPRequest {
|
|
21
|
+
jsonrpc: '2.0'
|
|
22
|
+
id?: string | number
|
|
23
|
+
method: string
|
|
24
|
+
params?: any
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface MCPResponse {
|
|
28
|
+
jsonrpc: '2.0'
|
|
29
|
+
id?: string | number
|
|
30
|
+
result?: any
|
|
31
|
+
error?: { code: number; message: string; data?: any }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Tool {
|
|
35
|
+
name: string
|
|
36
|
+
description: string
|
|
37
|
+
inputSchema: any
|
|
38
|
+
call: (params: any) => Promise<{ content: Array<{type: string; text: string}>; isError?: boolean }>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── State ───────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const allTools: Tool[] = [
|
|
44
|
+
...vaultTools,
|
|
45
|
+
...vaultWriteTools,
|
|
46
|
+
...amplifyTools
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
const toolMap = new Map(allTools.map(t => [t.name, t]))
|
|
50
|
+
|
|
51
|
+
// Session management: sessionId → SSE writer
|
|
52
|
+
interface Session {
|
|
53
|
+
send: (data: string) => void
|
|
54
|
+
heartbeat: ReturnType<typeof setInterval>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sessions = new Map<string, Session>()
|
|
58
|
+
|
|
59
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function makeResponse(id: string | number | undefined, result?: any, error?: any): MCPResponse {
|
|
62
|
+
return { jsonrpc: '2.0', id, result, error }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function generateSessionId(): string {
|
|
66
|
+
return crypto.randomUUID()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── MCP Handlers ─────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
async function handleRequest(req: MCPRequest): Promise<MCPResponse | null> {
|
|
72
|
+
const { method, id, params } = req
|
|
73
|
+
|
|
74
|
+
// ── notifications (no id = no response expected)
|
|
75
|
+
if (method === 'notifications/initialized' || method === 'notifications/cancelled') {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── initialize
|
|
80
|
+
if (method === 'initialize') {
|
|
81
|
+
return makeResponse(id, {
|
|
82
|
+
protocolVersion: '2024-11-05',
|
|
83
|
+
capabilities: {
|
|
84
|
+
tools: { listChanged: false },
|
|
85
|
+
},
|
|
86
|
+
serverInfo: {
|
|
87
|
+
name: 'research-vault-mcp',
|
|
88
|
+
version: '1.0.0'
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── tools/list
|
|
94
|
+
if (method === 'tools/list') {
|
|
95
|
+
return makeResponse(id, {
|
|
96
|
+
tools: allTools.map(t => ({
|
|
97
|
+
name: t.name,
|
|
98
|
+
description: t.description,
|
|
99
|
+
inputSchema: t.inputSchema
|
|
100
|
+
}))
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── tools/call
|
|
105
|
+
if (method === 'tools/call') {
|
|
106
|
+
const { name, arguments: args } = params
|
|
107
|
+
console.error('[DEBUG] tools/call:', name, JSON.stringify(args))
|
|
108
|
+
const tool = toolMap.get(name)
|
|
109
|
+
if (!tool) {
|
|
110
|
+
return makeResponse(id, undefined, { code: -32602, message: `Unknown tool: ${name}` })
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const result = await tool.call(args || {})
|
|
114
|
+
return makeResponse(id, { content: result.content, isError: result.isError })
|
|
115
|
+
} catch (e: any) {
|
|
116
|
+
return makeResponse(id, undefined, { code: -32603, message: `Tool error: ${e.message}` })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── ping
|
|
121
|
+
if (method === 'ping') {
|
|
122
|
+
return makeResponse(id, {})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return makeResponse(id, undefined, { code: -32601, message: `Method not found: ${method}` })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── STDIO Transport ──────────────────────────────────────────────────────────
|
|
129
|
+
async function handleStdioTransport() {
|
|
130
|
+
const rl = await import('readline')
|
|
131
|
+
const rli = rl.createInterface({ input: process.stdin as any, crlfDelay: Infinity })
|
|
132
|
+
const writer = Bun.stdout.writer()
|
|
133
|
+
|
|
134
|
+
const send = (obj: MCPResponse) => {
|
|
135
|
+
writer.write(JSON.stringify(obj) + '\n')
|
|
136
|
+
writer.flush()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for await (const line of rli) {
|
|
140
|
+
if (!line.trim()) continue
|
|
141
|
+
try {
|
|
142
|
+
const req = JSON.parse(line) as MCPRequest
|
|
143
|
+
const result = await handleRequest(req)
|
|
144
|
+
if (result) send(result)
|
|
145
|
+
} catch (e: unknown) {
|
|
146
|
+
send({ jsonrpc: '2.0', error: { code: -32700, message: `Parse error: ${e instanceof Error ? e.message : String(e)}` } })
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ─── HTTP Server ──────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
const server = Bun.serve({
|
|
154
|
+
port: PORT,
|
|
155
|
+
hostname: HOST,
|
|
156
|
+
|
|
157
|
+
async fetch(req: Request): Promise<Response> {
|
|
158
|
+
const url = new URL(req.url)
|
|
159
|
+
|
|
160
|
+
// ── GET /sse — MCP SSE Transport: establish SSE stream + send endpoint
|
|
161
|
+
if (url.pathname === '/sse' && req.method === 'GET') {
|
|
162
|
+
const sessionId = generateSessionId()
|
|
163
|
+
|
|
164
|
+
const stream = new ReadableStream({
|
|
165
|
+
start(controller) {
|
|
166
|
+
const encoder = new TextEncoder()
|
|
167
|
+
|
|
168
|
+
const send = (data: string) => {
|
|
169
|
+
try { controller.enqueue(encoder.encode(data)) } catch {}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Step 1: Send the endpoint event (MCP SSE spec requirement)
|
|
173
|
+
send(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`)
|
|
174
|
+
|
|
175
|
+
// Heartbeat every 15s
|
|
176
|
+
const heartbeat = setInterval(() => {
|
|
177
|
+
try {
|
|
178
|
+
controller.enqueue(encoder.encode(`: heartbeat\n\n`))
|
|
179
|
+
} catch {
|
|
180
|
+
clearInterval(heartbeat)
|
|
181
|
+
sessions.delete(sessionId)
|
|
182
|
+
}
|
|
183
|
+
}, 15000)
|
|
184
|
+
|
|
185
|
+
// Register session
|
|
186
|
+
sessions.set(sessionId, { send, heartbeat })
|
|
187
|
+
|
|
188
|
+
console.error(`[SSE] Session ${sessionId} connected`)
|
|
189
|
+
|
|
190
|
+
req.signal.addEventListener('abort', () => {
|
|
191
|
+
clearInterval(heartbeat)
|
|
192
|
+
sessions.delete(sessionId)
|
|
193
|
+
console.error(`[SSE] Session ${sessionId} disconnected`)
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
return new Response(stream, {
|
|
199
|
+
status: 200,
|
|
200
|
+
headers: {
|
|
201
|
+
'Content-Type': 'text/event-stream',
|
|
202
|
+
'Cache-Control': 'no-cache',
|
|
203
|
+
'Connection': 'keep-alive',
|
|
204
|
+
'X-Accel-Buffering': 'no'
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── POST /messages?sessionId=xxx — MCP SSE Transport: receive JSON-RPC, respond via SSE
|
|
210
|
+
if (url.pathname === '/messages' && req.method === 'POST') {
|
|
211
|
+
const sessionId = url.searchParams.get('sessionId')
|
|
212
|
+
|
|
213
|
+
if (!sessionId || !sessions.has(sessionId)) {
|
|
214
|
+
return Response.json(
|
|
215
|
+
{ error: 'Invalid or missing sessionId' },
|
|
216
|
+
{ status: 400 }
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const session = sessions.get(sessionId)!
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const body = await req.json() as MCPRequest
|
|
224
|
+
|
|
225
|
+
const result = await handleRequest(body)
|
|
226
|
+
|
|
227
|
+
// Send response via SSE stream (MCP SSE spec)
|
|
228
|
+
if (result) {
|
|
229
|
+
session.send(`event: message\ndata: ${JSON.stringify(result)}\n\n`)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Return 202 Accepted (MCP SSE spec: POST returns 202, response goes via SSE)
|
|
233
|
+
return new Response(null, { status: 202 })
|
|
234
|
+
} catch (e: any) {
|
|
235
|
+
return Response.json(
|
|
236
|
+
{ jsonrpc: '2.0', error: { code: -32700, message: `Parse error: ${e.message}` } },
|
|
237
|
+
{ status: 400 }
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── GET /health
|
|
243
|
+
if (url.pathname === '/health' && req.method === 'GET') {
|
|
244
|
+
return Response.json({
|
|
245
|
+
status: 'ok',
|
|
246
|
+
tools: allTools.length,
|
|
247
|
+
vault_tools: vaultTools.length,
|
|
248
|
+
amplify_tools: amplifyTools.length,
|
|
249
|
+
sse_sessions: sessions.size,
|
|
250
|
+
uptime: process.uptime()
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── POST /configure — set Amplify API key
|
|
255
|
+
if (url.pathname === '/configure' && req.method === 'POST') {
|
|
256
|
+
try {
|
|
257
|
+
const { apiKey } = await req.json() as { apiKey: string }
|
|
258
|
+
if (!apiKey) throw new Error('apiKey required')
|
|
259
|
+
configureAmplify(apiKey)
|
|
260
|
+
return Response.json({ status: 'configured' })
|
|
261
|
+
} catch (e: any) {
|
|
262
|
+
return Response.json({ error: e.message }, { status: 400 })
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── 404
|
|
267
|
+
return Response.json({ error: 'Not found' }, { status: 404 })
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// ─── Startup ─────────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
if (TRANSPORT === 'stdio') {
|
|
274
|
+
console.error('[MCP] Running in stdio mode (stdin/stdout JSON-RPC)')
|
|
275
|
+
await handleStdioTransport()
|
|
276
|
+
process.exit(0)
|
|
277
|
+
} else {
|
|
278
|
+
console.log(`
|
|
279
|
+
╔══════════════════════════════════════════════════════╗
|
|
280
|
+
║ Research Vault MCP Server — MCP SSE Transport ║
|
|
281
|
+
╠══════════════════════════════════════════════════════╣
|
|
282
|
+
║ SSE: http://${HOST}:${PORT}/sse ║
|
|
283
|
+
║ Messages: http://${HOST}:${PORT}/messages ║
|
|
284
|
+
║ Health: http://${HOST}:${PORT}/health ║
|
|
285
|
+
╠══════════════════════════════════════════════════════╣
|
|
286
|
+
║ Tools: ${String(allTools.length).padEnd(3)} (${vaultTools.length} vault, ${amplifyTools.length} amplify) ║
|
|
287
|
+
╚══════════════════════════════════════════════════════╝
|
|
288
|
+
`)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─── Graceful Shutdown ───────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
process.on('SIGINT', () => {
|
|
294
|
+
console.log('\nShutting down...')
|
|
295
|
+
for (const [id, session] of sessions) {
|
|
296
|
+
clearInterval(session.heartbeat)
|
|
297
|
+
}
|
|
298
|
+
sessions.clear()
|
|
299
|
+
server.stop()
|
|
300
|
+
process.exit(0)
|
|
301
|
+
})
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// packages/research-vault-mcp/src/types.ts
|
|
2
|
+
|
|
3
|
+
export interface VaultEntry {
|
|
4
|
+
id: string
|
|
5
|
+
title: string
|
|
6
|
+
category: string
|
|
7
|
+
path: string
|
|
8
|
+
modified: string
|
|
9
|
+
size: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DecayScore {
|
|
13
|
+
itemId: string
|
|
14
|
+
score: number
|
|
15
|
+
lastAccess: string
|
|
16
|
+
accessCount: number
|
|
17
|
+
summaryLevel: 'deep' | 'shallow' | 'none'
|
|
18
|
+
nextReviewAt: string
|
|
19
|
+
difficulty: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Ingest Job Types ───────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export type IngestStatus = 'queued' | 'fetching' | 'parsing' | 'done' | 'failed'
|
|
25
|
+
|
|
26
|
+
export interface IngestJob {
|
|
27
|
+
jobId: string
|
|
28
|
+
source: 'url' | 'file' | 'arxiv'
|
|
29
|
+
value: string
|
|
30
|
+
category: string
|
|
31
|
+
status: IngestStatus
|
|
32
|
+
rawPath: string | null
|
|
33
|
+
metadata: ArxivMetadata | null
|
|
34
|
+
error?: string
|
|
35
|
+
createdAt: string
|
|
36
|
+
updatedAt: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ArxivMetadata {
|
|
40
|
+
title: string | null
|
|
41
|
+
authors: string[] | null
|
|
42
|
+
abstract: string | null
|
|
43
|
+
arxivId: string | null
|
|
44
|
+
categories: string[] | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Tool Input/Output Types ───────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export interface RawIngestInput {
|
|
50
|
+
source: 'url' | 'file' | 'arxiv'
|
|
51
|
+
value: string
|
|
52
|
+
category?: string // defaults to "inbox"
|
|
53
|
+
priority?: 'high' | 'low'
|
|
54
|
+
arxivMetadata?: boolean // ArXiv only: prefetch metadata before storing, default true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface NoteSaveInput {
|
|
58
|
+
title: string
|
|
59
|
+
content: string
|
|
60
|
+
category: string
|
|
61
|
+
tags?: string[]
|
|
62
|
+
summaryLevel?: 'deep' | 'shallow' | 'none'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface VaultGetInput {
|
|
66
|
+
id?: string
|
|
67
|
+
path?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface VaultDeleteInput {
|
|
71
|
+
id?: string
|
|
72
|
+
path?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Checksum Types ────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export type ChecksumStore = Record<string, { sha256: string; writtenAt: string }>
|
package/src/vault.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Research Vault MCP Tools
|
|
2
|
+
// Resolves vault root via env override, else defaults to the actual data location.
|
|
3
|
+
// After Phase 07 T3, CCR/research-vault is a submodule of ds-research-vault.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'fs'
|
|
6
|
+
import { join, basename } from 'path'
|
|
7
|
+
import { homedir } from 'os'
|
|
8
|
+
|
|
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
|
|
24
|
+
}
|
|
25
|
+
|
|
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
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
function normalizeId(raw: string): string {
|
|
39
|
+
return raw
|
|
40
|
+
.replace(/^\d{8}--?\d{4}-/, '')
|
|
41
|
+
.replace(/^(\d{10,})--?/, '')
|
|
42
|
+
.replace(/\.md$/, '')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function loadDecayScores(): DecayScore[] {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(DECAY_PATH, 'utf-8'))
|
|
48
|
+
} catch {
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function loadTaxonomy(): string {
|
|
54
|
+
try {
|
|
55
|
+
return readFileSync(TAXONOMY_PATH, 'utf-8')
|
|
56
|
+
} catch {
|
|
57
|
+
return ''
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function loadFileMeta(filePath: string): { title: string; modified: string; size: number } {
|
|
62
|
+
try {
|
|
63
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
64
|
+
const lines = content.split('\n')
|
|
65
|
+
let title = ''
|
|
66
|
+
for (const line of lines.slice(0, 30)) {
|
|
67
|
+
const m = line.match(/^#\s+(.+)/)
|
|
68
|
+
if (m) { title = m[1]; break }
|
|
69
|
+
}
|
|
70
|
+
const s = statSync(filePath)
|
|
71
|
+
return {
|
|
72
|
+
title: title || normalizeId(basename(filePath)),
|
|
73
|
+
modified: s.mtime.toISOString(),
|
|
74
|
+
size: s.size
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
return { title: normalizeId(basename(filePath)), modified: '', size: 0 }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function scanKnowledge(): VaultEntry[] {
|
|
82
|
+
const entries: VaultEntry[] = []
|
|
83
|
+
if (!existsSync(KNOWLEDGE_DIR)) return entries
|
|
84
|
+
|
|
85
|
+
const categories = readdirSync(KNOWLEDGE_DIR)
|
|
86
|
+
for (const cat of categories) {
|
|
87
|
+
if (cat.startsWith('_')) continue
|
|
88
|
+
const catPath = join(KNOWLEDGE_DIR, cat)
|
|
89
|
+
if (!existsSync(catPath) || !statSync(catPath).isDirectory()) continue
|
|
90
|
+
|
|
91
|
+
const subEntries = readdirSync(catPath)
|
|
92
|
+
for (const sub of subEntries) {
|
|
93
|
+
const subPath = join(catPath, sub)
|
|
94
|
+
const subStat = statSync(subPath)
|
|
95
|
+
|
|
96
|
+
if (subStat.isDirectory()) {
|
|
97
|
+
const files = readdirSync(subPath).filter(f => f.endsWith('.md'))
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
const fp = join(subPath, file)
|
|
100
|
+
const meta = loadFileMeta(fp)
|
|
101
|
+
entries.push({
|
|
102
|
+
id: normalizeId(file),
|
|
103
|
+
title: meta.title,
|
|
104
|
+
category: `${cat}/${sub}`,
|
|
105
|
+
path: fp,
|
|
106
|
+
modified: meta.modified,
|
|
107
|
+
size: meta.size
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
} else if (sub.endsWith('.md')) {
|
|
111
|
+
const meta = loadFileMeta(subPath)
|
|
112
|
+
entries.push({
|
|
113
|
+
id: normalizeId(sub),
|
|
114
|
+
title: meta.title,
|
|
115
|
+
category: cat,
|
|
116
|
+
path: subPath,
|
|
117
|
+
modified: meta.modified,
|
|
118
|
+
size: meta.size
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return entries
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function scanRaw(): string[] {
|
|
127
|
+
const pending: string[] = []
|
|
128
|
+
if (!existsSync(RAW_DIR)) return pending
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const entries = readdirSync(RAW_DIR)
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (entry === '_inbox') {
|
|
134
|
+
const inbox = join(RAW_DIR, entry)
|
|
135
|
+
if (existsSync(inbox)) {
|
|
136
|
+
pending.push(...readdirSync(inbox).filter(f => /\.(md|pdf|txt)$/.test(f)))
|
|
137
|
+
}
|
|
138
|
+
} else if (/^\d{4}-\d{2}$/.test(entry)) {
|
|
139
|
+
const monthDir = join(RAW_DIR, entry)
|
|
140
|
+
if (existsSync(monthDir)) {
|
|
141
|
+
pending.push(
|
|
142
|
+
...readdirSync(monthDir)
|
|
143
|
+
.filter(f => /\.(md|pdf|txt)$/.test(f))
|
|
144
|
+
.map(f => `${entry}/${f}`)
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch {}
|
|
150
|
+
|
|
151
|
+
return pending
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── MCP Tools ───────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
const vaultTools = [
|
|
157
|
+
{
|
|
158
|
+
name: 'vault_search',
|
|
159
|
+
description: 'Search the Research Vault knowledge base. Returns analyzed papers with retention scores.',
|
|
160
|
+
inputSchema: {
|
|
161
|
+
type: 'object',
|
|
162
|
+
properties: {
|
|
163
|
+
query: { type: 'string', description: 'Search query (matches title, category)' },
|
|
164
|
+
category: { type: 'string', description: 'Filter by category (e.g., "ai-agents/benchmarking")' },
|
|
165
|
+
limit: { type: 'number', description: 'Max results (default 10)' }
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
call: async ({ query, category, limit = 10 }: { query?: string; category?: string; limit?: number }) => {
|
|
169
|
+
let items = scanKnowledge()
|
|
170
|
+
const scores = loadDecayScores()
|
|
171
|
+
const scoreMap = new Map(scores.map(s => [normalizeId(s.itemId), s]))
|
|
172
|
+
|
|
173
|
+
if (category) {
|
|
174
|
+
items = items.filter(item =>
|
|
175
|
+
item.category === category || item.category.startsWith(category + '/')
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
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
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
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)
|
|
191
|
+
return {
|
|
192
|
+
id: item.id,
|
|
193
|
+
title: item.title,
|
|
194
|
+
category: item.category,
|
|
195
|
+
score: score?.score ?? null,
|
|
196
|
+
summaryLevel: score?.summaryLevel ?? null,
|
|
197
|
+
nextReview: score?.nextReviewAt ?? null,
|
|
198
|
+
accessCount: score?.accessCount ?? 0,
|
|
199
|
+
modified: item.modified
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
content: [{
|
|
205
|
+
type: 'text',
|
|
206
|
+
text: JSON.stringify({ query, category, results, total: results.length }, null, 2)
|
|
207
|
+
}]
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
{
|
|
213
|
+
name: 'vault_status',
|
|
214
|
+
description: 'Get Research Vault health — item counts by decay level, top/bottom retention.',
|
|
215
|
+
inputSchema: { type: 'object', properties: {} },
|
|
216
|
+
call: async () => {
|
|
217
|
+
const scores = loadDecayScores()
|
|
218
|
+
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)
|
|
223
|
+
|
|
224
|
+
const top5 = sorted.slice(0, 5).map(s => {
|
|
225
|
+
const sid = s.itemId.replace(/--/g, '-')
|
|
226
|
+
const entry = entries.find(e => normalizeId(e.id) === normalizeId(s.itemId) || normalizeId(e.id) === normalizeId(sid))
|
|
227
|
+
return { itemId: s.itemId, score: s.score, accesses: s.accessCount, title: entry?.title || s.itemId }
|
|
228
|
+
})
|
|
229
|
+
const bottom5 = sorted.slice(-5).reverse().map(s => {
|
|
230
|
+
const sid = s.itemId.replace(/--/g, '-')
|
|
231
|
+
const entry = entries.find(e => normalizeId(e.id) === normalizeId(s.itemId) || normalizeId(e.id) === normalizeId(sid))
|
|
232
|
+
return { itemId: s.itemId, score: s.score, lastAccess: s.lastAccess.slice(0, 10), title: entry?.title || s.itemId }
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const pending = scanRaw()
|
|
236
|
+
return {
|
|
237
|
+
content: [{
|
|
238
|
+
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)
|
|
249
|
+
}]
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
{
|
|
255
|
+
name: 'vault_batch_analyze',
|
|
256
|
+
description: 'Check batch analyze status and pending papers in the raw queue.',
|
|
257
|
+
inputSchema: {
|
|
258
|
+
type: 'object',
|
|
259
|
+
properties: {
|
|
260
|
+
count: { type: 'number', description: 'Preview N papers (default 5)' }
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
call: async ({ count = 5 }: { count?: number } = {}) => {
|
|
264
|
+
const pending = scanRaw()
|
|
265
|
+
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
|
+
})
|
|
271
|
+
|
|
272
|
+
if (unanalyzed.length === 0) {
|
|
273
|
+
return { content: [{ type: 'text', text: JSON.stringify({ message: 'Queue empty — all papers analyzed', analyzed: entries.length }) }] }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
content: [{
|
|
278
|
+
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)
|
|
285
|
+
}]
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
{
|
|
291
|
+
name: 'vault_taxonomy',
|
|
292
|
+
description: 'Get the Research Vault taxonomy — all categories and counts.',
|
|
293
|
+
inputSchema: { type: 'object', properties: {} },
|
|
294
|
+
call: async () => {
|
|
295
|
+
const taxonomy = loadTaxonomy()
|
|
296
|
+
const entries = scanKnowledge()
|
|
297
|
+
const catCounts: Record<string, number> = {}
|
|
298
|
+
for (const e of entries) catCounts[e.category] = (catCounts[e.category] || 0) + 1
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
content: [{
|
|
302
|
+
type: 'text',
|
|
303
|
+
text: JSON.stringify({ taxonomy, categories: catCounts }, null, 2)
|
|
304
|
+
}]
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
export { vaultTools, scanKnowledge, scanRaw, loadDecayScores, normalizeId }
|