@minhpnq1807/contextos 0.5.51 → 0.5.52

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 (51) hide show
  1. package/CHANGELOG.md +10 -2
  2. package/README.md +114 -9
  3. package/bin/ctx.js +59 -7
  4. package/eval/skill-routing/cases.yaml +366 -0
  5. package/eval/skill-routing/fixtures/docker-node/Dockerfile +4 -0
  6. package/eval/skill-routing/fixtures/docker-node/docker-compose.yml +5 -0
  7. package/eval/skill-routing/fixtures/docker-node/package.json +6 -0
  8. package/eval/skill-routing/fixtures/expo-eas/.github/workflows/eas.yml +1 -0
  9. package/eval/skill-routing/fixtures/expo-eas/app.json +5 -0
  10. package/eval/skill-routing/fixtures/expo-eas/eas.json +6 -0
  11. package/eval/skill-routing/fixtures/expo-eas/package.json +11 -0
  12. package/eval/skill-routing/fixtures/expo-with-vercel-json/app.json +6 -0
  13. package/eval/skill-routing/fixtures/expo-with-vercel-json/eas.json +5 -0
  14. package/eval/skill-routing/fixtures/expo-with-vercel-json/package.json +8 -0
  15. package/eval/skill-routing/fixtures/expo-with-vercel-json/vercel.json +3 -0
  16. package/eval/skill-routing/fixtures/express-mongo-jwt/package.json +8 -0
  17. package/eval/skill-routing/fixtures/firebase-hosting/firebase.json +11 -0
  18. package/eval/skill-routing/fixtures/firebase-hosting/package.json +6 -0
  19. package/eval/skill-routing/fixtures/flutter-firebase/pubspec.yaml +5 -0
  20. package/eval/skill-routing/fixtures/frontend-only-next/package.json +8 -0
  21. package/eval/skill-routing/fixtures/integration-test/jest.config.js +3 -0
  22. package/eval/skill-routing/fixtures/integration-test/package.json +10 -0
  23. package/eval/skill-routing/fixtures/jest-project/jest.config.js +3 -0
  24. package/eval/skill-routing/fixtures/jest-project/package.json +7 -0
  25. package/eval/skill-routing/fixtures/nest-prisma/package.json +10 -0
  26. package/eval/skill-routing/fixtures/nest-prisma/prisma/schema.prisma +4 -0
  27. package/eval/skill-routing/fixtures/next-vercel/.github/workflows/deploy.yml +1 -0
  28. package/eval/skill-routing/fixtures/next-vercel/package.json +8 -0
  29. package/eval/skill-routing/fixtures/next-vercel/vercel.json +3 -0
  30. package/eval/skill-routing/fixtures/oauth-google/.env.example +3 -0
  31. package/eval/skill-routing/fixtures/oauth-google/package.json +9 -0
  32. package/eval/skill-routing/fixtures/password-reset/package.json +8 -0
  33. package/eval/skill-routing/fixtures/playwright-project/package.json +6 -0
  34. package/eval/skill-routing/fixtures/playwright-project/playwright.config.ts +5 -0
  35. package/eval/skill-routing/fixtures/railway-render/package.json +6 -0
  36. package/eval/skill-routing/fixtures/railway-render/railway.json +6 -0
  37. package/eval/skill-routing/fixtures/railway-render/render.yaml +5 -0
  38. package/eval/skill-routing/fixtures/rbac-api/package.json +8 -0
  39. package/eval/skill-routing/fixtures/redis-cache/package.json +7 -0
  40. package/eval/skill-routing/fixtures/static-docs/README.md +3 -0
  41. package/eval/skill-routing/run-eval.js +278 -0
  42. package/package.json +3 -1
  43. package/plugins/ctx/.codex-plugin/plugin.json +1 -1
  44. package/plugins/ctx/lib/ctx-mcp-client.js +19 -0
  45. package/plugins/ctx/lib/embedding-scorer.js +34 -0
  46. package/plugins/ctx/lib/package-install.js +1 -1
  47. package/plugins/ctx/lib/prompt-hook.js +13 -2
  48. package/plugins/ctx/lib/setup-wizard.js +8 -3
  49. package/plugins/ctx/lib/skill-discoverer.js +432 -18
  50. package/plugins/ctx/mcp/contextos-server.js +29 -1
  51. package/plugins/ctx/mcp/server.js +50 -4
@@ -16,6 +16,7 @@ const SCAN_CACHE_TTL_MS = 5 * 60 * 1000;
16
16
  const MAX_DESCRIPTION_CHARS = 500;
