@danielblomma/cortex-mcp 2.0.9 → 2.0.13

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@danielblomma/cortex-mcp",
3
3
  "mcpName": "io.github.DanielBlomma/cortex",
4
- "version": "2.0.9",
4
+ "version": "2.0.13",
5
5
  "description": "Local, repo-scoped context platform for coding assistants. Semantic search, graph relationships, and architectural rule context.",
6
6
  "type": "module",
7
7
  "author": "Daniel Blomma",
@@ -9,7 +9,8 @@
9
9
  "graph:load": "npm run build --silent && node dist/loadGraph.js",
10
10
  "dev": "node --loader ts-node/esm src/server.ts",
11
11
  "start": "node dist/server.js",
12
- "test": "npm run build --silent && node --test tests/*.test.mjs"
12
+ "test": "npm run build --silent && node --test tests/*.test.mjs",
13
+ "test:ci": "npm run build --silent && node --test --test-reporter=spec tests/*.test.mjs"
13
14
  },
14
15
  "dependencies": {
15
16
  "@huggingface/transformers": "^4.1.0",
@@ -46,6 +46,7 @@ type ManifestEntry = {
46
46
  };
47
47
 
48
48
  type LocalSkillRecord = {
49
+ cli: SkillCli;
49
50
  scope: string;
50
51
  updated_at: string;
51
52
  path: string;
@@ -88,7 +89,29 @@ function readState(): LocalSkillsState {
88
89
  if (!existsSync(path)) return { skills: {} };
89
90
  try {
90
91
  const parsed = JSON.parse(readFileSync(path, "utf8")) as LocalSkillsState;
91
- return { skills: parsed.skills ?? {}, last_synced_at: parsed.last_synced_at };
92
+ const normalizedSkills: Record<string, LocalSkillRecord> = {};
93
+ for (const [key, record] of Object.entries(parsed.skills ?? {})) {
94
+ if (!record || typeof record !== "object") continue;
95
+ const inferredCli =
96
+ record.path?.includes("/.codex/skills/")
97
+ ? "codex"
98
+ : "claude";
99
+ const cli =
100
+ record.cli === "codex" || record.cli === "claude"
101
+ ? record.cli
102
+ : inferredCli;
103
+ const normalizedKey = key.includes(":") ? key : `${cli}:${key}`;
104
+ normalizedSkills[normalizedKey] = {
105
+ cli,
106
+ scope: String(record.scope ?? "global"),
107
+ updated_at: String(record.updated_at ?? ""),
108
+ path: String(record.path ?? ""),
109
+ };
110
+ }
111
+ return {
112
+ skills: normalizedSkills,
113
+ last_synced_at: parsed.last_synced_at,
114
+ };
92
115
  } catch {
93
116
  return { skills: {} };
94
117
  }
@@ -103,19 +126,22 @@ function writeState(state: LocalSkillsState): void {
103
126
  }
104
127
 
105
128
  /**
106
- * Resolve the on-disk SKILL.md path for a skill. Global skills live under
107
- * ~/.claude/skills (Claude Code's user-scope skills directory); cli:codex
108
- * skills live under ~/.codex/skills. cli:claude scope is treated as
109
- * Claude-only and lands in ~/.claude/skills.
129
+ * Resolve the on-disk SKILL.md path for a skill install target. Global
130
+ * skills are installed once per CLI, so the destination root depends on the
131
+ * active sync target rather than just the stored scope.
110
132
  */
111
- function skillFilePath(scope: string, name: string): string {
133
+ function skillFilePath(cli: SkillCli, name: string): string {
112
134
  const root =
113
- scope === "cli:codex"
135
+ cli === "codex"
114
136
  ? join(homedir(), ".codex", "skills")
115
137
  : join(homedir(), ".claude", "skills");
116
138
  return join(root, name, "SKILL.md");
117
139
  }
118
140
 
141
+ function stateSkillKey(cli: SkillCli, name: string): string {
142
+ return `${cli}:${name}`;
143
+ }
144
+
119
145
  function shouldSyncForCli(scope: string, cli: SkillCli): boolean {
120
146
  if (scope === "global") return true;
121
147
  return scope === `cli:${cli}`;
@@ -222,7 +248,8 @@ export async function runSkillSyncForCli(
222
248
 
223
249
  // Detect adds + changes
224
250
  for (const entry of relevantManifest) {
225
- const local = state.skills[entry.name];
251
+ const skillKey = stateSkillKey(cli, entry.name);
252
+ const local = state.skills[skillKey];
226
253
  const isNew = !local;
227
254
  const isChanged =
228
255
  Boolean(local) &&
@@ -243,7 +270,7 @@ export async function runSkillSyncForCli(
243
270
  };
244
271
  }
245
272
 
246
- const path = skillFilePath(entry.scope, entry.name);
273
+ const path = skillFilePath(cli, entry.name);
247
274
  try {
248
275
  writeSkillFile(path, body);
249
276
  } catch (err) {
@@ -257,7 +284,8 @@ export async function runSkillSyncForCli(
257
284
  };
258
285
  }
259
286
 
260
- state.skills[entry.name] = {
287
+ state.skills[skillKey] = {
288
+ cli,
261
289
  scope: entry.scope,
262
290
  updated_at: entry.updated_at,
263
291
  path,
@@ -269,7 +297,10 @@ export async function runSkillSyncForCli(
269
297
  // dropped (or disabled). We only consider state entries whose scope
270
298
  // matches this cli, so we don't accidentally remove the other CLI's
271
299
  // skills when running a per-cli tick.
272
- for (const [name, record] of Object.entries(state.skills)) {
300
+ for (const [skillKey, record] of Object.entries(state.skills)) {
301
+ if (record.cli !== cli) continue;
302
+ const [, name] = skillKey.split(":", 2);
303
+ if (!name) continue;
273
304
  if (!shouldSyncForCli(record.scope, cli)) continue;
274
305
  if (remoteByName.has(name)) continue;
275
306
  try {
@@ -277,7 +308,7 @@ export async function runSkillSyncForCli(
277
308
  } catch {
278
309
  // best-effort; if unlink fails the next tick will retry
279
310
  }
280
- delete state.skills[name];
311
+ delete state.skills[skillKey];
281
312
  removed.push(name);
282
313
  }
283
314
 
@@ -1,20 +1,15 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
4
  import { env, pipeline } from "@huggingface/transformers";
6
5
  import { readJsonl, asString, asNumber, asBoolean } from "./jsonl.js";
6
+ import { CACHE_DIR, PATHS } from "./paths.js";
7
7
  import type { JsonObject, JsonValue } from "./types.js";
8
8
 
9
- const __filename = fileURLToPath(import.meta.url);
10
- const __dirname = path.dirname(__filename);
11
- const REPO_ROOT = path.resolve(__dirname, "../..");
12
- const CONTEXT_DIR = path.join(REPO_ROOT, ".context");
13
- const CACHE_DIR = path.join(CONTEXT_DIR, "cache");
14
- const EMBEDDINGS_DIR = path.join(CONTEXT_DIR, "embeddings");
15
- const EMBEDDINGS_PATH = path.join(EMBEDDINGS_DIR, "entities.jsonl");
16
- const EMBEDDINGS_MANIFEST_PATH = path.join(EMBEDDINGS_DIR, "manifest.json");
17
- const MODEL_CACHE_DIR = path.join(EMBEDDINGS_DIR, "models");
9
+ const EMBEDDINGS_PATH = PATHS.embeddingsEntities;
10
+ const EMBEDDINGS_MANIFEST_PATH = PATHS.embeddingsManifest;
11
+ const MODEL_CACHE_DIR = PATHS.embeddingsModelCache;
12
+ const EMBEDDINGS_DIR = path.dirname(EMBEDDINGS_PATH);
18
13
 
19
14
  const DEFAULT_MODEL_ID = "Xenova/all-MiniLM-L6-v2";
20
15
  const DEFAULT_MAX_TEXT_CHARS = 7000;
@@ -1,16 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
3
  import ryugraph, { type Connection, type PreparedStatement, type QueryResult, type RyuValue } from "ryugraph";
5
4
  import { readJsonl, asString, asNumber, asBoolean } from "./jsonl.js";
5
+ import { CACHE_DIR, CONTEXT_DIR, DB_PATH } from "./paths.js";
6
6
  import type { JsonObject } from "./types.js";
7
7
 
8
- const __filename = fileURLToPath(import.meta.url);
9
- const __dirname = path.dirname(__filename);
10
- const REPO_ROOT = path.resolve(__dirname, "../..");
11
- const CONTEXT_DIR = path.join(REPO_ROOT, ".context");
12
- const CACHE_DIR = path.join(CONTEXT_DIR, "cache");
13
- const DB_PATH = path.join(CONTEXT_DIR, "db", "graph.ryu");
14
8
  const ONTOLOGY_PATH = path.join(CONTEXT_DIR, "ontology.cypher");
15
9
  const BATCH_SIZE = 50;
16
10
 
@@ -21,9 +21,45 @@ function normalizeForWsl(rawPath: string): string {
21
21
  }
22
22
 
23
23
  const PROJECT_ROOT_OVERRIDE = process.env.CORTEX_PROJECT_ROOT?.trim();
24
- export const REPO_ROOT = PROJECT_ROOT_OVERRIDE
25
- ? path.resolve(normalizeForWsl(PROJECT_ROOT_OVERRIDE))
26
- : path.resolve(__dirname, "../..");
24
+
25
+ function hasContextConfig(candidate: string): boolean {
26
+ return fs.existsSync(path.join(candidate, ".context", "config.yaml"));
27
+ }
28
+
29
+ function resolveFrom(startDir: string): string | null {
30
+ let current = path.resolve(startDir);
31
+ while (true) {
32
+ if (hasContextConfig(current)) {
33
+ return current;
34
+ }
35
+
36
+ const parent = path.dirname(current);
37
+ if (parent === current) {
38
+ return null;
39
+ }
40
+ current = parent;
41
+ }
42
+ }
43
+
44
+ function resolveRepoRoot(): string {
45
+ const candidates = [
46
+ PROJECT_ROOT_OVERRIDE ? path.resolve(normalizeForWsl(PROJECT_ROOT_OVERRIDE)) : null,
47
+ process.cwd(),
48
+ __dirname,
49
+ process.env.INIT_CWD?.trim() ? path.resolve(normalizeForWsl(process.env.INIT_CWD.trim())) : null
50
+ ].filter((value): value is string => Boolean(value));
51
+
52
+ for (const candidate of candidates) {
53
+ const resolved = resolveFrom(candidate);
54
+ if (resolved) {
55
+ return resolved;
56
+ }
57
+ }
58
+
59
+ return path.resolve(__dirname, "../../..");
60
+ }
61
+
62
+ export const REPO_ROOT = resolveRepoRoot();
27
63
  export const CONTEXT_DIR = path.join(REPO_ROOT, ".context");
28
64
  export const CACHE_DIR = path.join(CONTEXT_DIR, "cache");
29
65
  export const DB_PATH = path.join(CONTEXT_DIR, "db", "graph.ryu");
@@ -0,0 +1,38 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { spawnSync } from "node:child_process";
7
+ import { pathToFileURL } from "node:url";
8
+
9
+ test("paths resolve the project root from cwd without duplicating .context", () => {
10
+ const projectRoot = mkdtempSync(path.join(tmpdir(), "cortex-paths-"));
11
+ const contextDir = path.join(projectRoot, ".context");
12
+ const mcpDir = path.join(contextDir, "mcp");
13
+ const pathsModuleUrl = pathToFileURL(path.resolve("dist/paths.js")).href;
14
+
15
+ mkdirSync(mcpDir, { recursive: true });
16
+ writeFileSync(path.join(contextDir, "config.yaml"), "source_paths:\n - src\n");
17
+
18
+ const result = spawnSync(
19
+ process.execPath,
20
+ [
21
+ "--input-type=module",
22
+ "-e",
23
+ `import { REPO_ROOT, CONTEXT_DIR, CACHE_DIR } from ${JSON.stringify(pathsModuleUrl)}; console.log(JSON.stringify({ REPO_ROOT, CONTEXT_DIR, CACHE_DIR }));`
24
+ ],
25
+ {
26
+ cwd: mcpDir,
27
+ encoding: "utf8"
28
+ }
29
+ );
30
+
31
+ assert.equal(result.status, 0, result.stderr);
32
+
33
+ const parsed = JSON.parse(result.stdout.trim());
34
+ const resolvedProjectRoot = realpathSync(projectRoot);
35
+ assert.equal(parsed.REPO_ROOT, resolvedProjectRoot);
36
+ assert.equal(parsed.CONTEXT_DIR, path.join(resolvedProjectRoot, ".context"));
37
+ assert.equal(parsed.CACHE_DIR, path.join(resolvedProjectRoot, ".context", "cache"));
38
+ });
@@ -12,4 +12,4 @@ fi
12
12
  mkdir -p "$MCP_DIR/.npm-cache"
13
13
 
14
14
  echo "[embed] generating embeddings via .context/mcp/embed"
15
- NPM_CONFIG_CACHE="$MCP_DIR/.npm-cache" npm --prefix "$MCP_DIR" run embed --silent -- "$@"
15
+ CORTEX_PROJECT_ROOT="$REPO_ROOT" NPM_CONFIG_CACHE="$MCP_DIR/.npm-cache" npm --prefix "$MCP_DIR" run embed --silent -- "$@"
@@ -15,4 +15,4 @@ if [[ ! -d "$MCP_DIR/node_modules" ]]; then
15
15
  exit 1
16
16
  fi
17
17
 
18
- NPM_CONFIG_CACHE="$MCP_DIR/.npm-cache" npm --prefix "$MCP_DIR" run graph:load -- "$@"
18
+ CORTEX_PROJECT_ROOT="$REPO_ROOT" NPM_CONFIG_CACHE="$MCP_DIR/.npm-cache" npm --prefix "$MCP_DIR" run graph:load -- "$@"