@danielblomma/cortex-mcp 2.0.18 → 2.1.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.
Files changed (34) hide show
  1. package/README.md +27 -1
  2. package/bin/cortex.mjs +81 -13
  3. package/package.json +3 -2
  4. package/scaffold/.context/config.yaml +7 -5
  5. package/scaffold/.githooks/_cortex-update-runner.sh +2 -2
  6. package/scaffold/mcp/package-lock.json +3 -3
  7. package/scaffold/mcp/src/embed.ts +32 -35
  8. package/scaffold/mcp/src/embeddings.ts +1 -1
  9. package/scaffold/mcp/src/graph.ts +3 -3
  10. package/scaffold/mcp/src/paths.ts +5 -2
  11. package/scaffold/mcp/src/searchResults.ts +34 -1
  12. package/scaffold/mcp/tests/embed-entities.test.mjs +72 -0
  13. package/scaffold/mcp/tests/search-graph-score.test.mjs +132 -0
  14. package/scaffold/scripts/bootstrap.sh +9 -8
  15. package/scaffold/scripts/context.sh +1 -1
  16. package/scaffold/scripts/dashboard.mjs +3 -1
  17. package/scaffold/scripts/dashboard.sh +1 -1
  18. package/scaffold/scripts/doctor.sh +2 -1
  19. package/scaffold/scripts/embed.sh +2 -1
  20. package/scaffold/scripts/ingest.mjs +24 -2
  21. package/scaffold/scripts/ingest.sh +3 -2
  22. package/scaffold/scripts/install-git-hooks.sh +2 -1
  23. package/scaffold/scripts/load-kuzu.sh +2 -2
  24. package/scaffold/scripts/load-ryu.sh +2 -1
  25. package/scaffold/scripts/memory-compile.mjs +2 -2
  26. package/scaffold/scripts/memory-compile.sh +3 -2
  27. package/scaffold/scripts/memory-lint.mjs +2 -2
  28. package/scaffold/scripts/memory-lint.sh +3 -2
  29. package/scaffold/scripts/parsers/markdown.mjs +63 -0
  30. package/scaffold/scripts/parsers/package-lock.json +0 -1
  31. package/scaffold/scripts/refresh.sh +2 -2
  32. package/scaffold/scripts/status.sh +6 -5
  33. package/scaffold/scripts/update-context.sh +5 -5
  34. package/scaffold/scripts/watch.sh +7 -8
package/README.md CHANGED
@@ -87,6 +87,32 @@ The result is an assistant that behaves as if it already knows your codebase, be
87
87
  npm i -g @danielblomma/cortex-mcp
