@theronap/cortex-mcp 0.9.7 → 0.9.9

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.
@@ -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,170 @@
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: first ~800 chars of the BODY (prose). Bounded so the raw full file never leaves the
92
+ // machine. Frontmatter is NOT prepended — it rides structured in payload, and the title is derived
93
+ // separately, so the summary reads as content (what the digest summarizes), not "key: value" noise.
94
+ function deriveSummary(body) {
95
+ return body.trim().slice(0, 800)
96
+ }
97
+
98
+ export async function runIngestFolder(argv) {
99
+ const path = argv[0]
100
+ if (!path) {
101
+ process.stderr.write(
102
+ 'usage: cortex-mcp ingest-folder <path>\n' +
103
+ ' Ingest a local markdown folder as your authored Cortex records.\n',
104
+ )
105
+ return
106
+ }
107
+
108
+ const token = process.env.CORTEX_TOKEN
109
+ if (!token) {
110
+ process.stderr.write(
111
+ 'cortex: CORTEX_TOKEN not set. Run setup first, or export CORTEX_TOKEN=<your-token>.\n',
112
+ )
113
+ return
114
+ }
115
+ const base = resolveBase(process.env.CORTEX_URL)
116
+
117
+ const files = walkMarkdown(path)
118
+ process.stdout.write(`Ingesting ${files.length} markdown files from ${path} …\n`)
119
+ if (files.length === 0) {
120
+ process.stdout.write('✓ ingested 0, updated 0, failed 0 (no markdown files found)\n')
121
+ return
122
+ }
123
+
124
+ let inserted = 0
125
+ let updated = 0
126
+ let failed = 0
127
+
128
+ for (const file of files) {
129
+ try {
130
+ const content = readFileSync(file, 'utf8')
131
+ const { frontmatter, bodyStart } = parseFrontmatter(content)
132
+ const body = content.slice(bodyStart)
133
+
134
+ const relpath = relative(path, file)
135
+ const title = deriveTitle(frontmatter, body, file)
136
+ const summary = deriveSummary(body)
137
+ const occurredAt = statSync(file).mtime.toISOString()
138
+
139
+ const res = await fetchCortex(`${base}/api/ingest`, {
140
+ method: 'POST',
141
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
142
+ body: JSON.stringify({
143
+ source: 'brain',
144
+ sessionId: relpath,
145
+ title,
146
+ summary,
147
+ privacy: 'scoped',
148
+ occurredAt,
149
+ payload: { path: relpath, frontmatter },
150
+ }),
151
+ })
152
+
153
+ if (res.ok) {
154
+ const j = await res.json().catch(() => ({}))
155
+ if (j.inserted === false) updated++
156
+ else inserted++
157
+ } else {
158
+ failed++
159
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
160
+ process.stderr.write(`cortex: ${relative(path, file)} failed — ${d.message}\n`)
161
+ }
162
+ } catch (e) {
163
+ failed++
164
+ process.stderr.write(`cortex: ${relative(path, file)} failed — ${e.message}\n`)
165
+ }
166
+ await sleep(60)
167
+ }
168
+
169
+ process.stdout.write(`✓ ingested ${inserted}, updated ${updated}, failed ${failed}\n`)
170
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {