@theronap/cortex-mcp 0.9.7 → 0.9.8
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/bin/cortex-mcp.mjs +7 -0
- package/lib/ingest_folder.mjs +171 -0
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* setup <TOKEN> wire BOTH the MCP server + capture hook into your Claude config
|
|
8
8
|
* doctor live health check — is the token actually working? (no restart needed)
|
|
9
9
|
* capture the Stop-hook capturer (invoked by Claude Code, not by hand)
|
|
10
|
+
* ingest-folder <path> ingest a local markdown folder as your authored records
|
|
10
11
|
* snapshot-context save the exact startup context served by Cortex to a local log file
|
|
11
12
|
* --version | -v
|
|
12
13
|
* --help | -h
|
|
@@ -47,6 +48,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
47
48
|
` skills install/repair the managed Cortex skills (also wired by setup)\n` +
|
|
48
49
|
` snapshot-context save the exact startup context Cortex served to a local snapshot\n` +
|
|
49
50
|
` capture Stop-hook capturer (invoked by Claude Code)\n` +
|
|
51
|
+
` ingest-folder <path> ingest a local markdown folder as your authored records\n` +
|
|
50
52
|
` (no args) run the MCP server (used by your Claude config)\n\n` +
|
|
51
53
|
`Get your token from the Cortex console → Connect your AI.\n`,
|
|
52
54
|
)
|
|
@@ -88,6 +90,11 @@ if (cmd === 'setup') {
|
|
|
88
90
|
await runCapture()
|
|
89
91
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
90
92
|
await closeFetch()
|
|
93
|
+
} else if (cmd === 'ingest-folder') {
|
|
94
|
+
const { runIngestFolder } = await import('../lib/ingest_folder.mjs')
|
|
95
|
+
await runIngestFolder(rest)
|
|
96
|
+
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
97
|
+
await closeFetch()
|
|
91
98
|
} else if (cmd === 'snapshot-context') {
|
|
92
99
|
const { runSnapshotContext } = await import('../lib/context_log.mjs')
|
|
93
100
|
process.exitCode = await runSnapshotContext()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, relative, basename, extname } from 'node:path'
|
|
3
|
+
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
4
|
+
|
|
5
|
+
// `cortex-mcp ingest-folder <path>` — walk a local markdown folder and upsert each file as an
|
|
6
|
+
// AUTHORED record in Cortex (source='brain'), so a user's personal digest is built from THEIR
|
|
7
|
+
// notes (Robin parity). Only an EXCERPT of each file leaves the machine (frontmatter + first
|
|
8
|
+
// ~800 chars), never the full body. The server clamps privacy for source='brain' and treats
|
|
9
|
+
// sessionId (the file's relative path) as the stable dedupe key, so re-running updates in place.
|
|
10
|
+
|
|
11
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
12
|
+
|
|
13
|
+
const MD_EXT = new Set(['.md', '.markdown'])
|
|
14
|
+
// Directories that are never user notes — skip them and any dotfile dir.
|
|
15
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.obsidian', '.trash'])
|
|
16
|
+
|
|
17
|
+
// Recursively collect markdown file paths under `root`. Uses withFileTypes so we can prune
|
|
18
|
+
// skipped directories without stat-ing every entry.
|
|
19
|
+
function walkMarkdown(root) {
|
|
20
|
+
const out = []
|
|
21
|
+
let entries
|
|
22
|
+
try {
|
|
23
|
+
entries = readdirSync(root, { withFileTypes: true })
|
|
24
|
+
} catch {
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
for (const ent of entries) {
|
|
28
|
+
const full = join(root, ent.name)
|
|
29
|
+
if (ent.isDirectory()) {
|
|
30
|
+
if (SKIP_DIRS.has(ent.name) || ent.name.startsWith('.')) continue
|
|
31
|
+
out.push(...walkMarkdown(full))
|
|
32
|
+
} else if (ent.isFile() && MD_EXT.has(extname(ent.name).toLowerCase())) {
|
|
33
|
+
out.push(full)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Tiny hand-rolled YAML frontmatter parser. Only the leading `---\n…\n---` block; flat
|
|
40
|
+
// `key: value` lines. Collects title/tags/project/people if present. No dependencies — this is
|
|
41
|
+
// deliberately minimal (not a full YAML parser). Returns { frontmatter, frontLines, bodyStart }.
|
|
42
|
+
function parseFrontmatter(content) {
|
|
43
|
+
if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) {
|
|
44
|
+
return { frontmatter: {}, frontLines: [], bodyStart: 0 }
|
|
45
|
+
}
|
|
46
|
+
const lines = content.split('\n')
|
|
47
|
+
// lines[0] is the opening '---'; find the closing '---'.
|
|
48
|
+
let end = -1
|
|
49
|
+
for (let i = 1; i < lines.length; i++) {
|
|
50
|
+
if (lines[i].trim() === '---') { end = i; break }
|
|
51
|
+
}
|
|
52
|
+
if (end === -1) return { frontmatter: {}, frontLines: [], bodyStart: 0 }
|
|
53
|
+
|
|
54
|
+
const frontLines = lines.slice(1, end)
|
|
55
|
+
const frontmatter = {}
|
|
56
|
+
for (const raw of frontLines) {
|
|
57
|
+
const line = raw.replace(/\r$/, '')
|
|
58
|
+
const idx = line.indexOf(':')
|
|
59
|
+
if (idx === -1) continue
|
|
60
|
+
const key = line.slice(0, idx).trim()
|
|
61
|
+
let value = line.slice(idx + 1).trim()
|
|
62
|
+
if (!key) continue
|
|
63
|
+
// Strip wrapping quotes.
|
|
64
|
+
value = value.replace(/^["']|["']$/g, '')
|
|
65
|
+
// Inline list form: [a, b, c]
|
|
66
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
67
|
+
const items = value.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean)
|
|
68
|
+
frontmatter[key] = items
|
|
69
|
+
} else if (value === '') {
|
|
70
|
+
frontmatter[key] = ''
|
|
71
|
+
} else {
|
|
72
|
+
frontmatter[key] = value
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// bodyStart = char offset just past the closing '---' line + its newline.
|
|
76
|
+
const consumed = lines.slice(0, end + 1).join('\n')
|
|
77
|
+
const bodyStart = consumed.length + 1
|
|
78
|
+
return { frontmatter, frontLines: frontLines.map((l) => l.replace(/\r$/, '')), bodyStart }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// title: frontmatter.title → first `# ` heading in body → filename without extension.
|
|
82
|
+
function deriveTitle(frontmatter, body, filePath) {
|
|
83
|
+
if (typeof frontmatter.title === 'string' && frontmatter.title.trim()) return frontmatter.title.trim()
|
|
84
|
+
for (const line of body.split('\n')) {
|
|
85
|
+
const m = line.match(/^#\s+(.+?)\s*$/)
|
|
86
|
+
if (m) return m[1].trim()
|
|
87
|
+
}
|
|
88
|
+
return basename(filePath, extname(filePath))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// summary: frontmatter line(s) joined + first ~800 chars of the body. Bounded so the raw full
|
|
92
|
+
// file never leaves the machine — only an excerpt.
|
|
93
|
+
function deriveSummary(frontLines, body) {
|
|
94
|
+
const front = frontLines.join('\n').trim()
|
|
95
|
+
const excerpt = body.trim().slice(0, 800)
|
|
96
|
+
return [front, excerpt].filter(Boolean).join('\n\n')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function runIngestFolder(argv) {
|
|
100
|
+
const path = argv[0]
|
|
101
|
+
if (!path) {
|
|
102
|
+
process.stderr.write(
|
|
103
|
+
'usage: cortex-mcp ingest-folder <path>\n' +
|
|
104
|
+
' Ingest a local markdown folder as your authored Cortex records.\n',
|
|
105
|
+
)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const token = process.env.CORTEX_TOKEN
|
|
110
|
+
if (!token) {
|
|
111
|
+
process.stderr.write(
|
|
112
|
+
'cortex: CORTEX_TOKEN not set. Run setup first, or export CORTEX_TOKEN=<your-token>.\n',
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
117
|
+
|
|
118
|
+
const files = walkMarkdown(path)
|
|
119
|
+
process.stdout.write(`Ingesting ${files.length} markdown files from ${path} …\n`)
|
|
120
|
+
if (files.length === 0) {
|
|
121
|
+
process.stdout.write('✓ ingested 0, updated 0, failed 0 (no markdown files found)\n')
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let inserted = 0
|
|
126
|
+
let updated = 0
|
|
127
|
+
let failed = 0
|
|
128
|
+
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
try {
|
|
131
|
+
const content = readFileSync(file, 'utf8')
|
|
132
|
+
const { frontmatter, frontLines, bodyStart } = parseFrontmatter(content)
|
|
133
|
+
const body = content.slice(bodyStart)
|
|
134
|
+
|
|
135
|
+
const relpath = relative(path, file)
|
|
136
|
+
const title = deriveTitle(frontmatter, body, file)
|
|
137
|
+
const summary = deriveSummary(frontLines, body)
|
|
138
|
+
const occurredAt = statSync(file).mtime.toISOString()
|
|
139
|
+
|
|
140
|
+
const res = await fetchCortex(`${base}/api/ingest`, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
source: 'brain',
|
|
145
|
+
sessionId: relpath,
|
|
146
|
+
title,
|
|
147
|
+
summary,
|
|
148
|
+
privacy: 'scoped',
|
|
149
|
+
occurredAt,
|
|
150
|
+
payload: { path: relpath, frontmatter },
|
|
151
|
+
}),
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
if (res.ok) {
|
|
155
|
+
const j = await res.json().catch(() => ({}))
|
|
156
|
+
if (j.inserted === false) updated++
|
|
157
|
+
else inserted++
|
|
158
|
+
} else {
|
|
159
|
+
failed++
|
|
160
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
161
|
+
process.stderr.write(`cortex: ${relative(path, file)} failed — ${d.message}\n`)
|
|
162
|
+
}
|
|
163
|
+
} catch (e) {
|
|
164
|
+
failed++
|
|
165
|
+
process.stderr.write(`cortex: ${relative(path, file)} failed — ${e.message}\n`)
|
|
166
|
+
}
|
|
167
|
+
await sleep(60)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
process.stdout.write(`✓ ingested ${inserted}, updated ${updated}, failed ${failed}\n`)
|
|
171
|
+
}
|