17
17
  const SKILL_EMBEDDING_THRESHOLD = 0.45;
18
18
  const DEFAULT_SKILL_TIMEOUT_MS = 2000;
19
+ const DEFAULT_ROUTER_THRESHOLD = 0.35;
19
20
 
20
21
  const scanCache = new Map();
21
22
 
@@ -50,6 +51,40 @@ export function parseSkillFrontmatter(content = "", { fallbackName = "", skillPa
50
51
  };
51
52
  }
52
53
 
54
+ export function parseSkillMetadata(content = "") {
55
+ const lines = String(content || "").split(/\r?\n/);
56
+ const root = {};
57
+ const stack = [{ indent: -1, value: root }];
58
+
59
+ for (const rawLine of lines) {
60
+ if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) continue;
61
+ const indent = rawLine.match(/^\s*/)?.[0].length || 0;
62
+ const line = rawLine.trim();
63
+ while (stack.length > 1 && indent <= stack.at(-1).indent) stack.pop();
64
+ const parent = stack.at(-1).value;
65
+
66
+ if (line.startsWith("- ")) {
67
+ if (!Array.isArray(parent)) continue;
68
+ parent.push(parseScalar(line.slice(2)));
69
+ continue;
70
+ }
71
+
72
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
73
+ if (!match) continue;
74
+ const [, key, rawValue] = match;
75
+ if (rawValue) {
76
+ parent[key] = parseScalar(rawValue);
77
+ continue;
78
+ }
79
+
80
+ const next = nextMeaningfulLine(lines, rawLine);
81
+ parent[key] = next?.trim().startsWith("- ") ? [] : {};
82
+ stack.push({ indent, value: parent[key] });
83
+ }
84
+
85
+ return normalizeSkillMetadata(root);
86
+ }
87
+
53
88
  function truncateDescription(value) {
54
89
  return String(value || "").replace(/\s+/g, " ").trim().slice(0, MAX_DESCRIPTION_CHARS);
55
90
  }