88
88
  ```
89
89
 
90
+ ## Upgrading
91
+
92
+ To upgrade an already-scaffolded project to a new Cortex version:
93
+
94
+ ```bash
95
+ npm i -g @danielblomma/cortex-mcp
96
+ cortex init --force # re-scaffolds .context/mcp + .context/scripts
97
+ cortex bootstrap
98
+ cortex update
99
+ ```
100
+
101
+ `cortex init --force` preserves per-project files: `.context/config.yaml`,
102
+ `.context/rules.yaml`, and your notes/decisions.
103
+
104
+ Version-specific notes (see [CHANGELOG.md](CHANGELOG.md) for details):
105
+
106
+ - **2.1.0**: the default embedding model changed, so the first
107
+ `cortex update` after upgrading triggers a full re-embed automatically
108
+ (~2 min per 1000 entities plus a one-time model download). The
109
+ `CORTEX_EMBED_MAX_CHARS` env var is removed and silently ignored.
110
+ Existing projects keep their old ranking weights in `config.yaml`; the
111
+ recommended block is now `semantic: 0.55, graph: 0.10, trust: 0.20,
112
+ recency: 0.15`. After re-embedding, restart the MCP server, and note
113
+ that the first search after a re-embed can hit a stale embeddings
114
+ cache — re-run the query.
115
+
90
116
  ## Quick Start
91
117
 
92
118
  From the repository you want to index:
@@ -97,7 +123,7 @@ cortex init --bootstrap
97
123
 
98
124
  This will:
99
125
 
100
- - scaffold `.context/`, `scripts/`, `mcp/`, `.githooks/`, and docs files
126
+ - scaffold `.context/`, `.context/scripts/`, `.context/mcp/`, `.githooks/`, and docs files
101
127
  - activate git hooks for checkout, pull/merge, commit, and rewrite events
102
128
  - build and prepare the local MCP server
103
129
  - try to auto-register MCP connections for Claude/Codex (if installed)
package/bin/cortex.mjs CHANGED
@@ -27,11 +27,14 @@ const PACKAGE_JSON_PATH = path.join(PACKAGE_ROOT, "package.json");
27
27
  // only the three editable config files". Generated artifacts (db,
28
28
  // embeddings, cache, hooks, mcp/, govern.local.json) never land in git.
29
29
  const MCP_PROJECT_REL = path.join(".context", "mcp");
30
+ const CONTEXT_SCRIPTS_REL = path.join(".context", "scripts");
30
31
 
32
+ // `.context/*` (not `.context/`) so the !-negations below actually re-include
33
+ // the config files — git can't re-include children of an excluded directory.
31
34
  const GITIGNORE_LINES = [
32
35
  "",
33
36
  "# Cortex local storage",
34
- ".context/",
37
+ ".context/*",
35
38
  "!.context/config.yaml",
36
39
  "!.context/rules.yaml",
37
40
  "!.context/ontology.cypher",
@@ -188,7 +191,7 @@ function ensureScaffoldExists() {
188
191
 
189
192
  // Files that should never be overwritten if they already exist in the target.
190
193
  // These contain user-specific configuration that would be lost on re-init.
191
- const PRESERVE_FILES = new Set(["config.yaml", "enterprise.yml", "enterprise.yaml", "CLAUDE.md", "AGENTS.md"]);
194
+ const PRESERVE_FILES = new Set(["config.yaml", "rules.yaml", "enterprise.yml", "enterprise.yaml", "CLAUDE.md", "AGENTS.md"]);
192
195
  const DEFAULT_SOURCE_PATHS = [
193
196
  "src",
194
197
  "docs",
@@ -407,9 +410,12 @@ function buildInitialConfig(targetDir) {
407
410
  " - RULE",
408
411
  " - CODE",
409
412
  " - WIKI",
413
+ "# graph weight is low because graph degree mostly measures how many rules",
414
+ "# constrain an entity (docs are hubs, leaf code is not). Tuned as a pair with",
415
+ "# the midrank-percentile graph_score.",
410
416
  "ranking:",
411
- " semantic: 0.40",
412
- " graph: 0.25",
417
+ " semantic: 0.55",
418
+ " graph: 0.10",
413
419
  " trust: 0.20",
414
420
  " recency: 0.15",
415
421
  "runtime:",
@@ -461,12 +467,73 @@ function migrateLegacyMcpLocation(targetDir) {
461
467
  );
462
468
  }
463
469
 
470
+ const LEGACY_SCRIPT_ENTRIES = [
471
+ "bootstrap.sh",
472
+ "context.sh",
473
+ "dashboard.mjs",
474
+ "dashboard.sh",
475
+ "doctor.sh",
476
+ "embed.sh",
477
+ "ingest.mjs",
478
+ "ingest.sh",
479
+ "install-git-hooks.sh",
480
+ "load-kuzu.sh",
481
+ "load-ryu.sh",
482
+ "memory-compile.mjs",
483
+ "memory-compile.sh",
484
+ "memory-lint.mjs",
485
+ "memory-lint.sh",
486
+ "refresh.sh",
487
+ "status.sh",
488
+ "update-context.sh",
489
+ "watch.sh",
490
+ "lib",
491
+ "parsers"
492
+ ];
493
+
494
+ function looksLikeLegacyCortexScriptsDir(scriptsDir) {
495
+ const contextScript = path.join(scriptsDir, "context.sh");
496
+ if (!fs.existsSync(contextScript)) {
497
+ return false;
498
+ }
499
+ try {
500
+ const contents = fs.readFileSync(contextScript, "utf8");
501
+ return contents.includes("bootstrap)") && contents.includes("graph-load)") && contents.includes("memory-lint)");
502
+ } catch {
503
+ return false;
504
+ }
505
+ }
506
+
507
+ function removeLegacyCortexScripts(targetDir) {
508
+ if (path.resolve(targetDir) === PACKAGE_ROOT) {
509
+ return;
510
+ }
511
+
512
+ const scriptsDir = path.join(targetDir, "scripts");
513
+ if (!looksLikeLegacyCortexScriptsDir(scriptsDir)) {
514
+ return;
515
+ }
516
+
517
+ for (const entry of LEGACY_SCRIPT_ENTRIES) {
518
+ fs.rmSync(path.join(scriptsDir, entry), { recursive: true, force: true });
519
+ }
520
+
521
+ try {
522
+ if (fs.existsSync(scriptsDir) && fs.readdirSync(scriptsDir).length === 0) {
523
+ fs.rmdirSync(scriptsDir);
524
+ }
525
+ } catch {
526
+ // Best effort. A user's own files in scripts/ must remain untouched.
527
+ }
528
+ }
529
+
464
530
  function installScaffold(targetDir, force) {
465
531
  migrateLegacyMcpLocation(targetDir);
532
+ removeLegacyCortexScripts(targetDir);
466
533
 
467
534
  const copyMap = [
468
535
  [path.join(SCAFFOLD_ROOT, ".context"), path.join(targetDir, ".context")],
469
- [path.join(SCAFFOLD_ROOT, "scripts"), path.join(targetDir, "scripts")],
536
+ [path.join(SCAFFOLD_ROOT, "scripts"), path.join(targetDir, CONTEXT_SCRIPTS_REL)],
470
537
  [path.join(SCAFFOLD_ROOT, "mcp"), path.join(targetDir, MCP_PROJECT_REL)],
471
538
  [path.join(SCAFFOLD_ROOT, ".githooks"), path.join(targetDir, ".githooks")]
472
539
  ];
@@ -720,7 +787,7 @@ async function connectMcpClients(targetDir, options = {}) {
720
787
  }
721
788
 
722
789
  async function maybeInstallGitHooks(targetDir) {
723
- const installScript = path.join(targetDir, "scripts", "install-git-hooks.sh");
790
+ const installScript = path.join(targetDir, CONTEXT_SCRIPTS_REL, "install-git-hooks.sh");
724
791
  if (!fs.existsSync(installScript)) {
725
792
  return false;
726
793
  }
@@ -758,16 +825,17 @@ function isTruthyEnv(value) {
758
825
  function canAutoInitialize(targetDir) {
759
826
  // Legacy mcp/ at root no longer counted — pre-v2.0.5 projects are migrated
760
827
  // by installScaffold rather than blocking auto-init.
761
- const scaffoldPaths = [".context", "scripts", ".githooks"].map((entry) => path.join(targetDir, entry));
828
+ const scaffoldPaths = [".context", ".githooks"].map((entry) => path.join(targetDir, entry));
762
829
  return scaffoldPaths.every((entryPath) => !fs.existsSync(entryPath));
763
830
  }
764
831
 
765
832
  function isScaffoldOutOfDate(targetDir) {
766
- const contextScript = path.join(targetDir, "scripts", "context.sh");
833
+ const contextScript = path.join(targetDir, CONTEXT_SCRIPTS_REL, "context.sh");
834
+ const legacyContextScript = path.join(targetDir, "scripts", "context.sh");
767
835
  if (!fs.existsSync(contextScript)) {
768
- return false;
836
+ return fs.existsSync(legacyContextScript);
769
837
  }
770
- const doctorScript = path.join(targetDir, "scripts", "doctor.sh");
838
+ const doctorScript = path.join(targetDir, CONTEXT_SCRIPTS_REL, "doctor.sh");
771
839
  if (!fs.existsSync(doctorScript)) {
772
840
  return true;
773
841
  }
@@ -812,7 +880,7 @@ async function maybeMigrateScaffold(targetDir, command) {
812
880
 
813
881
  console.error(
814
882
  `[cortex] scaffold in ${targetDir} is out of date ` +
815
- `(missing scripts/doctor.sh, .context/mcp/package.json, doctor subcommand in context.sh, ` +
883
+ `(missing .context/scripts/doctor.sh, .context/mcp/package.json, doctor subcommand in context.sh, ` +
816
884
  `or carries a legacy mcp/ directory at the project root).`
817
885
  );
818
886
 
@@ -881,7 +949,7 @@ async function ensureProjectInitializedForMcp(targetDir) {
881
949
  }
882
950
 
883
951
  async function runContextCommand(cwd, contextArgs) {
884
- const contextScript = path.join(cwd, "scripts", "context.sh");
952
+ const contextScript = path.join(cwd, CONTEXT_SCRIPTS_REL, "context.sh");
885
953
  if (!fs.existsSync(contextScript)) {
886
954
  throw new Error(`Missing ${contextScript}. Run 'cortex init' first.`);
887
955
  }
@@ -915,7 +983,7 @@ async function run() {
915
983
  await maybeInstallGitHooks(target);
916
984
 
917
985
  console.log(`[cortex] initialized in ${target}`);
918
- console.log("[cortex] scaffold copied: .context/, scripts/, mcp/, .githooks/, docs/");
986
+ console.log("[cortex] scaffold copied: .context/, .context/scripts/, .context/mcp/, .githooks/, docs/");
919
987
  console.log(`[cortex] Claude commands ready: /context-update (${helpers.claude.total} files)`);
920
988
  if (helpers.codex.changed) {
921
989
  console.log("[cortex] Codex workflow instructions added to AGENTS.md");
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.18",
4
+ "version": "2.1.0",
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",
@@ -49,7 +49,8 @@
49
49
  "docs/MCP_MARKETPLACE.md"
50
50
  ],
51
51
  "scripts": {
52
- "test": "node tests/context-regressions.test.mjs && node --test tests/ingest-units.test.mjs tests/javascript-parser.test.mjs tests/sql-parser.test.mjs tests/config-parser.test.mjs tests/resources-parser.test.mjs tests/vbnet-parser.test.mjs tests/cpp-parser.test.mjs tests/dashboard.test.mjs tests/init-config.test.mjs tests/init-agents.test.mjs tests/multi-level.test.mjs tests/no-legacy-paths.test.mjs tests/tree-sitter-error-reporting.test.mjs tests/tree-sitter-body-cap.test.mjs tests/tree-sitter-exported.test.mjs tests/tree-sitter-robustness.test.mjs",
52
+ "pretest": "test -d scaffold/scripts/parsers/node_modules || npm --prefix scaffold/scripts/parsers install --no-fund --no-update-notifier --silent",
53
+ "test": "node tests/context-regressions.test.mjs && node --test tests/ingest-units.test.mjs tests/javascript-parser.test.mjs tests/markdown-parser.test.mjs tests/sql-parser.test.mjs tests/config-parser.test.mjs tests/resources-parser.test.mjs tests/vbnet-parser.test.mjs tests/cpp-parser.test.mjs tests/dashboard.test.mjs tests/init-config.test.mjs tests/init-agents.test.mjs tests/multi-level.test.mjs tests/no-legacy-paths.test.mjs tests/tree-sitter-error-reporting.test.mjs tests/tree-sitter-body-cap.test.mjs tests/tree-sitter-exported.test.mjs tests/tree-sitter-robustness.test.mjs",
53
54
  "release:sync-version": "node scripts/sync-release-version.mjs",
54
55
  "release:check-version-sync": "node scripts/sync-release-version.mjs --check",
55
56
  "prepublishOnly": "echo 'Ready to publish to npm'"
@@ -1,19 +1,21 @@
1
1
  repo_id: cortex
2
2
  source_paths:
3
- - src
3
+ - scripts
4
+ - mcp/src
4
5
  - docs
5
- - design
6
6
  - .context/notes
7
7
  - .context/decisions
8
- - README.md
9
8
  truth_order:
10
9
  - ADR
11
10
  - RULE
12
11
  - CODE
13
12
  - WIKI
13
+ # graph weight is low because graph degree mostly measures how many rules
14
+ # constrain an entity (docs are hubs, leaf code is not). Tuned as a pair with
15
+ # the midrank-percentile graph_score.
14
16
  ranking:
15
- semantic: 0.40
16
- graph: 0.25
17
+ semantic: 0.55
18
+ graph: 0.10
17
19
  trust: 0.20
18
20
  recency: 0.15
19
21
  runtime:
@@ -6,12 +6,12 @@ if [[ -z "$REPO_ROOT" ]]; then
6
6
  exit 0
7
7
  fi
8
8
 
9
- CONTEXT_SCRIPT="$REPO_ROOT/scripts/context.sh"
9
+ CONTEXT_SCRIPT="$REPO_ROOT/.context/scripts/context.sh"
10
10
  if [[ ! -x "$CONTEXT_SCRIPT" ]]; then
11
11
  exit 0
12
12
  fi
13
13
 
14
- PARSER_MODULES_DIR="$REPO_ROOT/scripts/parsers/node_modules"
14
+ PARSER_MODULES_DIR="$REPO_ROOT/.context/scripts/parsers/node_modules"
15
15
 
16
16
  HOOK_DIR="$REPO_ROOT/.context/hooks"
17
17
  LOCK_DIR="$HOOK_DIR/update.lock"
@@ -2165,9 +2165,9 @@
2165
2165
  }
2166
2166
  },
2167
2167
  "node_modules/qs": {
2168
- "version": "6.15.0",
2169
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
2170
- "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
2168
+ "version": "6.15.2",
2169
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
2170
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
2171
2171
  "license": "BSD-3-Clause",
2172
2172
  "dependencies": {
2173
2173
  "side-channel": "^1.1.0"
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
4
5
  import { env, pipeline } from "@huggingface/transformers";
5
6
  import { readJsonl, asString, asNumber, asBoolean } from "./jsonl.js";
6
7
  import { CACHE_DIR, PATHS } from "./paths.js";
@@ -11,9 +12,11 @@ const EMBEDDINGS_MANIFEST_PATH = PATHS.embeddingsManifest;
11
12
  const MODEL_CACHE_DIR = PATHS.embeddingsModelCache;
12
13
  const EMBEDDINGS_DIR = path.dirname(EMBEDDINGS_PATH);
13
14
 
14
- const DEFAULT_MODEL_ID = "Xenova/all-MiniLM-L6-v2";
15
- const DEFAULT_MAX_TEXT_CHARS = 7000;
16
- const CHUNK_BODY_PREVIEW_CHARS = 2000;
15
+ export const DEFAULT_MODEL_ID = "jinaai/jina-embeddings-v2-base-code";
16
+
17
+ export function resolveModelId(): string {
18
+ return (process.env.CORTEX_EMBED_MODEL ?? DEFAULT_MODEL_ID).trim() || DEFAULT_MODEL_ID;
19
+ }
17
20
 
18
21
  type FileEntity = {
19
22
  id: string;
@@ -134,10 +137,6 @@ function normalizeText(value: string): string {
134
137
  return value.replace(/\s+/g, " ").trim();
135
138
  }
136
139
 
137
- function clampText(value: string, maxChars: number): string {
138
- return value.slice(0, maxChars);
139
- }
140
-
141
140
  function writeJsonl(filePath: string, records: EmbeddingRecord[]): void {
142
141
  const body = records.map((record) => JSON.stringify(record)).join("\n");
143
142
  fs.writeFileSync(filePath, body ? `${body}\n` : "", "utf8");
@@ -157,7 +156,7 @@ function ensureRequiredFiles(): void {
157
156
  }
158
157
  }
159
158
 
160
- function parseFileEntities(raw: JsonObject[], maxChars: number): FileEntity[] {
159
+ export function parseFileEntities(raw: JsonObject[]): FileEntity[] {
161
160
  return raw
162
161
  .map((item) => {
163
162
  const id = asString(item.id);
@@ -170,7 +169,7 @@ function parseFileEntities(raw: JsonObject[], maxChars: number): FileEntity[] {
170
169
  const excerpt = asString(item.excerpt);
171
170
  const updatedAt = asString(item.updated_at);
172
171
  const checksum = asString(item.checksum, hashText(content));
173
- const text = clampText(`${filePath}\n${excerpt}\n${content}`, maxChars);
172
+ const text = `${filePath}\n${excerpt}\n${content}`;
174
173
 
175
174
  return {
176
175
  id,
@@ -189,7 +188,7 @@ function parseFileEntities(raw: JsonObject[], maxChars: number): FileEntity[] {
189
188
  .filter((value): value is FileEntity => value !== null);
190
189
  }
191
190
 
192
- function parseRuleEntities(raw: JsonObject[], maxChars: number): RuleEntity[] {
191
+ function parseRuleEntities(raw: JsonObject[]): RuleEntity[] {
193
192
  return raw
194
193
  .map((item) => {
195
194
  const id = asString(item.id);
@@ -200,7 +199,7 @@ function parseRuleEntities(raw: JsonObject[], maxChars: number): RuleEntity[] {
200
199
  const title = asString(item.title, id);
201
200
  const body = asString(item.body);
202
201
  const updatedAt = asString(item.updated_at, "");
203
- const text = clampText(`${title}\n${body}`, maxChars);
202
+ const text = `${title}\n${body}`;
204
203
 
205
204
  return {
206
205
  id,
@@ -219,7 +218,7 @@ function parseRuleEntities(raw: JsonObject[], maxChars: number): RuleEntity[] {
219
218
  .filter((value): value is RuleEntity => value !== null);
220
219
  }
221
220
 
222
- function parseAdrEntities(raw: JsonObject[], maxChars: number): AdrEntity[] {
221
+ function parseAdrEntities(raw: JsonObject[]): AdrEntity[] {
223
222
  return raw
224
223
  .map((item) => {
225
224
  const id = asString(item.id);
@@ -231,7 +230,7 @@ function parseAdrEntities(raw: JsonObject[], maxChars: number): AdrEntity[] {
231
230
  const body = asString(item.body);
232
231
  const adrPath = asString(item.path);
233
232
  const decisionDate = asString(item.decision_date, "");
234
- const text = clampText(`${adrPath}\n${title}\n${body}`, maxChars);
233
+ const text = `${adrPath}\n${title}\n${body}`;
235
234
 
236
235
  return {
237
236
  id,
@@ -250,7 +249,7 @@ function parseAdrEntities(raw: JsonObject[], maxChars: number): AdrEntity[] {
250
249
  .filter((value): value is AdrEntity => value !== null);
251
250
  }
252
251
 
253
- function parseModuleEntities(raw: JsonObject[], maxChars: number): ModuleEntity[] {
252
+ function parseModuleEntities(raw: JsonObject[]): ModuleEntity[] {
254
253
  return raw
255
254
  .map((item) => {
256
255
  const id = asString(item.id);
@@ -263,7 +262,7 @@ function parseModuleEntities(raw: JsonObject[], maxChars: number): ModuleEntity[
263
262
  const summary = asString(item.summary);
264
263
  const exportedSymbols = asString(item.exported_symbols);
265
264
  const updatedAt = asString(item.updated_at);
266
- const text = clampText(`${modulePath}\n${name}\n${summary}\n${exportedSymbols}`, maxChars);
265
+ const text = `${modulePath}\n${name}\n${summary}\n${exportedSymbols}`;
267
266
 
268
267
  return {
269
268
  id,
@@ -282,7 +281,7 @@ function parseModuleEntities(raw: JsonObject[], maxChars: number): ModuleEntity[
282
281
  .filter((value): value is ModuleEntity => value !== null);
283
282
  }
284
283
 
285
- function parseChunkEntities(raw: JsonObject[], filePathById: Map<string, string>, maxChars: number): ChunkEntity[] {
284
+ export function parseChunkEntities(raw: JsonObject[], filePathById: Map<string, string>): ChunkEntity[] {
286
285
  return raw
287
286
  .map((item) => {
288
287
  const id = asString(item.id);
@@ -298,7 +297,7 @@ function parseChunkEntities(raw: JsonObject[], filePathById: Map<string, string>
298
297
  const body = asString(item.body);
299
298
  const updatedAt = asString(item.updated_at);
300
299
  const checksum = asString(item.checksum, hashText(body));
301
- const text = clampText(`${filePath}\n${name}\n${sig}\n${description}\n${body.slice(0, CHUNK_BODY_PREVIEW_CHARS)}`, maxChars);
300
+ const text = `${filePath}\n${name}\n${sig}\n${description}\n${body}`;
302
301
 
303
302
  return {
304
303
  id,
@@ -317,7 +316,7 @@ function parseChunkEntities(raw: JsonObject[], filePathById: Map<string, string>
317
316
  .filter((value): value is ChunkEntity => value !== null);
318
317
  }
319
318
 
320
- function parseProjectEntities(raw: JsonObject[], maxChars: number): ProjectEntity[] {
319
+ function parseProjectEntities(raw: JsonObject[]): ProjectEntity[] {
321
320
  return raw
322
321
  .map((item) => {
323
322
  const id = asString(item.id);
@@ -332,10 +331,7 @@ function parseProjectEntities(raw: JsonObject[], maxChars: number): ProjectEntit
332
331
  const targetFramework = asString(item.target_framework);
333
332
  const summary = asString(item.summary);
334
333
  const updatedAt = asString(item.updated_at);
335
- const text = clampText(
336
- `${projectPath}\n${name}\n${kind}\n${language}\n${targetFramework}\n${summary}`,
337
- maxChars
338
- );
334
+ const text = `${projectPath}\n${name}\n${kind}\n${language}\n${targetFramework}\n${summary}`;
339
335
 
340
336
  return {
341
337
  id,
@@ -416,22 +412,20 @@ async function main(): Promise<void> {
416
412
  fs.mkdirSync(EMBEDDINGS_DIR, { recursive: true });
417
413
  fs.mkdirSync(MODEL_CACHE_DIR, { recursive: true });
418
414
 
419
- const modelId = (process.env.CORTEX_EMBED_MODEL ?? DEFAULT_MODEL_ID).trim() || DEFAULT_MODEL_ID;
420
- const maxChars = Number(process.env.CORTEX_EMBED_MAX_CHARS ?? DEFAULT_MAX_TEXT_CHARS);
421
- const maxTextChars = Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : DEFAULT_MAX_TEXT_CHARS;
415
+ const modelId = resolveModelId();
422
416
 
423
- const documents = parseFileEntities(readJsonl(path.join(CACHE_DIR, "documents.jsonl")), maxTextChars);
424
- const rules = parseRuleEntities(readJsonl(path.join(CACHE_DIR, "entities.rule.jsonl")), maxTextChars);
425
- const adrs = parseAdrEntities(readJsonl(path.join(CACHE_DIR, "entities.adr.jsonl")), maxTextChars);
426
- const modules = parseModuleEntities(readJsonl(path.join(CACHE_DIR, "entities.module.jsonl")), maxTextChars);
427
- const projects = parseProjectEntities(readJsonl(path.join(CACHE_DIR, "entities.project.jsonl")), maxTextChars);
417
+ const documents = parseFileEntities(readJsonl(path.join(CACHE_DIR, "documents.jsonl")));
418
+ const rules = parseRuleEntities(readJsonl(path.join(CACHE_DIR, "entities.rule.jsonl")));
419
+ const adrs = parseAdrEntities(readJsonl(path.join(CACHE_DIR, "entities.adr.jsonl")));
420
+ const modules = parseModuleEntities(readJsonl(path.join(CACHE_DIR, "entities.module.jsonl")));
421
+ const projects = parseProjectEntities(readJsonl(path.join(CACHE_DIR, "entities.project.jsonl")));
428
422
 
429
423
  // Build file path lookup for chunk embedding text (reuse already-parsed documents)
430
424
  const filePathById = new Map<string, string>();
431
425
  for (const doc of documents) {
432
426
  filePathById.set(doc.id, doc.path);
433
427
  }
434
- const chunks = parseChunkEntities(readJsonl(path.join(CACHE_DIR, "entities.chunk.jsonl")), filePathById, maxTextChars);
428
+ const chunks = parseChunkEntities(readJsonl(path.join(CACHE_DIR, "entities.chunk.jsonl")), filePathById);
435
429
 
436
430
  const entities: SearchEntity[] = [...documents, ...rules, ...adrs, ...modules, ...projects, ...chunks].sort((a, b) => a.id.localeCompare(b.id));
437
431
 
@@ -531,7 +525,10 @@ async function main(): Promise<void> {
531
525
  console.log(`[embed] manifest ${EMBEDDINGS_MANIFEST_PATH}`);
532
526
  }
533
527
 
534
- main().catch((error) => {
535
- process.stderr.write(`${error instanceof Error ? error.message : "Embedding error"}\n`);
536
- process.exit(1);
537
- });
528
+ const isMain = process.argv[1] ? pathToFileURL(process.argv[1]).href === import.meta.url : false;
529
+ if (isMain) {
530
+ main().catch((error) => {
531
+ process.stderr.write(`${error instanceof Error ? error.message : "Embedding error"}\n`);
532
+ process.exit(1);
533
+ });
534
+ }
@@ -101,7 +101,7 @@ export function loadEmbeddingIndex(): EmbeddingIndex {
101
101
  embeddingsCache = {
102
102
  model: null,
103
103
  vectors: new Map(),
104
- warning: "Embedding index missing (run: ./scripts/context.sh embed)"
104
+ warning: "Embedding index missing (run: cortex embed)"
105
105
  };
106
106
  return embeddingsCache;
107
107
  }
@@ -419,8 +419,8 @@ function readGraphSignature(): string | null {
419
419
 
420
420
  function buildMissingDbMessage(): string {
421
421
  const dbDir = path.dirname(DB_PATH);
422
- const loadCommand = "./scripts/context.sh graph-load";
423
- const bootstrapCommand = "./scripts/context.sh bootstrap";
422
+ const loadCommand = "cortex graph-load";
423
+ const bootstrapCommand = "cortex bootstrap";
424
424
 
425
425
  if (!fs.existsSync(dbDir)) {
426
426
  return `RyuGraph directory missing at ${dbDir}. Run ${bootstrapCommand}.`;
@@ -430,7 +430,7 @@ function buildMissingDbMessage(): string {
430
430
  }
431
431
 
432
432
  function buildIncompatibleGraphMessage(missingKeys: string[]): string {
433
- const loadCommand = "./scripts/context.sh graph-load";
433
+ const loadCommand = "cortex graph-load";
434
434
  const missing = missingKeys.join(", ");
435
435
  return `RyuGraph manifest is missing schema keys (${missing}). Run ${loadCommand} to rebuild the graph DB.`;
436
436
  }
@@ -98,9 +98,12 @@ export const PATHS = {
98
98
  transformsConfigRelations: path.join(CACHE_DIR, "relations.transforms_config.jsonl")
99
99
  };
100
100
 
101
+ // Graph degree mostly measures how many rules constrain an entity (docs are
102
+ // hubs, leaf code is not), so it gets low weight. Tuned together with the
103
+ // midrank-percentile graph_score in searchResults.ts.
101
104
  export const DEFAULT_RANKING: RankingWeights = {
102
- semantic: 0.4,
103
- graph: 0.25,
105
+ semantic: 0.55,
106
+ graph: 0.1,
104
107
  trust: 0.2,
105
108
  recency: 0.15
106
109
  };
@@ -33,6 +33,38 @@ export function buildSearchResults(params: {
33
33
  recencyScorer: (isoDate: string) => number;
34
34
  legacyDataAccessBooster: (entity: SearchEntity, queryTokens: string[], queryPhrase: string) => number;
35
35
  }): Record<string, unknown>[] {
36
+ // Graph score = midrank percentile of relation degree within the entity's
37
+ // own type. The previous min(1, degree/4) saturated at degree >= 4, which
38
+ // nearly every entity exceeds, making the graph weight a constant. Per-type
39
+ // percentiles discriminate by connectivity while staying type-neutral
40
+ // (every type averages ~0.5), so hub-heavy types like rules cannot drown
41
+ // out leaf code files or doc sections.
42
+ const sortedDegreesByType = new Map<string, number[]>();
43
+ for (const entity of params.candidates) {
44
+ const degree = params.degreeByEntity.get(entity.id) ?? 0;
45
+ const list = sortedDegreesByType.get(entity.entity_type);
46
+ if (list) {
47
+ list.push(degree);
48
+ } else {
49
+ sortedDegreesByType.set(entity.entity_type, [degree]);
50
+ }
51
+ }
52
+ for (const list of sortedDegreesByType.values()) {
53
+ list.sort((a, b) => a - b);
54
+ }
55
+ const midrankPercentile = (sorted: number[], value: number): number => {
56
+ let lo = 0;
57
+ let hi = sorted.length;
58
+ while (lo < hi) {
59
+ const mid = (lo + hi) >> 1;
60
+ if (sorted[mid] < value) lo = mid + 1;
61
+ else hi = mid;
62
+ }
63
+ let upper = lo;
64
+ while (upper < sorted.length && sorted[upper] === value) upper += 1;
65
+ return sorted.length > 0 ? (lo + upper) / (2 * sorted.length) : 0;
66
+ };
67
+
36
68
  const rawResults = params.candidates
37
69
  .map((entity) => {
38
70
  const lexicalSemantic = params.semanticScorer(params.queryTokens, params.queryPhrase, entity.text);
@@ -49,7 +81,8 @@ export function buildSearchResults(params: {
49
81
 
50
82
  const semantic =
51
83
  vectorSemantic > 0 ? vectorSemantic * 0.75 + lexicalSemantic * 0.25 : lexicalSemantic;
52
- const graphScore = Math.min(1, (params.degreeByEntity.get(entity.id) ?? 0) / 4);
84
+ const degree = params.degreeByEntity.get(entity.id) ?? 0;
85
+ const graphScore = midrankPercentile(sortedDegreesByType.get(entity.entity_type) ?? [], degree);
53
86
  const trustScore = Math.max(0, Math.min(1, entity.trust_level / 100));
54
87
  const dateScore = params.recencyScorer(entity.updated_at);
55
88
 
@@ -0,0 +1,72 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseFileEntities, parseChunkEntities, resolveModelId, DEFAULT_MODEL_ID } from "../dist/embed.js";
4
+
5
+ function fileRecord(content) {
6
+ return {
7
+ id: "file-1",
8
+ path: "src/big.ts",
9
+ content,
10
+ excerpt: "excerpt",
11
+ updated_at: "2026-01-01T00:00:00Z",
12
+ checksum: "fixed-checksum"
13
+ };
14
+ }
15
+
16
+ function chunkRecord(body) {
17
+ return {
18
+ id: "chunk-1",
19
+ file_id: "file-1",
20
+ name: "bigFunction",
21
+ signature: "function bigFunction()",
22
+ description: "",
23
+ body,
24
+ updated_at: "2026-01-01T00:00:00Z",
25
+ checksum: "fixed-checksum"
26
+ };
27
+ }
28
+
29
+ test("file embedding text is uncapped beyond the old 7000-char limit", () => {
30
+ const base = "x".repeat(8000);
31
+ const variant = `${"x".repeat(7500)}CHANGED${"x".repeat(493)}`;
32
+
33
+ const [a] = parseFileEntities([fileRecord(base)]);
34
+ const [b] = parseFileEntities([fileRecord(variant)]);
35
+
36
+ assert.ok(a.text.length > 7000);
37
+ assert.ok(a.text.includes(base));
38
+ assert.notEqual(a.signature, b.signature);
39
+ });
40
+
41
+ test("chunk embedding text includes the full body beyond the old 2000-char preview", () => {
42
+ const filePathById = new Map([["file-1", "src/big.ts"]]);
43
+ const base = "y".repeat(3000);
44
+ const variant = `${"y".repeat(2500)}CHANGED${"y".repeat(493)}`;
45
+
46
+ const [a] = parseChunkEntities([chunkRecord(base)], filePathById);
47
+ const [b] = parseChunkEntities([chunkRecord(variant)], filePathById);
48
+
49
+ assert.ok(a.text.includes(base));
50
+ assert.notEqual(a.signature, b.signature);
51
+ });
52
+
53
+ test("default embedding model is jina code model and env override still works", () => {
54
+ const saved = process.env.CORTEX_EMBED_MODEL;
55
+ try {
56
+ delete process.env.CORTEX_EMBED_MODEL;
57
+ assert.equal(DEFAULT_MODEL_ID, "jinaai/jina-embeddings-v2-base-code");
58
+ assert.equal(resolveModelId(), DEFAULT_MODEL_ID);
59
+
60
+ process.env.CORTEX_EMBED_MODEL = "Xenova/all-MiniLM-L6-v2";
61
+ assert.equal(resolveModelId(), "Xenova/all-MiniLM-L6-v2");
62
+
63
+ process.env.CORTEX_EMBED_MODEL = " ";
64
+ assert.equal(resolveModelId(), DEFAULT_MODEL_ID);
65
+ } finally {
66
+ if (saved === undefined) {
67
+ delete process.env.CORTEX_EMBED_MODEL;
68
+ } else {
69
+ process.env.CORTEX_EMBED_MODEL = saved;
70
+ }
71
+ }
72
+ });
@@ -0,0 +1,132 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { buildSearchResults } from "../dist/searchResults.js";
4
+
5
+ function makeEntity(id, entityType, overrides = {}) {
6
+ return {
7
+ id,
8
+ entity_type: entityType,
9
+ kind: entityType === "Chunk" ? "function" : "DOC",
10
+ label: id,
11
+ path: `src/${id}.ts`,
12
+ text: `text for ${id}`,
13
+ status: "active",
14
+ source_of_truth: false,
15
+ trust_level: 50,
16
+ updated_at: "2026-01-01T00:00:00Z",
17
+ snippet: "",
18
+ matched_rules: [],
19
+ ...overrides
20
+ };
21
+ }
22
+
23
+ function runSearch(candidates, degrees) {
24
+ return buildSearchResults({
25
+ candidates,
26
+ degreeByEntity: new Map(degrees),
27
+ queryTokens: ["query"],
28
+ queryPhrase: "query",
29
+ ranking: { semantic: 0.55, graph: 0.1, trust: 0.2, recency: 0.15 },
30
+ includeScores: true,
31
+ includeMatchedRules: false,
32
+ includeContent: false,
33
+ queryVector: null,
34
+ embeddingVectors: new Map(),
35
+ topK: 100,
36
+ minLexicalRelevance: 0,
37
+ minVectorRelevance: 0,
38
+ semanticScorer: () => 0.5,
39
+ vectorScorer: () => 0,
40
+ recencyScorer: () => 0,
41
+ legacyDataAccessBooster: () => 0
42
+ });
43
+ }
44
+
45
+ function graphScoreById(results) {
46
+ return new Map(results.map((result) => [result.id, result.graph_score]));
47
+ }
48
+
49
+ test("graph score gives midrank to ties: a mass of degree-1 chunks lands at 0.5", () => {
50
+ const candidates = [1, 2, 3, 4].map((n) => makeEntity(`chunk-${n}`, "Chunk"));
51
+ const degrees = candidates.map((entity) => [entity.id, 1]);
52
+
53
+ const scores = graphScoreById(runSearch(candidates, degrees));
54
+
55
+ for (const entity of candidates) {
56
+ assert.equal(scores.get(entity.id), 0.5);
57
+ }
58
+ });
59
+
60
+ test("graph score for a single-entity type is 0.5", () => {
61
+ const candidates = [makeEntity("only-file", "File")];
62
+
63
+ const scores = graphScoreById(runSearch(candidates, [["only-file", 7]]));
64
+
65
+ assert.equal(scores.get("only-file"), 0.5);
66
+ });
67
+
68
+ test("graph score is monotonic in degree within a type", () => {
69
+ const candidates = [
70
+ makeEntity("low", "Chunk"),
71
+ makeEntity("mid", "Chunk"),
72
+ makeEntity("high", "Chunk"),
73
+ makeEntity("top", "Chunk")
74
+ ];
75
+ const degrees = [
76
+ ["low", 0],
77
+ ["mid", 2],
78
+ ["high", 5],
79
+ ["top", 9]
80
+ ];
81
+
82
+ const scores = graphScoreById(runSearch(candidates, degrees));
83
+
84
+ assert.ok(scores.get("low") < scores.get("mid"));
85
+ assert.ok(scores.get("mid") < scores.get("high"));
86
+ assert.ok(scores.get("high") < scores.get("top"));
87
+ });
88
+
89
+ test("graph score percentiles are isolated per type: hub types cannot drown out leaf code", () => {
90
+ const chunks = [
91
+ makeEntity("leaf-a", "Chunk"),
92
+ makeEntity("leaf-b", "Chunk")
93
+ ];
94
+ const hubRules = [
95
+ makeEntity("rule-hub-1", "Rule", { trust_level: 95 }),
96
+ makeEntity("rule-hub-2", "Rule", { trust_level: 95 })
97
+ ];
98
+ const degrees = [
99
+ ["leaf-a", 1],
100
+ ["leaf-b", 3],
101
+ ["rule-hub-1", 200],
102
+ ["rule-hub-2", 400]
103
+ ];
104
+
105
+ const scores = graphScoreById(runSearch([...chunks, ...hubRules], degrees));
106
+
107
+ // Chunk percentiles are computed against chunks only: degree 1 is the lower
108
+ // half and degree 3 the upper half, despite rule degrees being 100x larger.
109
+ assert.equal(scores.get("leaf-a"), 0.25);
110
+ assert.equal(scores.get("leaf-b"), 0.75);
111
+ assert.equal(scores.get("rule-hub-1"), 0.25);
112
+ assert.equal(scores.get("rule-hub-2"), 0.75);
113
+ });
114
+
115
+ test("graph score never saturates to a shared constant for common degrees", () => {
116
+ // The old min(1, degree/4) mapped every degree >= 4 to 1.0.
117
+ const candidates = [
118
+ makeEntity("deg-4", "Chunk"),
119
+ makeEntity("deg-8", "Chunk"),
120
+ makeEntity("deg-40", "Chunk")
121
+ ];
122
+ const degrees = [
123
+ ["deg-4", 4],
124
+ ["deg-8", 8],
125
+ ["deg-40", 40]
126
+ ];
127
+
128
+ const scores = graphScoreById(runSearch(candidates, degrees));
129
+ const unique = new Set(scores.values());
130
+
131
+ assert.equal(unique.size, 3);
132
+ });
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
5
6
  MCP_DIR="$REPO_ROOT/.context/mcp"
6
7
  TOTAL_STEPS=6
7
8
  STEP_INDEX=0
@@ -25,24 +26,24 @@ mkdir -p "$MCP_DIR/.npm-cache"
25
26
  step "Installing MCP dependencies"
26
27
  info "note: upstream RyuGraph dependencies may print deprecation warnings during install"
27
28
  NPM_CONFIG_CACHE="$MCP_DIR/.npm-cache" npm --prefix "$MCP_DIR" install --no-fund --no-update-notifier --loglevel=warn
28
- NPM_CONFIG_CACHE="$REPO_ROOT/scripts/parsers/.npm-cache" npm --prefix "$REPO_ROOT/scripts/parsers" install --no-fund --no-update-notifier --loglevel=warn
29
+ NPM_CONFIG_CACHE="$REPO_ROOT/.context/scripts/parsers/.npm-cache" npm --prefix "$REPO_ROOT/.context/scripts/parsers" install --no-fund --no-update-notifier --loglevel=warn
29
30
 
30
- source "$REPO_ROOT/scripts/lib/enterprise-check.sh"
31
+ source "$SCRIPT_DIR/lib/enterprise-check.sh"
31
32
 
32
33
  step "Indexing repository context"
33
- "$REPO_ROOT/scripts/ingest.sh"
34
+ "$SCRIPT_DIR/ingest.sh"
34
35
 
35
36
  step "Generating semantic embeddings"
36
- if ! "$REPO_ROOT/scripts/embed.sh"; then
37
+ if ! "$SCRIPT_DIR/embed.sh"; then
37
38
  info "warning: embedding generation failed; continuing with lexical search fallback"
38
39
  fi
39
40
 
40
41
  step "Loading RyuGraph"
41
- "$REPO_ROOT/scripts/load-ryu.sh"
42
+ "$SCRIPT_DIR/load-ryu.sh"
42
43
 
43
44
  step "Reading context status"
44
- "$REPO_ROOT/scripts/status.sh"
45
+ "$SCRIPT_DIR/status.sh"
45
46
 
46
47
  echo ""
47
48
  info "bootstrap complete"
48
- info "next: run ./scripts/context.sh update while coding"
49
+ info "next: run cortex update while coding"
@@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
5
 
6
6
  print_help() {
7
7
  cat <<'EOF'
8
- Usage: ./scripts/context.sh <command> [options]
8
+ Usage: cortex <command> [options]
9
9
 
10
10
  Commands:
11
11
  bootstrap Install deps + full ingest + graph load
@@ -6,7 +6,9 @@ import { execSync } from "node:child_process";
6
6
 
7
7
  const __filename = fileURLToPath(import.meta.url);
8
8
  const __dirname = path.dirname(__filename);
9
- const REPO_ROOT = path.resolve(__dirname, "..");
9
+ const REPO_ROOT = process.env.CORTEX_PROJECT_ROOT
10
+ ? path.resolve(process.env.CORTEX_PROJECT_ROOT)
11
+ : path.resolve(__dirname, "..", "..");
10
12
  const CONTEXT_DIR = path.join(REPO_ROOT, ".context");
11
13
  const CACHE_DIR = path.join(CONTEXT_DIR, "cache");
12
14
  const CONFIG_PATH = path.join(CONTEXT_DIR, "config.yaml");
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
- REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
4
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
5
5
  MANIFEST="$REPO_ROOT/.context/cache/manifest.json"
6
6
 
7
7
  if [[ ! -f "$MANIFEST" ]]; then
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
5
6
  CONTEXT_DIR="$REPO_ROOT/.context"
6
7
  MCP_DIR="$CONTEXT_DIR/mcp"
7
8
 
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
5
6
  MCP_DIR="$REPO_ROOT/.context/mcp"
6
7
 
7
8
  if ! command -v npm >/dev/null 2>&1; then
@@ -21,6 +21,7 @@ let parseJavaCode = null;
21
21
  let parseRubyCode = null;
22
22
  let parseBashCode = null;
23
23
  let parseVb6Code = null;
24
+ let parseMarkdownCode = null;
24
25
  let isVbNetParserAvailable = () => false;
25
26
  let isCSharpParserAvailable = () => false;
26
27
  let isCppParserAvailable = () => false;
@@ -83,6 +84,9 @@ async function loadOptionalParsers() {
83
84
  }),
84
85
  import("./parsers/vb6.mjs").then((module) => {
85
86
  parseVb6Code = module.parseCode;
87
+ }),
88
+ import("./parsers/markdown.mjs").then((module) => {
89
+ parseMarkdownCode = module.parseCode;
86
90
  })
87
91
  ];
88
92
 
@@ -91,7 +95,9 @@ async function loadOptionalParsers() {
91
95
 
92
96
  const __filename = fileURLToPath(import.meta.url);
93
97
  const __dirname = path.dirname(__filename);
94
- const REPO_ROOT = path.resolve(__dirname, "..");
98
+ const REPO_ROOT = process.env.CORTEX_PROJECT_ROOT
99
+ ? path.resolve(process.env.CORTEX_PROJECT_ROOT)
100
+ : path.resolve(__dirname, "..", "..");
95
101
  const CONTEXT_DIR = path.join(REPO_ROOT, ".context");
96
102
  const CACHE_DIR = path.join(CONTEXT_DIR, "cache");
97
103
  const DB_IMPORT_DIR = path.join(CONTEXT_DIR, "db", "import");
@@ -162,7 +168,7 @@ const LEGACY_DOTNET_METADATA_EXTENSIONS = new Set([
162
168
  ]);
163
169
 
164
170
  const PROJECT_DEFINITION_EXTENSIONS = new Set([".sln", ".vbproj", ".csproj", ".fsproj", ".vcxproj"]);
165
- const STRUCTURED_NON_CODE_CHUNK_EXTENSIONS = new Set([".config", ".resx", ".settings"]);
171
+ const STRUCTURED_NON_CODE_CHUNK_EXTENSIONS = new Set([".config", ".resx", ".settings", ".md", ".mdx"]);
166
172
 
167
173
  const CODE_FILE_EXTENSIONS = new Set([
168
174
  ".ts",
@@ -296,6 +302,22 @@ const CHUNK_PARSERS = new Map([
296
302
  isAvailable: () => typeof parseSqlCode === "function"
297
303
  }
298
304
  ],
305
+ [
306
+ ".md",
307
+ {
308
+ language: "markdown",
309
+ parse: (...args) => parseMarkdownCode(...args),
310
+ isAvailable: () => typeof parseMarkdownCode === "function"
311
+ }
312
+ ],
313
+ [
314
+ ".mdx",
315
+ {
316
+ language: "markdown",
317
+ parse: (...args) => parseMarkdownCode(...args),
318
+ isAvailable: () => typeof parseMarkdownCode === "function"
319
+ }
320
+ ],
299
321
  [
300
322
  ".config",
301
323
  {
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
5
6
  CONTEXT_DIR="$REPO_ROOT/.context"
6
7
 
7
8
  printf "[ingest] repo: %s\n" "$REPO_ROOT"
@@ -17,4 +18,4 @@ if ! command -v node >/dev/null 2>&1; then
17
18
  exit 1
18
19
  fi
19
20
 
20
- node "$REPO_ROOT/scripts/ingest.mjs" "$@"
21
+ node "$SCRIPT_DIR/ingest.mjs" "$@"
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
5
6
  HOOKS_DIR="$REPO_ROOT/.githooks"
6
7
 
7
8
  if [[ ! -d "$HOOKS_DIR" ]]; then
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
5
  echo "[graph-load] warning: load-kuzu.sh is deprecated; using RyuGraph loader"
6
- "$REPO_ROOT/scripts/load-ryu.sh" "$@"
6
+ "$SCRIPT_DIR/load-ryu.sh" "$@"
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
5
6
  MCP_DIR="$REPO_ROOT/.context/mcp"
6
7
 
7
8
  if [[ ! -f "$MCP_DIR/package.json" ]]; then
@@ -2,7 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { parseFrontmatter, parseStringList } from "../.context/mcp/dist/frontmatter.js";
5
+ import { parseFrontmatter, parseStringList } from "../mcp/dist/frontmatter.js";
6
6
 
7
7
  const __filename = fileURLToPath(import.meta.url);
8
8
  const __dirname = path.dirname(__filename);
@@ -17,7 +17,7 @@ function normalizeForWsl(rawPath) {
17
17
 
18
18
  const REPO_ROOT = process.env.CORTEX_PROJECT_ROOT
19
19
  ? path.resolve(normalizeForWsl(process.env.CORTEX_PROJECT_ROOT))
20
- : path.resolve(__dirname, "..");
20
+ : path.resolve(__dirname, "..", "..");
21
21
  const CONTEXT_DIR = path.join(REPO_ROOT, ".context");
22
22
  const MEMORY_DIR = path.join(CONTEXT_DIR, "memory");
23
23
  const RAW_DIR = path.join(MEMORY_DIR, "raw");
@@ -2,7 +2,8 @@
2
2
  # Keep in sync with scripts/memory-compile.sh
3
3
  set -euo pipefail
4
4
 
5
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
6
7
  CONTEXT_DIR="$REPO_ROOT/.context"
7
8
 
8
9
  printf "[memory-compile] repo: %s\n" "$REPO_ROOT"
@@ -17,4 +18,4 @@ if ! command -v node >/dev/null 2>&1; then
17
18
  exit 1
18
19
  fi
19
20
 
20
- node "$REPO_ROOT/scripts/memory-compile.mjs" "$@"
21
+ CORTEX_PROJECT_ROOT="$REPO_ROOT" node "$SCRIPT_DIR/memory-compile.mjs" "$@"
@@ -2,7 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { parseFrontmatter, parseStringList } from "../.context/mcp/dist/frontmatter.js";
5
+ import { parseFrontmatter, parseStringList } from "../mcp/dist/frontmatter.js";
6
6
 
7
7
  const __filename = fileURLToPath(import.meta.url);
8
8
  const __dirname = path.dirname(__filename);
@@ -17,7 +17,7 @@ function normalizeForWsl(rawPath) {
17
17
 
18
18
  const REPO_ROOT = process.env.CORTEX_PROJECT_ROOT
19
19
  ? path.resolve(normalizeForWsl(process.env.CORTEX_PROJECT_ROOT))
20
- : path.resolve(__dirname, "..");
20
+ : path.resolve(__dirname, "..", "..");
21
21
  const CONTEXT_DIR = path.join(REPO_ROOT, ".context");
22
22
  const MEMORY_DIR = path.join(CONTEXT_DIR, "memory");
23
23
  const COMPILED_DIR = path.join(MEMORY_DIR, "compiled");
@@ -2,7 +2,8 @@
2
2
  # Keep in sync with scripts/memory-lint.sh
3
3
  set -euo pipefail
4
4
 
5
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
6
7
  CONTEXT_DIR="$REPO_ROOT/.context"
7
8
 
8
9
  printf "[memory-lint] repo: %s\n" "$REPO_ROOT"
@@ -17,4 +18,4 @@ if ! command -v node >/dev/null 2>&1; then
17
18
  exit 1
18
19
  fi
19
20
 
20
- node "$REPO_ROOT/scripts/memory-lint.mjs" "$@"
21
+ CORTEX_PROJECT_ROOT="$REPO_ROOT" node "$SCRIPT_DIR/memory-lint.mjs" "$@"
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Markdown parser for Cortex.
4
+ * Chunks documents into heading-bounded sections (H1-H3). Headings inside
5
+ * fenced code blocks are ignored. Sections longer than the window threshold
6
+ * are split with overlap downstream by splitChunkIntoWindows in ingest.
7
+ */
8
+
9
+ const HEADING_PATTERN = /^(#{1,3})\s+(.+?)\s*#*\s*$/;
10
+ const FENCE_PATTERN = /^(```|~~~)/;
11
+
12
+ export function parseCode(code, filePath, language = "markdown") {
13
+ const lines = code.split(/\r?\n/);
14
+ const sections = [];
15
+ let current = null;
16
+ let inFence = false;
17
+
18
+ const pushCurrent = (endIndex) => {
19
+ if (!current) {
20
+ return;
21
+ }
22
+ const body = lines.slice(current.startIndex, endIndex).join("\n").trimEnd();
23
+ const contentLines = lines.slice(current.startIndex + (current.heading ? 1 : 0), endIndex);
24
+ const hasContent = contentLines.some((line) => line.trim().length > 0);
25
+ if (body.trim().length > 0 && hasContent) {
26
+ sections.push({
27
+ name: current.name,
28
+ kind: "section",
29
+ signature: current.heading ?? current.name,
30
+ body,
31
+ startLine: current.startIndex + 1,
32
+ endLine: Math.max(current.startIndex + 1, endIndex),
33
+ language,
34
+ calls: [],
35
+ imports: []
36
+ });
37
+ }
38
+ current = null;
39
+ };
40
+
41
+ for (let i = 0; i < lines.length; i += 1) {
42
+ const line = lines[i];
43
+ if (FENCE_PATTERN.test(line.trim())) {
44
+ inFence = !inFence;
45
+ continue;
46
+ }
47
+ if (inFence) {
48
+ continue;
49
+ }
50
+ const match = line.match(HEADING_PATTERN);
51
+ if (!match) {
52
+ if (!current) {
53
+ current = { name: "preamble", heading: null, startIndex: i };
54
+ }
55
+ continue;
56
+ }
57
+ pushCurrent(i);
58
+ current = { name: match[2].trim(), heading: line.trim(), startIndex: i };
59
+ }
60
+ pushCurrent(lines.length);
61
+
62
+ return { chunks: sections, errors: [] };
63
+ }
@@ -21,7 +21,6 @@
21
21
  "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
22
22
  "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
23
23
  "license": "MIT",
24
- "peer": true,
25
24
  "bin": {
26
25
  "acorn": "bin/acorn"
27
26
  },
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
5
 
6
6
  echo "[refresh] running ingestion"
7
- "$REPO_ROOT/scripts/ingest.sh" "$@"
7
+ "$SCRIPT_DIR/ingest.sh" "$@"
8
8
 
9
9
  echo "[refresh] done"
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
5
6
  MANIFEST="$REPO_ROOT/.context/cache/manifest.json"
6
7
  GRAPH_MANIFEST="$REPO_ROOT/.context/cache/graph-manifest.json"
7
8
  EMBED_MANIFEST="$REPO_ROOT/.context/embeddings/manifest.json"
8
9
 
9
10
  if [[ ! -f "$MANIFEST" ]]; then
10
11
  echo "[status] No ingest manifest found."
11
- echo "[status] Run: ./scripts/context.sh ingest"
12
+ echo "[status] Run: cortex ingest"
12
13
  exit 0
13
14
  fi
14
15
 
@@ -47,7 +48,7 @@ console.log(`[status] graph files=${c.files ?? 0} rules=${c.rules ?? 0} adrs=${c
47
48
  console.log(`[status] graph rels constrains=${c.constrains ?? 0} implements=${c.implements ?? 0} supersedes=${c.supersedes ?? 0}`);
48
49
  ' "$GRAPH_MANIFEST"
49
50
  else
50
- echo "[status] graph manifest missing (run: ./scripts/context.sh graph-load)"
51
+ echo "[status] graph manifest missing (run: cortex graph-load)"
51
52
  fi
52
53
 
53
54
  if [[ -f "$EMBED_MANIFEST" ]]; then
@@ -68,13 +69,13 @@ if (embedded > 0 && output > 0 && failed === 0) {
68
69
  } else if (embedded > 0 && output > 0) {
69
70
  console.log(`[status] semantic_search=embedding+lexical-partial (failed=${failed})`);
70
71
  } else if (entities > 0) {
71
- console.log("[status] semantic_search=lexical-only (run: ./scripts/context.sh embed)");
72
+ console.log("[status] semantic_search=lexical-only (run: cortex embed)");
72
73
  } else {
73
74
  console.log("[status] semantic_search=lexical-only (no indexed entities)");
74
75
  }
75
76
  ' "$EMBED_MANIFEST"
76
77
  else
77
- echo "[status] embeddings manifest missing (run: ./scripts/context.sh embed)"
78
+ echo "[status] embeddings manifest missing (run: cortex embed)"
78
79
  echo "[status] semantic_search=lexical-only (embeddings manifest missing)"
79
80
  fi
80
81
 
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
5
 
6
6
  echo "[update] ingesting changed files"
7
- "$REPO_ROOT/scripts/ingest.sh" --changed
7
+ "$SCRIPT_DIR/ingest.sh" --changed
8
8
 
9
9
  echo "[update] embedding changed entities"
10
- if ! "$REPO_ROOT/scripts/embed.sh" --changed; then
10
+ if ! "$SCRIPT_DIR/embed.sh" --changed; then
11
11
  echo "[update] warning: embedding generation failed; continuing with lexical search fallback"
12
12
  fi
13
13
 
14
14
  echo "[update] rebuilding graph"
15
- "$REPO_ROOT/scripts/load-ryu.sh"
15
+ "$SCRIPT_DIR/load-ryu.sh"
16
16
 
17
17
  echo "[update] status"
18
- "$REPO_ROOT/scripts/status.sh"
18
+ "$SCRIPT_DIR/status.sh"
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
5
6
  WATCH_DIR="$REPO_ROOT/.context/watch"
6
7
  PID_FILE="$WATCH_DIR/watch.pid"
7
8
  STAMP_FILE="$WATCH_DIR/last-stamp.sha1"
@@ -21,7 +22,7 @@ EVENT_BACKEND=""
21
22
 
22
23
  print_usage() {
23
24
  cat <<'USAGE'
24
- Usage: ./scripts/watch.sh [start|stop|status|run|once] [--interval <sec>] [--debounce <sec>] [--mode <auto|event|poll>]
25
+ Usage: cortex watch [start|stop|status|run|once] [--interval <sec>] [--debounce <sec>] [--mode <auto|event|poll>]
25
26
 
26
27
  Commands:
27
28
  start Start background watch loop
@@ -149,8 +150,8 @@ status_digest() {
149
150
  # .context/ excludes the relocated .context/mcp/ tree as well.
150
151
  find "$REPO_ROOT" -type f \
151
152
  ! -path "$REPO_ROOT/.context/*" \
152
- ! -path "$REPO_ROOT/scripts/parsers/node_modules/*" \
153
- ! -path "$REPO_ROOT/scripts/parsers/.npm-cache/*" \
153
+ ! -path "$REPO_ROOT/.context/scripts/parsers/node_modules/*" \
154
+ ! -path "$REPO_ROOT/.context/scripts/parsers/.npm-cache/*" \
154
155
  -print \
155
156
  | LC_ALL=C sort \
156
157
  | shasum -a 1 \
@@ -162,7 +163,7 @@ run_update() {
162
163
  start_ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
163
164
  echo "[watch] update start at $start_ts"
164
165
 
165
- if bash "$REPO_ROOT/scripts/context.sh" update; then
166
+ if bash "$SCRIPT_DIR/context.sh" update; then
166
167
  echo "[watch] update success"
167
168
  return 0
168
169
  fi
@@ -199,15 +200,13 @@ wait_for_change_event() {
199
200
  inotifywait)
200
201
  inotifywait -q -r \
201
202
  -e modify,create,delete,move \
202
- --exclude '(^|/)\\.git(/|$)|(^|/)\\.context(/|$)|(^|/)scripts/parsers/(node_modules|\\.npm-cache)(/|$)' \
203
+ --exclude '(^|/)\\.git(/|$)|(^|/)\\.context(/|$)' \
203
204
  "$REPO_ROOT" >/dev/null 2>&1 || true
204
205
  ;;
205
206
  fswatch)
206
207
  fswatch -1 -r \
207
208
  --exclude '(^|/)\\.git(/|$)' \
208
209
  --exclude '(^|/)\\.context(/|$)' \
209
- --exclude '(^|/)scripts/parsers/node_modules(/|$)' \
210
- --exclude '(^|/)scripts/parsers/\\.npm-cache(/|$)' \
211
210
  "$REPO_ROOT" >/dev/null 2>&1 || true
212
211
  ;;
213
212
  *)