@theronap/cortex-mcp 0.4.6 → 0.7.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.
@@ -16,7 +16,7 @@
16
16
  * Get your token from the Cortex console → Connect your AI.
17
17
  */
18
18
 
19
- const VERSION = '0.4.6'
19
+ const VERSION = '0.6.0'
20
20
  const cmd = process.argv[2]
21
21
  const rest = process.argv.slice(3)
22
22
 
@@ -36,6 +36,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
36
36
  ` setup <token> wire MCP server + capture hook into ~/.claude config\n` +
37
37
  ` doctor live health check — confirm your token works (no restart needed)\n` +
38
38
  ` status one-line connected/not-connected check (used by the SessionStart hook)\n` +
39
+ ` skills install/repair the managed Cortex skills (also wired by setup)\n` +
39
40
  ` capture Stop-hook capturer (invoked by Claude Code)\n` +
40
41
  ` (no args) run the MCP server (used by your Claude config)\n\n` +
41
42
  `Get your token from the Cortex console → Connect your AI.\n`,
@@ -71,6 +72,10 @@ if (cmd === 'setup') {
71
72
  await runCapture()
72
73
  const { closeFetch } = await import('../lib/diagnose.mjs')
73
74
  await closeFetch()
75
+ } else if (cmd === 'skills') {
76
+ // Install / repair the managed Cortex skills repository. No network — exits naturally.
77
+ const { runSkills } = await import('../lib/skills.mjs')
78
+ process.exitCode = await runSkills(rest)
74
79
  } else {
75
80
  // Default: run the MCP server (stays alive; never exits).
76
81
  const { runServer } = await import('../lib/server.mjs')
package/lib/server.mjs CHANGED
@@ -1,6 +1,9 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { z } from 'zod'
4
+ import { writeFileSync, mkdirSync } from 'fs'
5
+ import { homedir } from 'os'
6
+ import { join } from 'path'
4
7
  import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
5
8
 
6
9
  // The Cortex MCP server (stdio). Serves the signed-in employee's scoped org
@@ -49,13 +52,32 @@ export async function runServer(version) {
49
52
  {
50
53
  title: 'Search the org',
51
54
  description: 'Search your visible work activity and projects by keyword.',
52
- inputSchema: { query: z.string().describe('keyword to search for') },
55
+ inputSchema: { query: z.string().describe('keyword to search for — people or work activity') },
53
56
  },
54
57
  async ({ query }) => {
55
- const text = await fetchContext()
56
- const q = query.toLowerCase()
57
- const lines = text.split('\n').filter((l) => l.toLowerCase().includes(q))
58
- return { content: [{ type: 'text', text: lines.length ? `Matches for "${query}":\n${lines.join('\n')}` : `No visible results for "${query}".` }] }
58
+ // Real search via /api/search: records (RLS-scoped retrieve) + graph entities (people),
59
+ // instead of substring-grepping the cached context. People were previously invisible to search.
60
+ const res = await fetchCortex(`${BASE}/api/search?q=${encodeURIComponent(query)}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
61
+ if (!res.ok) {
62
+ const body = await res.text()
63
+ throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
64
+ }
65
+ const { people = [], records = [] } = await res.json()
66
+ if (!people.length && !records.length) return { content: [{ type: 'text', text: `No visible results for "${query}".` }] }
67
+ const lines = []
68
+ if (people.length) {
69
+ lines.push('People:')
70
+ for (const p of people) {
71
+ const meta = [p.title, p.company].filter(Boolean).join(', ')
72
+ lines.push(`- ${p.name}${meta ? ` (${meta})` : ''} — mentioned in ${p.mentions} record${p.mentions === 1 ? '' : 's'}`)
73
+ }
74
+ }
75
+ if (records.length) {
76
+ if (lines.length) lines.push('')
77
+ lines.push('Activity:')
78
+ for (const r of records) lines.push(`- [${r.source}] ${r.title}${r.project ? ` (${r.project})` : ''}`)
79
+ }
80
+ return { content: [{ type: 'text', text: `Results for "${query}":\n${lines.join('\n')}` }] }
59
81
  },
60
82
  )
61
83
 
@@ -160,5 +182,101 @@ export async function runServer(version) {
160
182
  },
161
183
  )
162
184
 
185
+ // ── File requests: ask the owner for a record's FULL original (lives on their machine) ──
186
+ const fail = (verb, res) => async () =>
187
+ ({ content: [{ type: 'text', text: `Could not ${verb}: ${classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id')).message}` }] })
188
+
189
+ server.registerTool(
190
+ 'request_file',
191
+ {
192
+ title: 'Request the full original of a record',
193
+ description: 'You have a record\'s SUMMARY but want the full original file (it lives on the owner\'s machine). This asks the owner to share it; once they approve, fetch it with get_file. Get record_id from a story Sources list or the "id:" on a my_context activity line.',
194
+ inputSchema: { record_id: z.string().describe('the record id (uuid)') },
195
+ },
196
+ async ({ record_id }) => {
197
+ let res
198
+ try {
199
+ res = await fetchCortex(`${BASE}/api/file-requests`, {
200
+ method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
201
+ body: JSON.stringify({ recordId: record_id }),
202
+ })
203
+ } catch (e) { return { content: [{ type: 'text', text: `Could not request: ${e.message}` }] } }
204
+ if (res.status === 409) {
205
+ const j = await res.json().catch(() => ({}))
206
+ if (j.error === 'you_own_it') return { content: [{ type: 'text', text: `You own this record — open the original directly:\n${j.uri}` }] }
207
+ if (j.error === 'no_original') return { content: [{ type: 'text', text: 'This record has no retrievable original.' }] }
208
+ }
209
+ if (!res.ok) return (await fail('request the file', res))()
210
+ const { request } = await res.json()
211
+ return { content: [{ type: 'text', text: `Requested. The owner will be asked to approve. Check status with file_requests; once approved + fulfilled, run get_file ${request.id}.` }] }
212
+ },
213
+ )
214
+
215
+ server.registerTool(
216
+ 'file_requests',
217
+ {
218
+ title: 'My file requests + approval inbox',
219
+ description: 'Lists file requests you made (with status) and requests from others awaiting YOUR approval. Approve/deny with decide_file_request; fetch an approved+fulfilled file with get_file.',
220
+ inputSchema: {},
221
+ },
222
+ async () => {
223
+ let res
224
+ try { res = await fetchCortex(`${BASE}/api/file-requests`, { headers: { Authorization: `Bearer ${TOKEN}` } }) }
225
+ catch (e) { return { content: [{ type: 'text', text: `Could not load file requests: ${e.message}` }] } }
226
+ if (!res.ok) return (await fail('load file requests', res))()
227
+ const { mine, inbox } = await res.json()
228
+ const parts = []
229
+ if (inbox?.length) parts.push('Awaiting YOUR approval:\n' + inbox.map((r) => ` - [${r.id}] ${r.requester} wants "${r.title}" (${r.source}) → decide_file_request ${r.id}`).join('\n'))
230
+ if (mine?.length) parts.push('Your requests:\n' + mine.map((r) => ` - [${r.id}] "${r.title}" — ${r.status}${r.status === 'fulfilled' ? ` → get_file ${r.id}` : ''}`).join('\n'))
231
+ return { content: [{ type: 'text', text: parts.length ? parts.join('\n\n') : 'No file requests.' }] }
232
+ },
233
+ )
234
+
235
+ server.registerTool(
236
+ 'decide_file_request',
237
+ {
238
+ title: 'Approve or deny a file request',
239
+ description: 'As the OWNER of a record, approve or deny someone\'s request for its full original. On approve, your Cortex desktop app fetches + shares the file. Get request_id from file_requests.',
240
+ inputSchema: { request_id: z.string(), decision: z.enum(['approve', 'deny']) },
241
+ },
242
+ async ({ request_id, decision }) => {
243
+ let res
244
+ try {
245
+ res = await fetchCortex(`${BASE}/api/file-requests/${request_id}/decide`, {
246
+ method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
247
+ body: JSON.stringify({ action: decision }),
248
+ })
249
+ } catch (e) { return { content: [{ type: 'text', text: `Could not decide: ${e.message}` }] } }
250
+ if (!res.ok) return (await fail('decide', res))()
251
+ return { content: [{ type: 'text', text: `Request ${decision === 'approve' ? 'approved' : 'denied'}.` }] }
252
+ },
253
+ )
254
+
255
+ server.registerTool(
256
+ 'get_file',
257
+ {
258
+ title: 'Download an approved file',
259
+ description: 'Download the full original for a request the owner approved + fulfilled. Saves it under ~/Downloads/cortex/ and returns the local path.',
260
+ inputSchema: { request_id: z.string() },
261
+ },
262
+ async ({ request_id }) => {
263
+ let res
264
+ try { res = await fetchCortex(`${BASE}/api/file-requests/${request_id}/download`, { headers: { Authorization: `Bearer ${TOKEN}` } }) }
265
+ catch (e) { return { content: [{ type: 'text', text: `Could not download: ${e.message}` }] } }
266
+ if (res.status === 409) return { content: [{ type: 'text', text: 'Not ready — the owner hasn\'t fulfilled this yet. Try again after they approve.' }] }
267
+ if (res.status === 410) return { content: [{ type: 'text', text: 'This file has expired (downloads are available for 7 days). Request it again.' }] }
268
+ if (!res.ok) return (await fail('download', res))()
269
+ const buf = Buffer.from(await res.arrayBuffer())
270
+ const cd = res.headers.get('content-disposition') ?? ''
271
+ const m = /filename="([^"]+)"/.exec(cd)
272
+ const name = (m ? m[1] : `cortex-file-${request_id}`).replace(/[^\w.\- ]/g, '_')
273
+ const dir = join(homedir(), 'Downloads', 'cortex')
274
+ mkdirSync(dir, { recursive: true })
275
+ const path = join(dir, name)
276
+ writeFileSync(path, buf)
277
+ return { content: [{ type: 'text', text: `Saved the full original to ${path} (${buf.length} bytes).` }] }
278
+ },
279
+ )
280
+
163
281
  await server.connect(new StdioServerTransport())
164
282
  }
package/lib/setup.mjs CHANGED
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from
2
2
  import { homedir } from 'os'
3
3
  import { join, dirname } from 'path'
4
4
  import { checkToken, resolveBase } from './diagnose.mjs'
5
+ import { installSkills } from './skills.mjs'
5
6
 
6
7
  // One-command employee onboarding. Wires both:
7
8
  // 1. ~/.claude.json → the cortex MCP server (context-serving)
@@ -115,6 +116,16 @@ export async function runSetup(argv, version) {
115
116
  sgrp.hooks = sgrp.hooks ?? []
116
117
  sgrp.hooks.push({ type: 'command', command: statusCmd })
117
118
 
119
+ // Skills self-heal: every session start, restore any drifted managed Cortex skill (quiet — only
120
+ // speaks up if it actually changed something). This is what makes the core skills "inalterable".
121
+ const skillsCmd = `npx -y ${spec} skills --repair --quiet`
122
+ for (const sg of s.hooks.SessionStart) {
123
+ if (Array.isArray(sg.hooks)) {
124
+ sg.hooks = sg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? skills/.test(h.command ?? ''))
125
+ }
126
+ }
127
+ sgrp.hooks.push({ type: 'command', command: skillsCmd })
128
+
118
129
  ensureDir(settingsJson)
119
130
  writeFileSync(settingsJson, JSON.stringify(s, null, 2))
120
131
  log(` ✓ Capture hook + status line → ${settingsJson}${bak ? ' (backup saved)' : ''}`)
@@ -123,7 +134,15 @@ export async function runSetup(argv, version) {
123
134
  process.exit(1)
124
135
  }
125
136
 
126
- // ── 3. Self-verify writing config proves "files written", NOT "connection works".
137
+ // ── 3. Managed skills repository (~/.claude/skills/cortex/) ──────────────
138
+ try {
139
+ installSkills({ quiet: false })
140
+ } catch (e) {
141
+ // Non-fatal: a skills hiccup must never block the core connection. Repair runs each session.
142
+ log(` ⚠ skills install skipped: ${e.message} (will retry on next session)`)
143
+ }
144
+
145
+ // ── 4. Self-verify — writing config proves "files written", NOT "connection works".
127
146
  // Actually call the API so a bad/expired token is caught HERE, not 40 minutes into debugging.
128
147
  log('')
129
148
  log('Verifying your token against Cortex…')
package/lib/skills.mjs ADDED
@@ -0,0 +1,121 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync } from 'fs'
2
+ import { homedir } from 'os'
3
+ import { join, dirname } from 'path'
4
+ import { fileURLToPath } from 'url'
5
+
6
+ // Managed Cortex skills repository.
7
+ //
8
+ // Cortex owns a namespace under the user's Claude skills dir: ~/.claude/skills/cortex/
9
+ // cortex/
10
+ // README.md ← explains the namespace is Cortex-managed
11
+ // .cortex-manifest.json ← what's managed + content hashes (machine-readable)
12
+ // core/<name>/SKILL.md ← MANDATORY, self-healing skills shipped in this package
13
+ // (org/ and shared/ are reserved for server-synced skills — not yet populated)
14
+ //
15
+ // "Inalterable" in practice: the bundled core skills are the source of truth. On every install AND
16
+ // on every session start (the SessionStart --repair hook), any core skill whose on-disk content
17
+ // drifted from the bundled source is RESTORED — the user's version is backed up to <file>.user-bak
18
+ // first, so nothing is lost, but the canonical skill always wins. Idempotent: identical content is a
19
+ // no-op (no backup, no write, no churn).
20
+
21
+ const HERE = dirname(fileURLToPath(import.meta.url))
22
+ const BUNDLED = join(HERE, '..', 'skills') // packages/cortex-mcp/skills/<name>/SKILL.md
23
+
24
+ // djb2 — tiny, dependency-free content fingerprint for the manifest (drift detection, not security).
25
+ function hash(s) {
26
+ let h = 5381
27
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0
28
+ return h.toString(16)
29
+ }
30
+
31
+ function ensureDir(path) {
32
+ if (!existsSync(path)) mkdirSync(path, { recursive: true })
33
+ }
34
+
35
+ // Discover bundled core skills: each subdir of skills/ that contains a SKILL.md.
36
+ function bundledSkills() {
37
+ if (!existsSync(BUNDLED)) return []
38
+ return readdirSync(BUNDLED, { withFileTypes: true })
39
+ .filter((d) => d.isDirectory() && existsSync(join(BUNDLED, d.name, 'SKILL.md')))
40
+ .map((d) => ({ name: d.name, src: join(BUNDLED, d.name, 'SKILL.md') }))
41
+ }
42
+
43
+ const README = `# Cortex-managed skills
44
+
45
+ This folder is owned by Cortex (\`@theronap/cortex-mcp\`). Skills under \`core/\` are **mandatory and
46
+ self-healing**: if you edit or delete one, Cortex restores it on your next Claude session (your edited
47
+ copy is saved as \`SKILL.md.user-bak\` first). To change a core skill, change it upstream in Cortex.
48
+
49
+ - \`core/\` — required Cortex skills, shipped and repaired by this package
50
+ - \`org/\` — your organization's skills (reserved; synced from Cortex)
51
+ - \`shared/\` — skills shared across the org (reserved; synced from Cortex)
52
+
53
+ Managed by: \`npx -y @theronap/cortex-mcp skills\` · repaired automatically each session.
54
+ `
55
+
56
+ /**
57
+ * Install / repair the managed Cortex skills.
58
+ * @param {{ quiet?: boolean }} opts quiet → only emit on actual change (for the SessionStart hook)
59
+ * @returns {{ installed: string[], repaired: string[], unchanged: string[] }}
60
+ */
61
+ export function installSkills(opts = {}) {
62
+ const quiet = !!opts.quiet
63
+ const root = join(homedir(), '.claude', 'skills', 'cortex')
64
+ const coreDir = join(root, 'core')
65
+ const log = (m) => { if (!quiet) process.stdout.write(m + '\n') }
66
+
67
+ const skills = bundledSkills()
68
+ const result = { installed: [], repaired: [], unchanged: [] }
69
+ if (!skills.length) return result // nothing bundled (shouldn't happen) — never error
70
+
71
+ ensureDir(coreDir)
72
+ // Reserved namespaces so the structure is discoverable even before server sync exists.
73
+ ensureDir(join(root, 'org'))
74
+ ensureDir(join(root, 'shared'))
75
+ writeFileSync(join(root, 'README.md'), README)
76
+
77
+ const manifest = { managed: [], updated_by: 'cortex-mcp', skills: {} }
78
+
79
+ for (const sk of skills) {
80
+ const source = readFileSync(sk.src, 'utf8')
81
+ const dest = join(coreDir, sk.name, 'SKILL.md')
82
+ manifest.managed.push(`core/${sk.name}/SKILL.md`)
83
+ manifest.skills[sk.name] = hash(source)
84
+
85
+ if (!existsSync(dest)) {
86
+ ensureDir(dirname(dest))
87
+ writeFileSync(dest, source)
88
+ result.installed.push(sk.name)
89
+ continue
90
+ }
91
+ const current = readFileSync(dest, 'utf8')
92
+ if (current === source) { result.unchanged.push(sk.name); continue }
93
+
94
+ // Drift: preserve the user's version, then restore canonical.
95
+ try { copyFileSync(dest, `${dest}.user-bak`) } catch { /* best-effort backup */ }
96
+ writeFileSync(dest, source)
97
+ result.repaired.push(sk.name)
98
+ }
99
+
100
+ writeFileSync(join(root, '.cortex-manifest.json'), JSON.stringify(manifest, null, 2))
101
+
102
+ if (result.installed.length) log(` ✓ Cortex skills installed: ${result.installed.join(', ')} → ${coreDir}`)
103
+ if (result.repaired.length) log(` ✓ Cortex skills restored (backed up your copy): ${result.repaired.join(', ')}`)
104
+ if (quiet && (result.installed.length || result.repaired.length)) {
105
+ // SessionStart surfaces one line in Claude Code so a silent self-heal isn't invisible.
106
+ process.stdout.write(`Cortex: synced ${result.installed.length + result.repaired.length} managed skill(s).\n`)
107
+ }
108
+ return result
109
+ }
110
+
111
+ // CLI entry: `cortex-mcp skills [--repair] [--quiet]`. (--repair and plain install are the same
112
+ // idempotent operation; --repair is just the name the SessionStart hook uses for intent.)
113
+ export async function runSkills(argv = []) {
114
+ const quiet = argv.includes('--quiet')
115
+ if (!quiet) process.stdout.write('\nCortex skills — installing managed repository…\n')
116
+ const r = installSkills({ quiet })
117
+ if (!quiet && !r.installed.length && !r.repaired.length) {
118
+ process.stdout.write(` ✓ Up to date (${r.unchanged.join(', ') || 'none'}).\n`)
119
+ }
120
+ return 0
121
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.4.6",
3
+ "version": "0.7.0",
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": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "bin",
11
- "lib"
11
+ "lib",
12
+ "skills"
12
13
  ],
13
14
  "engines": {
14
15
  "node": ">=18"
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: cortex-log
3
+ description: Close out a work session into Cortex — summarize what happened, confirm it reached the org, and surface anything teammates should know. Run at or near the end of any working session.
4
+ ---
5
+
6
+ > **Cortex-managed skill.** This file is installed and kept up to date by Cortex. Local edits are
7
+ > restored on the next session (a backup of your version is saved alongside). Don't rely on changes here.
8
+
9
+ ## When to use
10
+
11
+ At the end of a Claude Code session, or after finishing a meaningful phase of work. Cortex already
12
+ captures your sessions automatically in the background — this skill is the *deliberate* close-out: it
13
+ produces a clean, structured summary and confirms the org received it.
14
+
15
+ ## Inputs
16
+
17
+ No arguments. Read the conversation context.
18
+
19
+ ## Steps
20
+
21
+ 1. **Summarize the session** — what was worked on, what was decided, what changed. Be concrete: name
22
+ the projects, files, and people involved.
23
+ 2. **Surface org-relevant signal** — blockers, decisions, handoffs, and anyone you coordinated with.
24
+ These are the things a teammate or manager would want to know without reading the whole transcript.
25
+ 3. **Confirm capture** — check the Cortex MCP is connected (`my_context` returns your context). Your
26
+ session flows to the org automatically at session end via the capture hook; if `my_context` errors,
27
+ tell the user their session may not be captured and to run `npx -y @theronap/cortex-mcp doctor`.
28
+ 4. **Flag privacy** — if any record from this session should be confidential, note it so the user can
29
+ mark it (`set_record_privacy`). Default is org-visible under access rules.
30
+
31
+ ## Output
32
+
33
+ A short structured summary:
34
+
35
+ ```markdown
36
+ ## Session summary
37
+
38
+ **Worked on:** brief description
39
+ **Decisions:** decision 1; decision 2
40
+ **Open / blocked:** anything unresolved or waiting on someone
41
+ **Coordinated with:** people involved
42
+ **Capture:** ✅ flowing to Cortex (or ⚠ not connected — run doctor)
43
+ ```
44
+
45
+ ## Safety rules
46
+
47
+ - This skill only summarizes and reports. It never sends external messages, never deletes anything,
48
+ and never changes access on a record without the user explicitly asking.
49
+ - Raw session text stays on this machine — only the summary-grade record reaches the org, scoped by
50
+ access rules.