@@ -101,8 +136,10 @@ export function scanSkills({ cwd = process.cwd(), roots = skillSearchRoots({ cwd
101
136
  skillPath
102
137
  });
103
138
  if (!skill.name || !skill.description) continue;
139
+ const metadata = readSkillMetadata({ skillPath, skill });
104
140
  skills.push(enrichSkill({
105
141
  ...skill,
142
+ metadata,
106
143
  root,
107
144
  scope: isInsidePath(skillPath, cwd) ? "project" : "global",
108
145
  relativePath: path.relative(cwd, skillPath)
@@ -175,7 +212,8 @@ export async function suggestSkills({
175
212
  const query = skillQuery({ prompt, cwd, dataDir });
176
213
  const byId = new Map(catalog.map((skill) => [skillIndexId(skill), skill]));
177
214
  const explicitSkills = explicitSkillSuggestions({ prompt, byId });
178
- if (!embeddingsEnabled) return finalizeSkillScores(explicitSkills, limit);
215
+ const projectEvidence = detectProjectEvidence({ cwd });
216
+ if (!embeddingsEnabled) return finalizeSkillScores(explicitSkills, limit, { cwd, prompt, projectEvidence });
179
217
 
180
218
  if (dataDir) {
181
219
  const indexed = await searchSkillIndexes({ cwd, query, dataDir, timeoutMs, indexedSearcher });
@@ -189,14 +227,14 @@ export async function suggestSkills({
189
227
  return skillScoreFromEmbedding(skill, item.embeddingScore, [`embedding:${Number(item.embeddingScore || 0).toFixed(2)}`]);
190
228
  })
191
229
  .filter(Boolean)
192
- ], limit);
230
+ ], limit, { cwd, prompt, projectEvidence });
193
231
  }
194
232
  }
195
233
 
196
- if (catalog.length > DEFAULT_EMBEDDING_CANDIDATES) return finalizeSkillScores(explicitSkills, limit);
234
+ if (catalog.length > DEFAULT_EMBEDDING_CANDIDATES) return finalizeSkillScores(explicitSkills, limit, { cwd, prompt, projectEvidence });
197
235
 
198
236
  const embeddingCandidates = catalog.map((skill, index) => skillRule({ skill, index }));
199
- if (!embeddingCandidates.length) return finalizeSkillScores(explicitSkills, limit);
237
+ if (!embeddingCandidates.length) return finalizeSkillScores(explicitSkills, limit, { cwd, prompt, projectEvidence });
200
238
 
201
239
  const embedding = await embeddingEnhancer(embeddingCandidates, query, {
202
240
  dataDir,
@@ -205,7 +243,7 @@ export async function suggestSkills({
205
243
  allowRemote: false
206
244
  });
207
245
 
208
- return finalizeSkillScores([...explicitSkills, ...embedding.rules], limit);
246
+ return finalizeSkillScores([...explicitSkills, ...embedding.rules], limit, { cwd, prompt, projectEvidence });
209
247
  }
210
248
 
211
249
  function skillQuery({ prompt = "", cwd = process.cwd(), dataDir } = {}) {
@@ -237,19 +275,28 @@ function extractExplicitSkillNames(prompt = "") {
237
275
  return names;
238
276
  }
239
277
 
240
- function finalizeSkillScores(skills, limit) {
278
+ export async function diagnoseSkills({
279
+ prompt = "",
280
+ skills = [],
281
+ dataDir,
282
+ cwd = process.cwd(),
283
+ limit = 10,
284
+ ...options
285
+ } = {}) {
286
+ const suggestions = await suggestSkills({ prompt, skills, dataDir, cwd, limit, ...options });
287
+ const projectEvidence = detectProjectEvidence({ cwd });
288
+ return {
289
+ prompt,
290
+ cwd,
291
+ projectEvidence,
292
+ skills: suggestions
293
+ };
294
+ }
295
+
296
+ function finalizeSkillScores(skills, limit, { cwd = process.cwd(), prompt = "", projectEvidence = detectProjectEvidence({ cwd }) } = {}) {
241
297
  const ranked = skills
242
- .map((rule) => ({
243
- name: rule.name,
244
- description: rule.description,
245
- path: rule.path,
246
- scope: rule.scope,
247
- score: Math.min(1, Number(rule.score || 0)),
248
- embeddingScore: rule.embeddingScore,
249
- rankScore: Math.min(1, Number(rule.score || 0)),
250
- reasons: rule.reasons || []
251
- }))
252
- .filter((skill) => Number(skill.embeddingScore || skill.score || 0) >= SKILL_EMBEDDING_THRESHOLD)
298
+ .map((rule) => hybridSkillScore(rule, { prompt, projectEvidence }))
299
+ .filter((skill) => skill.explicit || Number(skill.rankScore || 0) >= DEFAULT_ROUTER_THRESHOLD)
253
300
  .sort((a, b) => b.rankScore - a.rankScore
254
301
  || b.score - a.score
255
302
  || scopePriority(b.scope) - scopePriority(a.scope)
@@ -324,6 +371,7 @@ function skillScoreFromEmbedding(skill, embeddingScore, reasons = []) {
324
371
  description: skill.description,
325
372
  path: skill.path,
326
373
  scope: skill.scope,
374
+ metadata: skill.metadata,
327
375
  score,
328
376
  embeddingScore: score,
329
377
  reasons
@@ -387,7 +435,14 @@ function skillIndexId(skill) {
387
435
  }
388
436
 
389
437
  function skillEmbeddingText(skill) {
390
- return [skill.name, skill.description].filter(Boolean).join("\n");
438
+ const metadata = skill.metadata || {};
439
+ return [
440
+ skill.name,
441
+ skill.description,
442
+ ...(metadata.positivePrompts || []),
443
+ ...(metadata.dependencies || []),
444
+ ...(metadata.files || [])
445
+ ].filter(Boolean).join("\n");
391
446
  }
392
447
 
393
448
  export function projectSkillHints({ cwd = process.cwd() } = {}) {
@@ -420,6 +475,365 @@ function readJson(filePath) {
420
475
  }
421
476
  }
422
477
 
478
+ function readSkillMetadata({ skillPath, skill }) {
479
+ const skillDir = path.dirname(skillPath);
480
+ for (const fileName of ["skill.yaml", "skill.yml"]) {
481
+ const metadataPath = path.join(skillDir, fileName);
482
+ if (!fs.existsSync(metadataPath)) continue;
483
+ try {
484
+ return {
485
+ ...inferSkillMetadata(skill),
486
+ ...parseSkillMetadata(fs.readFileSync(metadataPath, "utf8")),
487
+ sourcePath: metadataPath
488
+ };
489
+ } catch {
490
+ return inferSkillMetadata(skill);
491
+ }
492
+ }
493
+ return inferSkillMetadata(skill);
494
+ }
495
+
496
+ function normalizeSkillMetadata(metadata = {}) {
497
+ const positive = metadata.positive_triggers || metadata.triggers || {};
498
+ const negative = metadata.negative_triggers || {};
499
+ return {
500
+ id: metadata.id,
501
+ name: metadata.name,
502
+ positivePrompts: asArray(positive.prompts || positive.keywords || metadata.keywords),
503
+ files: asArray(positive.files || metadata.files),
504
+ dependencies: asArray(positive.dependencies || metadata.dependencies),
505
+ negativePrompts: asArray(negative.prompts || negative.keywords),
506
+ negativeFiles: asArray(negative.files),
507
+ negativeDependencies: asArray(negative.dependencies),
508
+ relatedSkills: asArray(metadata.related_skills)
509
+ };
510
+ }
511
+
512
+ function inferSkillMetadata(skill = {}) {
513
+ const text = normalize(`${skill.name || ""} ${skill.description || ""}`);
514
+ const metadata = {
515
+ positivePrompts: [],
516
+ files: [],
517
+ dependencies: [],
518
+ negativePrompts: [],
519
+ negativeFiles: [],
520
+ negativeDependencies: [],
521
+ relatedSkills: []
522
+ };
523
+ if (/\b(eas|expo|react native|mobile deployment|app store)\b/.test(text)) {
524
+ metadata.positivePrompts.push("eas", "expo build", "deployed", "deploy", "submit", "android", "ios", "mobile release", "qr", "connect");
525
+ metadata.files.push("eas.json", "app.json", "app.config.js", "app.config.ts", ".github/workflows/*");
526
+ metadata.dependencies.push("expo", "eas-cli", "expo-router", "react-native");
527
+ metadata.negativeDependencies.push("next", "vite");
528
+ metadata.negativeFiles.push("vercel.json");
529
+ }
530
+ if (/\bgithub actions|ci cd|cicd\b/.test(text)) {
531
+ metadata.positivePrompts.push("deploy", "deployed", "ci", "workflow", "github actions", "build failed");
532
+ metadata.files.push(".github/workflows/*");
533
+ }
534
+ if (/\benv|secret|credential|api key\b/.test(text)) {
535
+ metadata.positivePrompts.push("secret", "env", "environment", "api key", "deploy", "deployed");
536
+ metadata.files.push(".env", ".env.example", ".github/workflows/*");
537
+ }
538
+ if (/\bbuild log|debugging|debug|error|failed|failure\b/.test(text)) {
539
+ metadata.positivePrompts.push("error", "failed", "failure", "fix", "debug", "build", "deployed");
540
+ }
541
+ if (/\bvercel\b/.test(text)) {
542
+ metadata.positivePrompts.push("vercel", "deploy", "deployed");
543
+ metadata.files.push("vercel.json", "next.config.js", "next.config.ts");
544
+ metadata.dependencies.push("next", "vercel");
545
+ metadata.negativeDependencies.push("expo", "react-native");
546
+ metadata.negativeFiles.push("eas.json");
547
+ }
548
+ if (/\bnext|app router\b/.test(text)) {
549
+ metadata.dependencies.push("next", "react");
550
+ metadata.positivePrompts.push("frontend", "ui", "role", "dashboard", "app router");
551
+ }
552
+ return metadata;
553
+ }
554
+
555
+ function hybridSkillScore(skill, { prompt, projectEvidence }) {
556
+ const semanticScore = Math.min(1, Number(skill.embeddingScore || skill.score || 0));
557
+ const metadata = skill.metadata || inferSkillMetadata(skill);
558
+ const hasRouterSignals = metadataHasSignals(metadata);
559
+ const promptMatch = matchTextTriggers(prompt, metadata.positivePrompts);
560
+ const dependencyEvidence = matchList(projectEvidence.dependencies, metadata.dependencies);
561
+ const fileEvidence = matchFiles(projectEvidence.files, metadata.files);
562
+ const negativeDependencies = matchList(projectEvidence.dependencies, metadata.negativeDependencies);
563
+ const negativeFiles = matchFiles(projectEvidence.files, metadata.negativeFiles);
564
+ const negativePrompts = matchTextTriggers(prompt, metadata.negativePrompts);
565
+ const negativePenalty = Math.max(negativeDependencies.score, negativeFiles.score, negativePrompts.score);
566
+ const projectEvidenceScore = dependencyEvidence.score;
567
+ const fileConfigScore = fileEvidence.score;
568
+ const graphScore = 0;
569
+ const hybridScore = Math.max(0, Math.min(1,
570
+ semanticScore * 0.35
571
+ + promptMatch.score * 0.20
572
+ + projectEvidenceScore * 0.25
573
+ + fileConfigScore * 0.10
574
+ + graphScore * 0.05
575
+ - negativePenalty * 0.20
576
+ ));
577
+ const explicit = (skill.reasons || []).includes("explicit-skill");
578
+ const finalScore = hasRouterSignals ? hybridScore : semanticScore;
579
+ const calibratedConfidence = calibrateSkillConfidence(finalScore, {
580
+ prompt,
581
+ promptMatch,
582
+ dependencyEvidence,
583
+ fileEvidence,
584
+ negativePenalty,
585
+ explicit
586
+ });
587
+ const evidence = [...new Set([
588
+ ...(skill.reasons || []),
589
+ ...promptMatch.matches.map((item) => `prompt:${item}`),
590
+ ...dependencyEvidence.matches.map((item) => `dependency:${item}`),
591
+ ...fileEvidence.matches.map((item) => `file:${item}`)
592
+ ])];
593
+ const negativeEvidence = [
594
+ ...negativeDependencies.matches.map((item) => `dependency:${item}`),
595
+ ...negativeFiles.matches.map((item) => `file:${item}`),
596
+ ...negativePrompts.matches.map((item) => `prompt:${item}`)
597
+ ];
598
+ const rankScore = explicit ? semanticScore : finalScore;
599
+ return {
600
+ name: skill.name,
601
+ description: skill.description,
602
+ path: skill.path,
603
+ scope: skill.scope,
604
+ score: finalScore,
605
+ confidence: calibratedConfidence,
606
+ confidenceBand: confidenceBand(calibratedConfidence),
607
+ embeddingScore: semanticScore,
608
+ semanticScore,
609
+ promptTriggerScore: promptMatch.score,
610
+ projectEvidenceScore,
611
+ fileConfigScore,
612
+ graphScore,
613
+ negativePenalty,
614
+ rankScore,
615
+ explicit,
616
+ evidence,
617
+ negativeEvidence,
618
+ reasons: skill.reasons || []
619
+ };
620
+ }
621
+
622
+ function calibrateSkillConfidence(score, {
623
+ prompt = "",
624
+ promptMatch,
625
+ dependencyEvidence,
626
+ fileEvidence,
627
+ negativePenalty = 0,
628
+ explicit = false
629
+ } = {}) {
630
+ let confidence = Math.max(0, Math.min(1, Number(score || 0)));
631
+ const hasDependencyEvidence = Boolean(dependencyEvidence?.matches?.length);
632
+ const hasFileEvidence = Boolean(fileEvidence?.matches?.length);
633
+ const hasPromptEvidence = Boolean(promptMatch?.matches?.length);
634
+ const hasProjectEvidence = hasDependencyEvidence || hasFileEvidence;
635
+
636
+ if (!hasProjectEvidence && !explicit) {
637
+ confidence = Math.min(confidence, 0.62);
638
+ }
639
+ if (isAmbiguousPrompt(prompt) && !(hasDependencyEvidence && hasFileEvidence) && !explicit) {
640
+ confidence = Math.min(confidence, 0.64);
641
+ }
642
+ if (hasPromptEvidence && hasProjectEvidence && confidence >= 0.5) {
643
+ confidence = Math.max(confidence, 0.68);
644
+ }
645
+ if (hasDependencyEvidence && hasFileEvidence) {
646
+ confidence = Math.max(confidence, 0.88);
647
+ }
648
+ if (negativePenalty > 0) {
649
+ confidence = Math.min(confidence, 0.74);
650
+ }
651
+ return Math.max(0, Math.min(1, confidence));
652
+ }
653
+
654
+ function confidenceBand(confidence) {
655
+ const value = Number(confidence || 0);
656
+ if (value >= 0.85) return "high";
657
+ if (value >= 0.65) return "medium";
658
+ return "low";
659
+ }
660
+
661
+ function isAmbiguousPrompt(prompt = "") {
662
+ const tokens = normalize(prompt).split(" ").filter(Boolean);
663
+ if (tokens.length <= 2) return true;
664
+ const generic = new Set(["fix", "add", "update", "debug", "deploy", "deployed", "auth", "cache", "test", "error"]);
665
+ return tokens.length <= 4 && tokens.every((token) => generic.has(token));
666
+ }
667
+
668
+ function detectProjectEvidence({ cwd = process.cwd() } = {}) {
669
+ const dependencies = new Set();
670
+ const scripts = new Set();
671
+ const files = new Set();
672
+ for (const packagePath of workspacePackagePaths(cwd)) {
673
+ const packageDir = path.dirname(packagePath);
674
+ const packageJson = readJson(packagePath);
675
+ files.add(normalizeFile(path.relative(cwd, packagePath) || "package.json"));
676
+ for (const name of Object.keys({
677
+ ...(packageJson?.dependencies || {}),
678
+ ...(packageJson?.devDependencies || {}),
679
+ ...(packageJson?.peerDependencies || {})
680
+ })) dependencies.add(normalizeDependency(name));
681
+ for (const name of Object.keys(packageJson?.scripts || {})) scripts.add(normalize(name));
682
+ for (const fileName of knownProjectConfigFiles()) {
683
+ if (fs.existsSync(path.join(packageDir, fileName))) files.add(normalizeFile(path.relative(cwd, path.join(packageDir, fileName))));
684
+ }
685
+ }
686
+ for (const fileName of knownProjectConfigFiles()) {
687
+ if (fs.existsSync(path.join(cwd, fileName))) files.add(normalizeFile(fileName));
688
+ }
689
+ const pubspecPath = path.join(cwd, "pubspec.yaml");
690
+ if (fs.existsSync(pubspecPath)) {
691
+ files.add("pubspec.yaml");
692
+ for (const dependency of readPubspecDependencies(pubspecPath)) dependencies.add(normalizeDependency(dependency));
693
+ }
694
+ collectExistingFiles(cwd, [".github/workflows"], files);
695
+ return {
696
+ dependencies: [...dependencies],
697
+ scripts: [...scripts],
698
+ files: [...files]
699
+ };
700
+ }
701
+
702
+ function knownProjectConfigFiles() {
703
+ return [
704
+ "eas.json",
705
+ "app.json",
706
+ "app.config.js",
707
+ "app.config.ts",
708
+ "vercel.json",
709
+ "next.config.js",
710
+ "next.config.ts",
711
+ "firebase.json",
712
+ "railway.json",
713
+ "render.yaml",
714
+ "Dockerfile",
715
+ "docker-compose.yml",
716
+ "jest.config.js",
717
+ "jest.config.ts",
718
+ "playwright.config.js",
719
+ "playwright.config.ts"
720
+ ];
721
+ }
722
+
723
+ function readPubspecDependencies(filePath) {
724
+ const dependencies = [];
725
+ let inDependencies = false;
726
+ try {
727
+ for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
728
+ if (/^dependencies:\s*$/.test(line)) {
729
+ inDependencies = true;
730
+ continue;
731
+ }
732
+ if (inDependencies && /^\S/.test(line) && !/^dependencies:\s*$/.test(line)) break;
733
+ const match = line.match(/^\s{2}([A-Za-z0-9_-]+):/);
734
+ if (inDependencies && match) dependencies.push(match[1]);
735
+ }
736
+ } catch {
737
+ return [];
738
+ }
739
+ return dependencies;
740
+ }
741
+
742
+ function collectExistingFiles(cwd, relativeDirs, files) {
743
+ for (const relativeDir of relativeDirs) {
744
+ const directory = path.join(cwd, relativeDir);
745
+ let entries = [];
746
+ try {
747
+ entries = fs.readdirSync(directory, { withFileTypes: true });
748
+ } catch {
749
+ continue;
750
+ }
751
+ files.add(normalizeFile(relativeDir));
752
+ for (const entry of entries) {
753
+ if (entry.isFile()) files.add(normalizeFile(path.join(relativeDir, entry.name)));
754
+ }
755
+ }
756
+ }
757
+
758
+ function matchTextTriggers(text, triggers = []) {
759
+ const normalizedText = normalize(text);
760
+ const matches = [];
761
+ for (const trigger of triggers || []) {
762
+ const normalizedTrigger = normalize(trigger);
763
+ if (!normalizedTrigger) continue;
764
+ if (normalizedText.includes(normalizedTrigger)) matches.push(trigger);
765
+ }
766
+ return { score: matches.length ? 1 : 0, matches };
767
+ }
768
+
769
+ function matchList(values = [], triggers = []) {
770
+ const valueSet = new Set(values.map(normalizeDependency));
771
+ const matches = [];
772
+ for (const trigger of triggers || []) {
773
+ const normalizedTrigger = normalizeDependency(trigger);
774
+ if (valueSet.has(normalizedTrigger)) matches.push(trigger);
775
+ }
776
+ return { score: matches.length ? 1 : 0, matches };
777
+ }
778
+
779
+ function matchFiles(files = [], triggers = []) {
780
+ const normalizedFiles = files.map(normalizeFile);
781
+ const matches = [];
782
+ for (const trigger of triggers || []) {
783
+ const normalizedTrigger = normalizeFile(trigger);
784
+ if (!normalizedTrigger) continue;
785
+ if (normalizedTrigger.endsWith("/*")) {
786
+ const prefix = normalizedTrigger.slice(0, -1);
787
+ if (normalizedFiles.some((file) => file.startsWith(prefix))) matches.push(trigger);
788
+ continue;
789
+ }
790
+ if (normalizedFiles.some((file) => file === normalizedTrigger || file.endsWith(`/${normalizedTrigger}`))) matches.push(trigger);
791
+ }
792
+ return { score: matches.length ? 1 : 0, matches };
793
+ }
794
+
795
+ function metadataHasSignals(metadata = {}) {
796
+ return [
797
+ metadata.positivePrompts,
798
+ metadata.files,
799
+ metadata.dependencies,
800
+ metadata.negativePrompts,
801
+ metadata.negativeFiles,
802
+ metadata.negativeDependencies
803
+ ].some((items) => Array.isArray(items) && items.length > 0);
804
+ }
805
+
806
+ function asArray(value) {
807
+ if (Array.isArray(value)) return value.map(String).filter(Boolean);
808
+ if (value === undefined || value === null || value === "") return [];
809
+ return [String(value)];
810
+ }
811
+
812
+ function parseScalar(value) {
813
+ const trimmed = String(value || "").trim();
814
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
815
+ return trimmed.slice(1, -1).split(",").map((item) => item.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
816
+ }
817
+ return trimmed.replace(/^["']|["']$/g, "");
818
+ }
819
+
820
+ function nextMeaningfulLine(lines, currentRawLine) {
821
+ const start = lines.indexOf(currentRawLine);
822
+ for (let index = start + 1; index < lines.length; index += 1) {
823
+ const line = lines[index];
824
+ if (line.trim() && !line.trimStart().startsWith("#")) return line;
825
+ }
826
+ return null;
827
+ }
828
+
829
+ function normalizeFile(value) {
830
+ return String(value || "").replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
831
+ }
832
+
833
+ function normalizeDependency(value) {
834
+ return String(value || "").toLowerCase().trim();
835
+ }
836
+
423
837
  function addHintText(hints, value) {
424
838
  for (const token of normalize(value).split(/\s+/).filter(Boolean)) hints.add(token);
425
839
  }
@@ -4,12 +4,32 @@ import { z } from "zod";
4
4
  import { scoreContext } from "../lib/score-context.js";
5
5
  import { scheduleContext } from "../lib/scheduler.js";
6
6
 
7
- export function createContextOSMcpServer({ dataDir }) {
7
+ export function createContextOSMcpServer({ dataDir, getHealth = defaultHealth }) {
8
8
  const server = new McpServer({
9
9
  name: "ctx-mcp",
10
10
  version: "0.1.0"
11
11
  });
12
12
 
13
+ server.registerTool("ctx_health", {
14
+ title: "ContextOS health",
15
+ description: "Reports ContextOS MCP bridge and embedding model readiness.",
16
+ inputSchema: {},
17
+ outputSchema: {
18
+ model_cache_ready: z.boolean(),
19
+ embedding_pipeline_loaded: z.boolean(),
20
+ bridge_ready: z.boolean(),
21
+ preload_status: z.string().optional(),
22
+ loaded_at: z.number().optional(),
23
+ error: z.string().optional()
24
+ }
25
+ }, async () => {
26
+ const health = getHealth();
27
+ return {
28
+ content: [{ type: "text", text: JSON.stringify(health) }],
29
+ structuredContent: health
30
+ };
31
+ });
32
+
13
33
  server.registerTool("ctx_score_context", {
14
34
  title: "Score ContextOS prompt context",
15
35
  description: "Scores AGENTS.md rules and suggests files/skills for an agent prompt.",
@@ -90,3 +110,11 @@ export function createContextOSMcpServer({ dataDir }) {
90
110
  return server;
91
111
  }
92
112
 
113
+ function defaultHealth() {
114
+ return {
115
+ model_cache_ready: false,
116
+ embedding_pipeline_loaded: false,
117
+ bridge_ready: false,
118
+ preload_status: "unknown"
119
+ };
120
+ }
@@ -4,7 +4,7 @@ import net from "node:net";
4
4
 
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
 
7
- import { isModelCacheReady, modelCacheDir } from "../lib/embedding-scorer.js";
7
+ import { isModelCacheReady, modelCacheDir, preloadEmbeddingPipeline } from "../lib/embedding-scorer.js";
8
8
  import { scoreContext } from "../lib/score-context.js";
9
9
  import { CTX_MCP_BRIDGE_REVISION, ctxMcpSocketPath } from "../lib/ctx-mcp-client.js";
10
10
  import { defaultDataRoot } from "../lib/workspace-data.js";
@@ -12,23 +12,51 @@ import { createContextOSMcpServer } from "./contextos-server.js";
12
12
 
13
13
  const dataDir = defaultDataRoot();
14
14
  const socketPath = ctxMcpSocketPath(dataDir);
15
+ const modelState = {
16
+ modelCacheReady: false,
17
+ embeddingPipelineLoaded: false,
18
+ bridgeReady: false,
19
+ preloadStatus: "starting",
20
+ loadedAt: null,
21
+ error: null
22
+ };
15
23
 
16
24
  fs.mkdirSync(dataDir, { recursive: true });
17
25
  await ensureModelReady();
18
26
  if (process.env.CONTEXTOS_DISABLE_BRIDGE !== "1") startBridge();
27
+ preloadEmbeddingModel();
19
28
  const keepAlive = setInterval(() => {}, 2 ** 31 - 1);
20
29
 
21
- const server = createContextOSMcpServer({ dataDir });
30
+ const server = createContextOSMcpServer({ dataDir, getHealth: bridgeHealth });
22
31
  console.error("ctx-mcp ready");
23
32
  await server.connect(new StdioServerTransport());
24
33
 
25
34
  async function ensureModelReady() {
26
35
  const modelDir = modelCacheDir(dataDir);
27
- if (!fs.existsSync(modelDir) || !isModelCacheReady(dataDir)) {
36
+ modelState.modelCacheReady = fs.existsSync(modelDir) && isModelCacheReady(dataDir);
37
+ if (!modelState.modelCacheReady) {
28
38
  throw new Error(`ContextOS model cache missing: ${modelDir}. Run ctx install first.`);
29
39
  }
30
40
  }
31
41
 
42
+ async function preloadEmbeddingModel() {
43
+ modelState.preloadStatus = "loading";
44
+ const result = await preloadEmbeddingPipeline({
45
+ dataDir,
46
+ allowRemote: false,
47
+ warmText: "contextos warmup"
48
+ });
49
+ modelState.embeddingPipelineLoaded = Boolean(result.loaded);
50
+ modelState.preloadStatus = result.status;
51
+ modelState.loadedAt = result.loaded ? Date.now() : null;
52
+ modelState.error = result.error || null;
53
+ if (result.loaded) {
54
+ console.error(`ctx-mcp embedding model hot (${result.elapsedMs}ms)`);
55
+ } else {
56
+ console.error(`ctx-mcp embedding preload failed: ${result.error || result.status}`);
57
+ }
58
+ }
59
+
32
60
  function startBridge() {
33
61
  fs.rmSync(socketPath, { force: true });
34
62
  const bridge = net.createServer((socket) => {
@@ -42,9 +70,12 @@ function startBridge() {
42
70
  });
43
71
  });
44
72
  bridge.on("error", (error) => {
73
+ modelState.bridgeReady = false;
45
74
  console.error(`ctx-mcp bridge disabled: ${error?.message || String(error)}`);
46
75
  });
47
- bridge.listen(socketPath);
76
+ bridge.listen(socketPath, () => {
77
+ modelState.bridgeReady = true;
78
+ });
48
79
  process.on("exit", () => {
49
80
  clearInterval(keepAlive);
50
81
  fs.rmSync(socketPath, { force: true });
@@ -60,6 +91,10 @@ async function handleBridgeRequest(socket, raw) {
60
91
  socket.pause();
61
92
  try {
62
93
  const payload = JSON.parse(raw.trim() || "{}");
94
+ if (payload.type === "health") {
95
+ socket.end(JSON.stringify({ bridgeRevision: CTX_MCP_BRIDGE_REVISION, health: bridgeHealth() }));
96
+ return;
97
+ }
63
98
  const result = await scoreContext({
64
99
  cwd: payload.cwd || process.cwd(),
65
100
  prompt: payload.prompt || "",
@@ -84,3 +119,14 @@ async function handleBridgeRequest(socket, raw) {
84
119
  }));
85
120
  }
86
121
  }
122
+
123
+ function bridgeHealth() {
124
+ return {
125
+ model_cache_ready: Boolean(modelState.modelCacheReady),
126
+ embedding_pipeline_loaded: Boolean(modelState.embeddingPipelineLoaded),
127
+ bridge_ready: Boolean(modelState.bridgeReady),
128
+ preload_status: modelState.preloadStatus,
129
+ loaded_at: modelState.loadedAt || undefined,
130
+ error: modelState.error || undefined
131
+ };
132
+ }