@syndash/research-vault-mcp 0.2.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 ADDED
@@ -0,0 +1,143 @@
1
+ # research-vault MCP server
2
+
3
+ MCP server exposing a local research-vault checkout to any MCP client over **stdio** or **Streamable HTTP**.
4
+
5
+ Wave 2D — see `docs/superpowers/plans/2026-04-19-wave2d-research-vault-mcp.md`.
6
+
7
+ ## Architecture
8
+
9
+ MCP client → this server → local `VAULT_ROOT` checkout → optional BGE-M3-compatible embedding endpoint.
10
+
11
+ Phase 1: substring search over `.meta/registry.jsonl`, exact-ID read via `vault_get`, plus `vault_status` and `vault_taxonomy`. Phase 2: real BGE-M3 cosine over `.meta/embeddings.jsonl`.
12
+
13
+ ## Tools (v0)
14
+
15
+ | Tool | Args | Returns |
16
+ |------------------|--------------------------------------------|--------------------------------------------------------|
17
+ | `vault_search` | `query` (str), `top_k?` (1-50), `mode?` | Ranked hits with separated readability/index/analysis verdicts |
18
+ | `vault_get` | `id` (exact str), `include_content?` | Authoritative exact-ID metadata/content read |
19
+ | `vault_status` | — | Registry counts, decay summary, last maintenance run |
20
+ | `vault_taxonomy` | — | `knowledge/_taxonomy.md` verbatim |
21
+
22
+ `mode` defaults to `substring`; `embedding` is wired in Phase 2.
23
+
24
+ `vault_search` reports `verdict`, `readability_verdict`, `index_verdict`, and `analysis_verdict` separately. A readable raw note that has no `lastAnalyzedAt` returns `verdict: "PASS"` with `analysis_verdict: "NOT_ANALYZED"`; that means content is readable but has not been analyzed yet.
25
+
26
+ ## Search And Read Semantics
27
+
28
+ `vault_search` is an index/search surface. It answers "did this query match registry metadata, and is the matched content readable?"
29
+
30
+ Matching covers:
31
+
32
+ - exact entry IDs, with exact ID hits marked as `matched: ["id_exact"]`
33
+ - titles, categories, tags, and source URLs
34
+ - punctuation-normalized text, so `Music/Rhythm` can be found with `music rhythm`, and `software-engineering/game-design` can be found with `software engineering game design`
35
+
36
+ The top-level `verdict` is about search/read usability, not analysis freshness:
37
+
38
+ ```json
39
+ {
40
+ "verdict": "PASS",
41
+ "readability_verdict": "PASS",
42
+ "index_verdict": "PASS",
43
+ "analysis_verdict": "NOT_ANALYZED"
44
+ }
45
+ ```
46
+
47
+ That shape means the result matched and the file is readable. It does not mean the entry has been analyzed. Missing `lastAnalyzedAt` is reported as `NOT_ANALYZED`, not as missing or broken content.
48
+
49
+ `vault_get` is the authoritative exact-ID read path. It requires an exact `id`, reads `knowledgePath` first when present, and falls back to `rawPath` when the knowledge file is absent. Use `include_content: true` when the client needs the markdown body:
50
+
51
+ ```json
52
+ {
53
+ "id": "20260608-readable-raw-note",
54
+ "include_content": true
55
+ }
56
+ ```
57
+
58
+ ## Install From npm
59
+
60
+ ```bash
61
+ npm install -g @syndash/research-vault-mcp
62
+ ```
63
+
64
+ This package runs on Bun. Keep `VAULT_ROOT` pointed at your local vault checkout.
65
+
66
+ ## Install From Source
67
+
68
+ ```bash
69
+ cd mcp
70
+ bun install
71
+ ```
72
+
73
+ ## Run — stdio
74
+
75
+ ```bash
76
+ VAULT_ROOT=/path/to/research-vault research-vault-mcp
77
+ ```
78
+
79
+ From a source checkout:
80
+
81
+ ```bash
82
+ VAULT_ROOT=/path/to/research-vault bun run dev
83
+ ```
84
+
85
+ ## Run — HTTP
86
+
87
+ ```bash
88
+ VAULT_ROOT=/path/to/research-vault MCP_MODE=http MCP_HOST=127.0.0.1 MCP_PORT=8765 research-vault-mcp
89
+ curl http://127.0.0.1:8765/health
90
+ ```
91
+
92
+ Bind `MCP_HOST=0.0.0.0` only behind private network/auth controls.
93
+
94
+ ## Inspect (verify handshake + tool listing)
95
+
96
+ ```bash
97
+ bun run inspect
98
+ # = bunx @modelcontextprotocol/inspector bun run server.ts
99
+ ```
100
+
101
+ Then in the Inspector UI: connect → list tools → call each one.
102
+
103
+ ## Environment
104
+
105
+ | Var | Default | Notes |
106
+ |-------------------|----------------------------------|--------------------------------------------------------|
107
+ | `MCP_MODE` | `stdio` | `stdio` \| `http` |
108
+ | `MCP_HOST` | `127.0.0.1` | Use `0.0.0.0` only behind private access controls |
109
+ | `MCP_PORT` | `8765` | HTTP mode port |
110
+ | `VAULT_ROOT` | parent dir of `mcp/` | Absolute path to the vault checkout |
111
+ | `EMBED_ENDPOINT` | `http://127.0.0.1:8080` | Optional BGE-M3-compatible embedding endpoint |
112
+
113
+ ## Client Configuration
114
+
115
+ Use your actual private MCP URL in your local client config:
116
+
117
+ ```yaml
118
+ mcp_servers:
119
+ research_vault:
120
+ url: http://127.0.0.1:8765/mcp
121
+ timeout: 60
122
+ connect_timeout: 30
123
+ ```
124
+
125
+ Tool names will appear as `mcp_research_vault_vault_search` etc.
126
+
127
+ ## Publishing OPSEC
128
+
129
+ The npm package is intentionally allowlisted to runtime files only: `README.md`, `server.ts`, `types.ts`, and `tsconfig.json`.
130
+
131
+ Before publishing, run:
132
+
133
+ ```bash
134
+ npm pack --dry-run
135
+ ```
136
+
137
+ Do not publish vault content, `.meta`, private hostnames, private IPs, service-token material, local absolute paths, or operator-specific deployment notes.
138
+
139
+ ## Non-goals (explicit)
140
+
141
+ - No public internet exposure by default
142
+ - No bundled vault content or registry data
143
+ - No multi-tenant / multi-vault — one server, one `VAULT_ROOT`
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@syndash/research-vault-mcp",
3
+ "version": "0.2.0",
4
+ "description": "MCP server exposing local research-vault search and exact-ID reads",
5
+ "type": "module",
6
+ "bin": {
7
+ "research-vault-mcp": "server.ts"
8
+ },
9
+ "files": [
10
+ "README.md",
11
+ "server.ts",
12
+ "types.ts",
13
+ "tsconfig.json"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/Fearvox/ds-research-vault.git",
18
+ "directory": "mcp"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/Fearvox/ds-research-vault/issues"
22
+ },
23
+ "homepage": "https://github.com/Fearvox/ds-research-vault/tree/main/mcp#readme",
24
+ "license": "UNLICENSED",
25
+ "engines": {
26
+ "bun": ">=1.3.0"
27
+ },
28
+ "scripts": {
29
+ "start": "bun ./server.ts",
30
+ "dev": "MCP_MODE=stdio bun run server.ts",
31
+ "http": "MCP_MODE=http MCP_HOST=127.0.0.1 MCP_PORT=8765 bun run server.ts",
32
+ "inspect": "bunx @modelcontextprotocol/inspector bun run server.ts",
33
+ "test": "bun test server.test.ts",
34
+ "typecheck": "bunx tsc --noEmit -p tsconfig.json",
35
+ "pack:dry": "npm pack --dry-run",
36
+ "prepublishOnly": "bun run typecheck && bun test server.test.ts && npm pack --dry-run"
37
+ },
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.29.0",
40
+ "zod": "^3.23.8"
41
+ },
42
+ "devDependencies": {
43
+ "@types/bun": "latest",
44
+ "typescript": "^6.0.3"
45
+ }
46
+ }
package/server.ts ADDED
@@ -0,0 +1,913 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * research-vault MCP server
4
+ *
5
+ * Exposes semantic + metadata access to the research-vault knowledge repo
6
+ * over MCP (stdio for local clients, Streamable HTTP for private deployments).
7
+ *
8
+ * v0 tools (Phase 1):
9
+ * - vault_search → substring match over registry.jsonl (titles, tags, category)
10
+ * Phase 2 upgrade: BGE-M3 query embedding + cosine over .meta/embeddings.jsonl
11
+ * - vault_status → registry counts + decay summary + last maintenance run
12
+ * - vault_taxonomy → returns knowledge/_taxonomy.md verbatim
13
+ *
14
+ * v1 write tools (DAS-808 — write upgrade):
15
+ * - vault_store → create a new vault entry (registry + knowledge file)
16
+ * - vault_write → update an existing vault entry's metadata or content
17
+ *
18
+ * Env:
19
+ * MCP_MODE stdio | http (default: stdio)
20
+ * MCP_HOST host for http mode (default: 127.0.0.1; use 0.0.0.0 only behind private access controls)
21
+ * MCP_PORT port for http mode (default: 8765)
22
+ * VAULT_ROOT absolute path to research-vault root
23
+ * (default: parent of this file — works when running `bun run server.ts` from mcp/)
24
+ * EMBED_ENDPOINT BGE-M3 OpenAI-compatible embeddings URL
25
+ * (default: http://127.0.0.1:8080)
26
+ * MCP_READONLY if "1" / "true", do not register vault_store / vault_write
27
+ * (default: off; set on remote-agent hosts that consume but must not mutate)
28
+ */
29
+
30
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
31
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
32
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
33
+ import { z } from 'zod'
34
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, appendFileSync, statSync } from 'node:fs'
35
+ import { join, resolve, dirname, basename } from 'node:path'
36
+ import { fileURLToPath } from 'node:url'
37
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
38
+ import { createHash, randomUUID } from 'node:crypto'
39
+ import type { RawItem, DecayScore } from './types.ts'
40
+
41
+ // --------------------------------------------------------------------------
42
+ // Paths + config
43
+ // --------------------------------------------------------------------------
44
+
45
+ const HERE = dirname(fileURLToPath(import.meta.url))
46
+ const VAULT_ROOT = resolve(process.env.VAULT_ROOT ?? join(HERE, '..'))
47
+ const META_DIR = join(VAULT_ROOT, '.meta')
48
+ const REGISTRY_PATH = join(META_DIR, 'registry.jsonl')
49
+ const DECAY_PATH = join(META_DIR, 'decay-scores.json')
50
+ const MAINT_PATH = join(META_DIR, 'last-maintenance.json')
51
+ const TAXONOMY_PATH = join(VAULT_ROOT, 'knowledge', '_taxonomy.md')
52
+
53
+ const MODE = (process.env.MCP_MODE ?? 'stdio').toLowerCase()
54
+ const HOST = process.env.MCP_HOST ?? '127.0.0.1'
55
+ const PORT = Number(process.env.MCP_PORT ?? 8765)
56
+ const EMBED_ENDPOINT = process.env.EMBED_ENDPOINT ?? 'http://127.0.0.1:8080'
57
+ const READONLY = process.env.MCP_READONLY === '1' || process.env.MCP_READONLY === 'true'
58
+ const PUBLIC_BASE_URL =
59
+ (process.env.MCP_PUBLIC_BASE_URL ?? process.env.PUBLIC_BASE_URL ?? `http://${HOST}:${PORT}`).replace(/\/+$/, '')
60
+ const MCP_ENDPOINT_URL = `${PUBLIC_BASE_URL}/mcp`
61
+
62
+ // --------------------------------------------------------------------------
63
+ // Vault readers (cheap — O(registry size), called per tool invocation; no cache)
64
+ // --------------------------------------------------------------------------
65
+
66
+ function readRegistry(): RawItem[] {
67
+ if (!existsSync(REGISTRY_PATH)) return []
68
+ const raw = readFileSync(REGISTRY_PATH, 'utf-8').trim()
69
+ if (!raw) return []
70
+ return raw.split('\n').map((line) => JSON.parse(line) as RawItem)
71
+ }
72
+
73
+ function readDecayScores(): DecayScore[] {
74
+ if (!existsSync(DECAY_PATH)) return []
75
+ return JSON.parse(readFileSync(DECAY_PATH, 'utf-8')) as DecayScore[]
76
+ }
77
+
78
+ function readLastMaintenance(): { last_run: string | null; items_processed: number } {
79
+ if (!existsSync(MAINT_PATH)) return { last_run: null, items_processed: 0 }
80
+ return JSON.parse(readFileSync(MAINT_PATH, 'utf-8'))
81
+ }
82
+
83
+ function readTaxonomy(): string {
84
+ if (!existsSync(TAXONOMY_PATH)) return '(taxonomy file missing)'
85
+ return readFileSync(TAXONOMY_PATH, 'utf-8')
86
+ }
87
+
88
+ // --------------------------------------------------------------------------
89
+ // Vault writers (v1 — DAS-808 write upgrade)
90
+ // --------------------------------------------------------------------------
91
+
92
+ function slugify(text: string): string {
93
+ return text
94
+ .toLowerCase()
95
+ .replace(/[^a-z0-9]+/g, '-')
96
+ .replace(/^-+|-+$/g, '')
97
+ .slice(0, 80)
98
+ }
99
+
100
+ function generateEntryId(title: string): string {
101
+ const date = new Date().toISOString().slice(0, 10).replace(/-/g, '')
102
+ const slug = slugify(title)
103
+ return `${date}-${slug}`
104
+ }
105
+
106
+ interface WriteStoreResult {
107
+ entry: RawItem
108
+ knowledgePath: string
109
+ rawPath: string
110
+ }
111
+
112
+ function writeStoreEntry(args: {
113
+ title: string
114
+ content: string
115
+ category?: string
116
+ tags?: string[]
117
+ sourceUrl?: string
118
+ }): WriteStoreResult {
119
+ const id = generateEntryId(args.title)
120
+ const now = new Date().toISOString()
121
+ const yearMonth = now.slice(0, 7) // YYYY-MM
122
+
123
+ // Determine paths
124
+ const catDir = args.category ? args.category.replace(/\/+/g, '/').replace(/^\/|\/$/g, '') : ''
125
+ const knowledgeDir = catDir ? join(VAULT_ROOT, 'knowledge', catDir) : join(VAULT_ROOT, 'knowledge')
126
+ const rawDir = join(VAULT_ROOT, 'raw', yearMonth)
127
+ const knowledgeFile = join(knowledgeDir, `${id}.md`)
128
+ const rawFile = join(rawDir, `${id}.md`)
129
+
130
+ // Create directories
131
+ mkdirSync(knowledgeDir, { recursive: true })
132
+ mkdirSync(rawDir, { recursive: true })
133
+
134
+ // Write content files
135
+ const header = `# ${args.title}\n\n`
136
+ const sourceLine = args.sourceUrl ? `> Source: ${args.sourceUrl}\n\n` : ''
137
+ const body = header + sourceLine + args.content
138
+
139
+ writeFileSync(knowledgeFile, body, 'utf-8')
140
+ writeFileSync(rawFile, body, 'utf-8')
141
+
142
+ // Build registry entry
143
+ const entry: RawItem = {
144
+ id,
145
+ title: args.title,
146
+ source: args.sourceUrl ? 'url' : 'local',
147
+ sourceUrl: args.sourceUrl,
148
+ rawPath: `raw/${yearMonth}/${id}.md`,
149
+ ingestedAt: now,
150
+ status: 'raw',
151
+ tags: args.tags ?? [],
152
+ category: args.category,
153
+ knowledgePath: catDir ? `knowledge/${catDir}/${id}.md` : `knowledge/${id}.md`,
154
+ }
155
+
156
+ // Append to registry
157
+ appendFileSync(REGISTRY_PATH, JSON.stringify(entry) + '\n', 'utf-8')
158
+
159
+ return { entry, knowledgePath: knowledgeFile, rawPath: rawFile }
160
+ }
161
+
162
+ interface WriteUpdateResult {
163
+ entry: RawItem
164
+ previous: RawItem
165
+ knowledgePath?: string
166
+ }
167
+
168
+ function writeUpdateEntry(args: {
169
+ id: string
170
+ title?: string
171
+ content?: string
172
+ category?: string
173
+ tags?: string[]
174
+ status?: RawItem['status']
175
+ sourceUrl?: string
176
+ }): WriteUpdateResult {
177
+ const registry = readRegistry()
178
+ const idx = registry.findIndex((item) => item.id === args.id)
179
+ if (idx === -1) {
180
+ throw new Error(`Entry not found: ${args.id}`)
181
+ }
182
+
183
+ const previous = { ...registry[idx], tags: [...registry[idx].tags] }
184
+ const updated = registry[idx]
185
+
186
+ // Update scalar fields
187
+ if (args.title !== undefined) updated.title = args.title
188
+ if (args.category !== undefined) updated.category = args.category
189
+ if (args.tags !== undefined) updated.tags = args.tags
190
+ if (args.status !== undefined) updated.status = args.status
191
+ if (args.sourceUrl !== undefined) {
192
+ updated.sourceUrl = args.sourceUrl
193
+ updated.source = 'url'
194
+ }
195
+
196
+ // Update content if provided
197
+ let knowledgePath: string | undefined
198
+ if (args.content !== undefined && updated.knowledgePath) {
199
+ const kp = join(VAULT_ROOT, updated.knowledgePath)
200
+ const header = `# ${updated.title}\n\n`
201
+ const sourceLine = updated.sourceUrl ? `> Source: ${updated.sourceUrl}\n\n` : ''
202
+ writeFileSync(kp, header + sourceLine + args.content, 'utf-8')
203
+ knowledgePath = kp
204
+ }
205
+
206
+ // Handle category change — move knowledge file
207
+ if (args.category !== undefined && args.category !== previous.category) {
208
+ const oldPath = previous.knowledgePath ? join(VAULT_ROOT, previous.knowledgePath) : null
209
+ const newCatDir = args.category.replace(/\/+/g, '/').replace(/^\/|\/$/g, '')
210
+ const newDir = join(VAULT_ROOT, 'knowledge', newCatDir)
211
+ const newPath = join(newDir, `${args.id}.md`)
212
+ const newRelPath = `knowledge/${newCatDir}/${args.id}.md`
213
+
214
+ if (oldPath && existsSync(oldPath)) {
215
+ mkdirSync(newDir, { recursive: true })
216
+ writeFileSync(newPath, readFileSync(oldPath, 'utf-8'), 'utf-8')
217
+ // Note: old file is left in place to avoid data loss; manual cleanup expected
218
+ }
219
+ updated.knowledgePath = newRelPath
220
+ }
221
+
222
+ // Rewrite registry
223
+ registry[idx] = updated
224
+ writeFileSync(REGISTRY_PATH, registry.map((item) => JSON.stringify(item)).join('\n') + '\n', 'utf-8')
225
+
226
+ return { entry: updated, previous, knowledgePath }
227
+ }
228
+
229
+ // --------------------------------------------------------------------------
230
+ // Search (v0: substring match; v1: BGE-M3 cosine)
231
+ // --------------------------------------------------------------------------
232
+
233
+ interface SearchHit {
234
+ id: string
235
+ title: string
236
+ category?: string
237
+ tags: string[]
238
+ status: RawItem['status']
239
+ knowledgePath?: string
240
+ rawPath?: string
241
+ score: number
242
+ matched: string[] // which fields matched
243
+ }
244
+
245
+ type GlobalVerdict = 'PASS' | 'FLAG'
246
+ type ReadabilityVerdict = 'PASS' | 'MISSING'
247
+ type IndexVerdict = 'PASS' | 'NO_MATCH'
248
+ type AnalysisVerdict = 'PASS' | 'NOT_ANALYZED'
249
+
250
+ interface ReadableContent {
251
+ readability_verdict: ReadabilityVerdict
252
+ read_path?: string
253
+ read_source?: 'knowledge' | 'raw'
254
+ source_mtime?: string
255
+ content_hash?: string
256
+ content?: string
257
+ }
258
+
259
+ interface SearchVerdicts {
260
+ verdict: GlobalVerdict
261
+ readability_verdict: ReadabilityVerdict
262
+ index_verdict: IndexVerdict
263
+ analysis_verdict: AnalysisVerdict
264
+ analysis_caveat?: string
265
+ }
266
+
267
+ interface DecoratedSearchHit extends SearchHit, SearchVerdicts {
268
+ sourceMtime?: string
269
+ contentHash?: string
270
+ lastIndexedAt?: string
271
+ lastAnalyzedAt?: string
272
+ analysisVersion?: string
273
+ read_path?: string
274
+ read_source?: 'knowledge' | 'raw'
275
+ }
276
+
277
+ interface SearchResponse extends SearchVerdicts {
278
+ query: string
279
+ backend: 'substring' | 'embedding'
280
+ count: number
281
+ hits: DecoratedSearchHit[]
282
+ }
283
+
284
+ function normalizeSearchText(text: string): string {
285
+ return text
286
+ .toLowerCase()
287
+ .replace(/[^a-z0-9]+/g, ' ')
288
+ .replace(/\s+/g, ' ')
289
+ .trim()
290
+ }
291
+
292
+ function matchesQuery(value: string | undefined, query: string): boolean {
293
+ if (!value) return false
294
+ const rawValue = value.toLowerCase()
295
+ const rawQuery = query.toLowerCase().trim()
296
+ if (rawValue.includes(rawQuery)) return true
297
+ const normalizedValue = normalizeSearchText(value)
298
+ const normalizedQuery = normalizeSearchText(query)
299
+ return normalizedQuery.length > 0 && normalizedValue.includes(normalizedQuery)
300
+ }
301
+
302
+ export function substringSearch(items: RawItem[], query: string, topK: number): SearchHit[] {
303
+ const q = query.toLowerCase().trim()
304
+ if (!q) return []
305
+ const hits: SearchHit[] = []
306
+ for (const item of items) {
307
+ const matched: string[] = []
308
+ let score = 0
309
+ if (item.id.toLowerCase() === q) { matched.push('id_exact'); score += 10 }
310
+ else if (matchesQuery(item.id, query)) { matched.push('id'); score += 4 }
311
+ if (matchesQuery(item.title, query)) { matched.push('title'); score += 3 }
312
+ if (matchesQuery(item.category, query)) { matched.push('category'); score += 2 }
313
+ if (item.tags.some((t) => matchesQuery(t, query))) { matched.push('tag'); score += 2 }
314
+ if (matchesQuery(item.sourceUrl, query)) { matched.push('source_url'); score += 1 }
315
+ if (score > 0) {
316
+ hits.push({
317
+ id: item.id,
318
+ title: item.title,
319
+ category: item.category,
320
+ tags: item.tags,
321
+ status: item.status,
322
+ knowledgePath: item.knowledgePath,
323
+ rawPath: item.rawPath,
324
+ score,
325
+ matched,
326
+ })
327
+ }
328
+ }
329
+ hits.sort((a, b) => b.score - a.score)
330
+ return hits.slice(0, topK)
331
+ }
332
+
333
+ function candidateReadPaths(item: RawItem): Array<{ source: 'knowledge' | 'raw'; path: string }> {
334
+ const candidates: Array<{ source: 'knowledge' | 'raw'; path: string }> = []
335
+ if (item.knowledgePath) candidates.push({ source: 'knowledge', path: item.knowledgePath })
336
+ candidates.push({ source: 'raw', path: item.rawPath })
337
+ return candidates
338
+ }
339
+
340
+ export function resolveReadableContent(item: RawItem, vaultRoot = VAULT_ROOT, includeContent = false): ReadableContent {
341
+ for (const candidate of candidateReadPaths(item)) {
342
+ const absPath = join(vaultRoot, candidate.path)
343
+ if (!existsSync(absPath)) continue
344
+
345
+ const content = readFileSync(absPath, 'utf-8')
346
+ const stat = statSync(absPath)
347
+ return {
348
+ readability_verdict: 'PASS',
349
+ read_path: candidate.path,
350
+ read_source: candidate.source,
351
+ source_mtime: stat.mtime.toISOString(),
352
+ content_hash: createHash('sha256').update(content).digest('hex'),
353
+ ...(includeContent ? { content } : {}),
354
+ }
355
+ }
356
+
357
+ return { readability_verdict: 'MISSING' }
358
+ }
359
+
360
+ function analysisVerdict(item: RawItem): Pick<SearchVerdicts, 'analysis_verdict' | 'analysis_caveat'> {
361
+ if (item.lastAnalyzedAt) return { analysis_verdict: 'PASS' }
362
+ return {
363
+ analysis_verdict: 'NOT_ANALYZED',
364
+ analysis_caveat: 'Readable content exists, but registry has no lastAnalyzedAt. Treat as not analyzed, not missing.',
365
+ }
366
+ }
367
+
368
+ function itemVerdicts(item: RawItem, vaultRoot = VAULT_ROOT): SearchVerdicts & ReadableContent {
369
+ const readable = resolveReadableContent(item, vaultRoot, false)
370
+ const analysis = analysisVerdict(item)
371
+ return {
372
+ verdict: readable.readability_verdict === 'PASS' ? 'PASS' : 'FLAG',
373
+ readability_verdict: readable.readability_verdict,
374
+ index_verdict: 'PASS',
375
+ ...analysis,
376
+ read_path: readable.read_path,
377
+ read_source: readable.read_source,
378
+ source_mtime: item.sourceMtime ?? readable.source_mtime,
379
+ content_hash: item.contentHash ?? readable.content_hash,
380
+ }
381
+ }
382
+
383
+ function decorateSearchHit(hit: SearchHit, item: RawItem, vaultRoot = VAULT_ROOT): DecoratedSearchHit {
384
+ const verdicts = itemVerdicts(item, vaultRoot)
385
+ return {
386
+ ...hit,
387
+ ...verdicts,
388
+ sourceMtime: item.sourceMtime ?? verdicts.source_mtime,
389
+ contentHash: item.contentHash ?? verdicts.content_hash,
390
+ lastIndexedAt: item.lastIndexedAt,
391
+ lastAnalyzedAt: item.lastAnalyzedAt,
392
+ analysisVersion: item.analysisVersion,
393
+ }
394
+ }
395
+
396
+ export function buildSearchResponse(
397
+ query: string,
398
+ backend: 'substring' | 'embedding',
399
+ hits: SearchHit[],
400
+ items: RawItem[],
401
+ vaultRoot = VAULT_ROOT
402
+ ): SearchResponse {
403
+ const byId = new Map(items.map((item) => [item.id, item]))
404
+ const decorated = hits.map((hit) => {
405
+ const item = byId.get(hit.id)
406
+ if (!item) {
407
+ return {
408
+ ...hit,
409
+ verdict: 'FLAG' as const,
410
+ readability_verdict: 'MISSING' as const,
411
+ index_verdict: 'PASS' as const,
412
+ analysis_verdict: 'NOT_ANALYZED' as const,
413
+ analysis_caveat: 'Search hit has no matching registry item.',
414
+ }
415
+ }
416
+ return decorateSearchHit(hit, item, vaultRoot)
417
+ })
418
+
419
+ const hasHits = decorated.length > 0
420
+ const readable = hasHits && decorated.every((hit) => hit.readability_verdict === 'PASS')
421
+ const allAnalyzed = hasHits && decorated.every((hit) => hit.analysis_verdict === 'PASS')
422
+ return {
423
+ query,
424
+ backend,
425
+ count: decorated.length,
426
+ verdict: hasHits && readable ? 'PASS' : 'FLAG',
427
+ readability_verdict: readable ? 'PASS' : 'MISSING',
428
+ index_verdict: hasHits ? 'PASS' : 'NO_MATCH',
429
+ analysis_verdict: allAnalyzed ? 'PASS' : 'NOT_ANALYZED',
430
+ ...(allAnalyzed ? {} : { analysis_caveat: 'One or more readable hits have no lastAnalyzedAt.' }),
431
+ hits: decorated,
432
+ }
433
+ }
434
+
435
+ export function getVaultItem(
436
+ registry: RawItem[],
437
+ id: string,
438
+ vaultRoot = VAULT_ROOT,
439
+ includeContent = false
440
+ ) {
441
+ const item = registry.find((entry) => entry.id === id)
442
+ if (!item) {
443
+ return {
444
+ found: false as const,
445
+ id,
446
+ verdict: 'FLAG' as const,
447
+ readability_verdict: 'MISSING' as const,
448
+ index_verdict: 'NO_MATCH' as const,
449
+ analysis_verdict: 'NOT_ANALYZED' as const,
450
+ error: `Entry not found: ${id}`,
451
+ }
452
+ }
453
+
454
+ const readable = resolveReadableContent(item, vaultRoot, includeContent)
455
+ const analysis = analysisVerdict(item)
456
+ return {
457
+ found: true as const,
458
+ item: {
459
+ ...item,
460
+ sourceMtime: item.sourceMtime ?? readable.source_mtime,
461
+ contentHash: item.contentHash ?? readable.content_hash,
462
+ read_path: readable.read_path,
463
+ read_source: readable.read_source,
464
+ },
465
+ verdict: readable.readability_verdict === 'PASS' ? 'PASS' as const : 'FLAG' as const,
466
+ readability_verdict: readable.readability_verdict,
467
+ index_verdict: 'PASS' as const,
468
+ ...analysis,
469
+ ...(includeContent ? { content: readable.content ?? null } : {}),
470
+ }
471
+ }
472
+
473
+ // Phase 2 hook — embed query via BGE-M3-compatible endpoint, cosine against .meta/embeddings.jsonl
474
+ // Left as a TODO stub; substringSearch ships tonight so Hermes can talk to the vault.
475
+ async function embeddingSearch(_query: string, _topK: number): Promise<SearchHit[]> {
476
+ throw new Error(
477
+ 'embeddingSearch not yet wired — Phase 2. ' +
478
+ `Will call ${EMBED_ENDPOINT}/v1/embeddings and cosine against .meta/embeddings.jsonl.`
479
+ )
480
+ }
481
+
482
+ // --------------------------------------------------------------------------
483
+ // MCP server + tools
484
+ // --------------------------------------------------------------------------
485
+
486
+ function buildServer(): McpServer {
487
+ const server = new McpServer(
488
+ { name: 'research-vault', version: '0.2.0' },
489
+ { capabilities: { tools: {} } }
490
+ )
491
+
492
+ server.registerTool(
493
+ 'vault_search',
494
+ {
495
+ title: 'Search research vault',
496
+ description:
497
+ 'Search the research-vault knowledge base. v0 uses substring match over registry (title/category/tags/source URL). ' +
498
+ 'Phase 2 will swap in BGE-M3 semantic search via the configured embedding endpoint.',
499
+ inputSchema: {
500
+ query: z.string().min(1).describe('Search query — keyword, phrase, tag, or URL fragment'),
501
+ top_k: z.number().int().min(1).max(50).optional().describe('Max results (default 10)'),
502
+ mode: z.enum(['substring', 'embedding']).optional().describe('Search backend (default substring; embedding = Phase 2)'),
503
+ },
504
+ },
505
+ async ({ query, top_k, mode }) => {
506
+ const k = top_k ?? 10
507
+ const backend = mode ?? 'substring'
508
+ const registry = readRegistry()
509
+ let hits: SearchHit[]
510
+ if (backend === 'embedding') {
511
+ hits = await embeddingSearch(query, k)
512
+ } else {
513
+ hits = substringSearch(registry, query, k)
514
+ }
515
+ const response = buildSearchResponse(query, backend, hits, registry)
516
+ return {
517
+ content: [
518
+ {
519
+ type: 'text',
520
+ text: JSON.stringify(
521
+ response,
522
+ null,
523
+ 2
524
+ ),
525
+ },
526
+ ],
527
+ }
528
+ }
529
+ )
530
+
531
+ server.registerTool(
532
+ 'vault_get',
533
+ {
534
+ title: 'Get research vault entry by exact ID',
535
+ description:
536
+ 'Authoritative exact-ID read path for a research-vault entry. ' +
537
+ 'Reads the knowledge file when present, otherwise falls back to the raw file. ' +
538
+ 'Missing lastAnalyzedAt is reported as NOT_ANALYZED, not missing content.',
539
+ inputSchema: {
540
+ id: z.string().min(1).describe('Exact registry entry ID'),
541
+ include_content: z.boolean().optional().describe('Include markdown content in the response (default false)'),
542
+ },
543
+ },
544
+ async ({ id, include_content }) => {
545
+ const result = getVaultItem(readRegistry(), id, VAULT_ROOT, include_content ?? false)
546
+ return {
547
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
548
+ ...(result.found ? {} : { isError: true }),
549
+ }
550
+ }
551
+ )
552
+
553
+ server.registerTool(
554
+ 'vault_status',
555
+ {
556
+ title: 'Vault status',
557
+ description:
558
+ 'Registry counts + decay summary + last maintenance run for the research vault. ' +
559
+ 'Use to check vault health before ingest/analyze workflows.',
560
+ inputSchema: {},
561
+ },
562
+ async () => {
563
+ const reg = readRegistry()
564
+ const decay = readDecayScores()
565
+ const maint = readLastMaintenance()
566
+ const byStatus: Record<string, number> = {}
567
+ const byCategory: Record<string, number> = {}
568
+ for (const item of reg) {
569
+ byStatus[item.status] = (byStatus[item.status] ?? 0) + 1
570
+ if (item.category) byCategory[item.category] = (byCategory[item.category] ?? 0) + 1
571
+ }
572
+ const bySummary: Record<string, number> = { deep: 0, shallow: 0, none: 0 }
573
+ for (const d of decay) bySummary[d.summaryLevel]++
574
+ const status = {
575
+ vault_root: VAULT_ROOT,
576
+ registry: { total: reg.length, by_status: byStatus, by_category: byCategory },
577
+ decay: { tracked: decay.length, by_summary_level: bySummary },
578
+ last_maintenance: maint,
579
+ }
580
+ return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] }
581
+ }
582
+ )
583
+
584
+ server.registerTool(
585
+ 'vault_taxonomy',
586
+ {
587
+ title: 'Vault taxonomy',
588
+ description: 'Returns the canonical category taxonomy (knowledge/_taxonomy.md). Use before /analyze to pick the right category.',
589
+ inputSchema: {},
590
+ },
591
+ async () => {
592
+ return { content: [{ type: 'text', text: readTaxonomy() }] }
593
+ }
594
+ )
595
+
596
+ // --- v1 write tools (DAS-808 write upgrade) ---
597
+ // Skipped entirely when MCP_READONLY=1 — remote-agent hosts get a strictly
598
+ // read-only surface; write attempts return "tool not found" instead of touching disk.
599
+ if (!READONLY) {
600
+ server.registerTool(
601
+ 'vault_store',
602
+ {
603
+ title: 'Store new entry in research vault',
604
+ description:
605
+ 'Create a new entry in the research vault with title, content, and optional category/tags/source URL. ' +
606
+ 'Appends to registry.jsonl and creates knowledge + raw files. ' +
607
+ 'Requires explicit write approval (DAS-808). ' +
608
+ 'Do not store secrets, OAuth material, tokens, raw payloads, or private infrastructure details.',
609
+ inputSchema: {
610
+ title: z.string().min(1).max(500).describe('Entry title (used to generate the entry ID)'),
611
+ content: z.string().min(1).describe('Markdown content body — must be sanitized (no secrets, tokens, or private payloads)'),
612
+ category: z.string().optional().describe('Taxonomy category path (e.g. software-engineering/security). Check vault_taxonomy first.'),
613
+ tags: z.array(z.string()).max(20).optional().describe('Tags for search indexing (max 20)'),
614
+ source_url: z.string().url().optional().describe('Source URL if this entry references an external resource'),
615
+ },
616
+ },
617
+ async ({ title, content, category, tags, source_url }) => {
618
+ try {
619
+ const result = writeStoreEntry({ title, content, category, tags, sourceUrl: source_url })
620
+ return {
621
+ content: [
622
+ {
623
+ type: 'text',
624
+ text: JSON.stringify(
625
+ {
626
+ stored: true,
627
+ entry: result.entry,
628
+ paths: { knowledge: result.knowledgePath, raw: result.rawPath },
629
+ },
630
+ null,
631
+ 2
632
+ ),
633
+ },
634
+ ],
635
+ }
636
+ } catch (err) {
637
+ return {
638
+ content: [{ type: 'text', text: JSON.stringify({ stored: false, error: String(err) }, null, 2) }],
639
+ isError: true,
640
+ }
641
+ }
642
+ }
643
+ )
644
+
645
+ server.registerTool(
646
+ 'vault_write',
647
+ {
648
+ title: 'Update existing vault entry',
649
+ description:
650
+ 'Update metadata or content of an existing research vault entry identified by its id. ' +
651
+ 'Rewrites the registry and optionally updates the knowledge file. ' +
652
+ 'Requires explicit write approval (DAS-808). ' +
653
+ 'Do not store secrets, OAuth material, tokens, raw payloads, or private infrastructure details.',
654
+ inputSchema: {
655
+ id: z.string().min(1).describe('Entry ID to update (e.g. 20260503-my-entry-slug)'),
656
+ title: z.string().min(1).max(500).optional().describe('New title'),
657
+ content: z.string().min(1).optional().describe('New markdown content — must be sanitized'),
658
+ category: z.string().optional().describe('New taxonomy category path'),
659
+ tags: z.array(z.string()).max(20).optional().describe('New tags (replaces existing tags)'),
660
+ status: z.enum(['raw', 'analyzed', 'archived']).optional().describe('New entry status'),
661
+ source_url: z.string().url().optional().describe('New source URL'),
662
+ },
663
+ },
664
+ async ({ id, title, content, category, tags, status, source_url }) => {
665
+ try {
666
+ const result = writeUpdateEntry({ id, title, content, category, tags, status, sourceUrl: source_url })
667
+ return {
668
+ content: [
669
+ {
670
+ type: 'text',
671
+ text: JSON.stringify(
672
+ {
673
+ updated: true,
674
+ entry: result.entry,
675
+ previous: { title: result.previous.title, category: result.previous.category, status: result.previous.status, tags: result.previous.tags },
676
+ knowledgePath: result.knowledgePath,
677
+ },
678
+ null,
679
+ 2
680
+ ),
681
+ },
682
+ ],
683
+ }
684
+ } catch (err) {
685
+ return {
686
+ content: [{ type: 'text', text: JSON.stringify({ updated: false, error: String(err) }, null, 2) }],
687
+ isError: true,
688
+ }
689
+ }
690
+ }
691
+ )
692
+ } // end if (!READONLY)
693
+
694
+ return server
695
+ }
696
+
697
+ // --------------------------------------------------------------------------
698
+ // Transports
699
+ // --------------------------------------------------------------------------
700
+
701
+ async function runStdio() {
702
+ const server = buildServer()
703
+ const transport = new StdioServerTransport()
704
+ await server.connect(transport)
705
+ // stdio holds the process open via the transport's stdin reader.
706
+ process.stderr.write(`[research-vault-mcp] stdio ready — vault=${VAULT_ROOT}\n`)
707
+ }
708
+
709
+ function writeJson(res: ServerResponse, status: number, body: unknown) {
710
+ res.writeHead(status, {
711
+ 'content-type': 'application/json',
712
+ 'cache-control': 'no-store',
713
+ })
714
+ res.end(JSON.stringify(body))
715
+ }
716
+
717
+ function writeOAuthMetadata(res: ServerResponse) {
718
+ writeJson(res, 200, {
719
+ issuer: PUBLIC_BASE_URL,
720
+ authorization_endpoint: `${PUBLIC_BASE_URL}/oauth/authorize`,
721
+ token_endpoint: `${PUBLIC_BASE_URL}/oauth/token`,
722
+ registration_endpoint: `${PUBLIC_BASE_URL}/oauth/register`,
723
+ response_types_supported: ['code'],
724
+ grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
725
+ token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
726
+ code_challenge_methods_supported: ['S256', 'plain'],
727
+ scopes_supported: ['vault.read', 'vault.write'],
728
+ })
729
+ }
730
+
731
+ function writeProtectedResourceMetadata(res: ServerResponse) {
732
+ writeJson(res, 200, {
733
+ resource: MCP_ENDPOINT_URL,
734
+ authorization_servers: [PUBLIC_BASE_URL],
735
+ bearer_methods_supported: ['header'],
736
+ scopes_supported: ['vault.read', 'vault.write'],
737
+ })
738
+ }
739
+
740
+ async function readRequestBody(req: IncomingMessage): Promise<string> {
741
+ const chunks: Buffer[] = []
742
+ for await (const chunk of req) {
743
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
744
+ }
745
+ return Buffer.concat(chunks).toString('utf8')
746
+ }
747
+
748
+ async function readRequestParams(req: IncomingMessage): Promise<Record<string, string>> {
749
+ const raw = await readRequestBody(req)
750
+ if (!raw.trim()) return {}
751
+ const contentType = req.headers['content-type'] ?? ''
752
+ if (contentType.includes('application/json')) {
753
+ const parsed = JSON.parse(raw) as Record<string, unknown>
754
+ return Object.fromEntries(
755
+ Object.entries(parsed).map(([key, value]) => [key, typeof value === 'string' ? value : String(value)])
756
+ )
757
+ }
758
+ return Object.fromEntries(new URLSearchParams(raw).entries())
759
+ }
760
+
761
+ function handleOAuthAuthorize(req: IncomingMessage, res: ServerResponse) {
762
+ const url = new URL(req.url ?? '/', PUBLIC_BASE_URL)
763
+ const redirectUri = url.searchParams.get('redirect_uri')
764
+ if (!redirectUri) {
765
+ writeJson(res, 400, { error: 'invalid_request', error_description: 'redirect_uri is required' })
766
+ return
767
+ }
768
+
769
+ const redirect = new URL(redirectUri)
770
+ redirect.searchParams.set('code', `rv-${randomUUID()}`)
771
+ const state = url.searchParams.get('state')
772
+ if (state) redirect.searchParams.set('state', state)
773
+ res.writeHead(302, {
774
+ location: redirect.toString(),
775
+ 'cache-control': 'no-store',
776
+ })
777
+ res.end()
778
+ }
779
+
780
+ async function handleOAuthToken(req: IncomingMessage, res: ServerResponse) {
781
+ let params: Record<string, string>
782
+ try {
783
+ params = await readRequestParams(req)
784
+ } catch {
785
+ writeJson(res, 400, { error: 'invalid_request' })
786
+ return
787
+ }
788
+
789
+ const grantType = params.grant_type ?? 'authorization_code'
790
+ if (!['authorization_code', 'refresh_token', 'client_credentials'].includes(grantType)) {
791
+ writeJson(res, 400, { error: 'unsupported_grant_type' })
792
+ return
793
+ }
794
+
795
+ writeJson(res, 200, {
796
+ access_token: `rv-${randomUUID()}`,
797
+ token_type: 'Bearer',
798
+ expires_in: 3600,
799
+ scope: params.scope ?? 'vault.read vault.write',
800
+ })
801
+ }
802
+
803
+ async function handleOAuthRegister(req: IncomingMessage, res: ServerResponse) {
804
+ // Dynamic registration compatibility: deployments should enforce real
805
+ // authorization at their private access layer.
806
+ try {
807
+ await readRequestBody(req)
808
+ } catch {
809
+ // Ignore malformed registration bodies; this endpoint is compatibility-only.
810
+ }
811
+ writeJson(res, 201, {
812
+ client_id: `rv-client-${randomUUID()}`,
813
+ client_id_issued_at: Math.floor(Date.now() / 1000),
814
+ token_endpoint_auth_method: 'none',
815
+ grant_types: ['authorization_code', 'refresh_token', 'client_credentials'],
816
+ response_types: ['code'],
817
+ redirect_uris: [],
818
+ scope: 'vault.read vault.write',
819
+ })
820
+ }
821
+
822
+ async function handleMcpRequest(req: IncomingMessage, res: ServerResponse) {
823
+ // Stateless per-request transport keeps Capy refresh/probe/idempotent retries
824
+ // from poisoning a singleton transport session.
825
+ const server = buildServer()
826
+ const transport = new StreamableHTTPServerTransport({
827
+ sessionIdGenerator: undefined,
828
+ })
829
+ await server.connect(transport)
830
+ res.on('close', () => {
831
+ void transport.close().catch(() => undefined)
832
+ void server.close().catch(() => undefined)
833
+ })
834
+ await transport.handleRequest(req, res)
835
+ }
836
+
837
+ async function runHttp() {
838
+ const http = createServer(async (req, res) => {
839
+ try {
840
+ const url = new URL(req.url ?? '/', PUBLIC_BASE_URL)
841
+ // Health probe — matches embedding endpoint's /v1/models convention.
842
+ if (req.method === 'GET' && url.pathname === '/health') {
843
+ writeJson(res, 200, { status: 'ok', server: 'research-vault-mcp', version: '0.2.0', vault: VAULT_ROOT })
844
+ return
845
+ }
846
+ if (
847
+ req.method === 'GET' &&
848
+ (url.pathname === '/.well-known/oauth-protected-resource' ||
849
+ url.pathname === '/.well-known/oauth-protected-resource/mcp')
850
+ ) {
851
+ writeProtectedResourceMetadata(res)
852
+ return
853
+ }
854
+ if (
855
+ req.method === 'GET' &&
856
+ (url.pathname === '/.well-known/oauth-authorization-server' ||
857
+ url.pathname === '/.well-known/oauth-authorization-server/mcp')
858
+ ) {
859
+ writeOAuthMetadata(res)
860
+ return
861
+ }
862
+ if (req.method === 'GET' && url.pathname === '/oauth/authorize') {
863
+ handleOAuthAuthorize(req, res)
864
+ return
865
+ }
866
+ if (req.method === 'POST' && url.pathname === '/oauth/token') {
867
+ await handleOAuthToken(req, res)
868
+ return
869
+ }
870
+ if (req.method === 'POST' && url.pathname === '/oauth/register') {
871
+ await handleOAuthRegister(req, res)
872
+ return
873
+ }
874
+ // MCP Streamable HTTP endpoint (default path /mcp; accept / too for convenience).
875
+ if (url.pathname === '/mcp' || url.pathname === '/') {
876
+ await handleMcpRequest(req, res)
877
+ return
878
+ }
879
+ res.writeHead(404, { 'content-type': 'text/plain' })
880
+ res.end('not found')
881
+ } catch (err) {
882
+ process.stderr.write(`[research-vault-mcp] http error: ${String(err)}\n`)
883
+ if (!res.headersSent) {
884
+ res.writeHead(500, { 'content-type': 'text/plain' })
885
+ res.end('internal error')
886
+ }
887
+ }
888
+ })
889
+
890
+ http.listen(PORT, HOST, () => {
891
+ process.stderr.write(`[research-vault-mcp] http ready — http://${HOST}:${PORT}/mcp vault=${VAULT_ROOT}\n`)
892
+ })
893
+ }
894
+
895
+ // --------------------------------------------------------------------------
896
+ // Entry
897
+ // --------------------------------------------------------------------------
898
+
899
+ const isMain = process.argv[1] ? import.meta.url === new URL(process.argv[1], 'file:').href : false
900
+
901
+ if (isMain) {
902
+ if (MODE === 'http') {
903
+ runHttp().catch((err) => {
904
+ process.stderr.write(`[research-vault-mcp] fatal: ${String(err)}\n`)
905
+ process.exit(1)
906
+ })
907
+ } else {
908
+ runStdio().catch((err) => {
909
+ process.stderr.write(`[research-vault-mcp] fatal: ${String(err)}\n`)
910
+ process.exit(1)
911
+ })
912
+ }
913
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "allowImportingTsExtensions": true,
9
+ "noEmit": true,
10
+ "types": ["bun-types"]
11
+ },
12
+ "include": ["**/*.ts"]
13
+ }
package/types.ts ADDED
@@ -0,0 +1,27 @@
1
+ export interface RawItem {
2
+ id: string
3
+ title: string
4
+ source: 'url' | 'local'
5
+ sourceUrl?: string
6
+ rawPath: string
7
+ ingestedAt: string
8
+ status: 'raw' | 'analyzed' | 'archived'
9
+ tags: string[]
10
+ category?: string
11
+ knowledgePath?: string
12
+ sourceMtime?: string
13
+ contentHash?: string
14
+ lastIndexedAt?: string
15
+ lastAnalyzedAt?: string
16
+ analysisVersion?: string
17
+ }
18
+
19
+ export interface DecayScore {
20
+ itemId: string
21
+ score: number
22
+ lastAccess: string
23
+ accessCount: number
24
+ summaryLevel: 'deep' | 'shallow' | 'none'
25
+ nextReviewAt: string
26
+ difficulty: number
27
+ }