@minhpnq1807/contextos 0.5.51 → 0.5.53
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/CHANGELOG.md +17 -2
- package/README.md +147 -13
- package/bin/ctx.js +59 -7
- package/eval/skill-routing/cases.yaml +366 -0
- package/eval/skill-routing/fixtures/docker-node/Dockerfile +4 -0
- package/eval/skill-routing/fixtures/docker-node/docker-compose.yml +5 -0
- package/eval/skill-routing/fixtures/docker-node/package.json +6 -0
- package/eval/skill-routing/fixtures/expo-eas/.github/workflows/eas.yml +1 -0
- package/eval/skill-routing/fixtures/expo-eas/app.json +5 -0
- package/eval/skill-routing/fixtures/expo-eas/eas.json +6 -0
- package/eval/skill-routing/fixtures/expo-eas/package.json +11 -0
- package/eval/skill-routing/fixtures/expo-with-vercel-json/app.json +6 -0
- package/eval/skill-routing/fixtures/expo-with-vercel-json/eas.json +5 -0
- package/eval/skill-routing/fixtures/expo-with-vercel-json/package.json +8 -0
- package/eval/skill-routing/fixtures/expo-with-vercel-json/vercel.json +3 -0
- package/eval/skill-routing/fixtures/express-mongo-jwt/package.json +8 -0
- package/eval/skill-routing/fixtures/firebase-hosting/firebase.json +11 -0
- package/eval/skill-routing/fixtures/firebase-hosting/package.json +6 -0
- package/eval/skill-routing/fixtures/flutter-firebase/pubspec.yaml +5 -0
- package/eval/skill-routing/fixtures/frontend-only-next/package.json +8 -0
- package/eval/skill-routing/fixtures/integration-test/jest.config.js +3 -0
- package/eval/skill-routing/fixtures/integration-test/package.json +10 -0
- package/eval/skill-routing/fixtures/jest-project/jest.config.js +3 -0
- package/eval/skill-routing/fixtures/jest-project/package.json +7 -0
- package/eval/skill-routing/fixtures/nest-prisma/package.json +10 -0
- package/eval/skill-routing/fixtures/nest-prisma/prisma/schema.prisma +4 -0
- package/eval/skill-routing/fixtures/next-vercel/.github/workflows/deploy.yml +1 -0
- package/eval/skill-routing/fixtures/next-vercel/package.json +8 -0
- package/eval/skill-routing/fixtures/next-vercel/vercel.json +3 -0
- package/eval/skill-routing/fixtures/oauth-google/.env.example +3 -0
- package/eval/skill-routing/fixtures/oauth-google/package.json +9 -0
- package/eval/skill-routing/fixtures/password-reset/package.json +8 -0
- package/eval/skill-routing/fixtures/playwright-project/package.json +6 -0
- package/eval/skill-routing/fixtures/playwright-project/playwright.config.ts +5 -0
- package/eval/skill-routing/fixtures/railway-render/package.json +6 -0
- package/eval/skill-routing/fixtures/railway-render/railway.json +6 -0
- package/eval/skill-routing/fixtures/railway-render/render.yaml +5 -0
- package/eval/skill-routing/fixtures/rbac-api/package.json +8 -0
- package/eval/skill-routing/fixtures/redis-cache/package.json +7 -0
- package/eval/skill-routing/fixtures/static-docs/README.md +3 -0
- package/eval/skill-routing/run-eval.js +278 -0
- package/package.json +3 -1
- package/plugins/ctx/.codex-plugin/plugin.json +1 -1
- package/plugins/ctx/lib/ctx-mcp-client.js +19 -0
- package/plugins/ctx/lib/embedding-scorer.js +34 -0
- package/plugins/ctx/lib/package-install.js +1 -1
- package/plugins/ctx/lib/prompt-hook.js +13 -2
- package/plugins/ctx/lib/setup-wizard.js +8 -3
- package/plugins/ctx/lib/skill-discoverer.js +439 -18
- package/plugins/ctx/mcp/contextos-server.js +29 -1
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,372 @@ 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 importGraphScore = 0;
|
|
569
|
+
const externalGraphScore = 0;
|
|
570
|
+
const memoryScore = 0;
|
|
571
|
+
const hybridScore = Math.max(0, Math.min(1,
|
|
572
|
+
semanticScore * 0.30
|
|
573
|
+
+ promptMatch.score * 0.20
|
|
574
|
+
+ projectEvidenceScore * 0.20
|
|
575
|
+
+ fileConfigScore * 0.10
|
|
576
|
+
+ importGraphScore * 0.10
|
|
577
|
+
+ externalGraphScore * 0.05
|
|
578
|
+
+ memoryScore * 0.05
|
|
579
|
+
- negativePenalty * 0.20
|
|
580
|
+
));
|
|
581
|
+
const explicit = (skill.reasons || []).includes("explicit-skill");
|
|
582
|
+
const finalScore = hasRouterSignals ? hybridScore : semanticScore;
|
|
583
|
+
const calibratedConfidence = calibrateSkillConfidence(finalScore, {
|
|
584
|
+
prompt,
|
|
585
|
+
promptMatch,
|
|
586
|
+
dependencyEvidence,
|
|
587
|
+
fileEvidence,
|
|
588
|
+
negativePenalty,
|
|
589
|
+
explicit
|
|
590
|
+
});
|
|
591
|
+
const evidence = [...new Set([
|
|
592
|
+
...(skill.reasons || []),
|
|
593
|
+
...promptMatch.matches.map((item) => `prompt:${item}`),
|
|
594
|
+
...dependencyEvidence.matches.map((item) => `dependency:${item}`),
|
|
595
|
+
...fileEvidence.matches.map((item) => `file:${item}`)
|
|
596
|
+
])];
|
|
597
|
+
const negativeEvidence = [
|
|
598
|
+
...negativeDependencies.matches.map((item) => `dependency:${item}`),
|
|
599
|
+
...negativeFiles.matches.map((item) => `file:${item}`),
|
|
600
|
+
...negativePrompts.matches.map((item) => `prompt:${item}`)
|
|
601
|
+
];
|
|
602
|
+
const rankScore = explicit ? semanticScore : finalScore;
|
|
603
|
+
return {
|
|
604
|
+
name: skill.name,
|
|
605
|
+
description: skill.description,
|
|
606
|
+
path: skill.path,
|
|
607
|
+
scope: skill.scope,
|
|
608
|
+
score: finalScore,
|
|
609
|
+
confidence: calibratedConfidence,
|
|
610
|
+
confidenceBand: confidenceBand(calibratedConfidence),
|
|
611
|
+
embeddingScore: semanticScore,
|
|
612
|
+
semanticScore,
|
|
613
|
+
promptTriggerScore: promptMatch.score,
|
|
614
|
+
projectEvidenceScore,
|
|
615
|
+
fileConfigScore,
|
|
616
|
+
importGraphScore,
|
|
617
|
+
externalGraphScore,
|
|
618
|
+
memoryScore,
|
|
619
|
+
graphScore: externalGraphScore,
|
|
620
|
+
negativePenalty,
|
|
621
|
+
rankScore,
|
|
622
|
+
explicit,
|
|
623
|
+
evidence,
|
|
624
|
+
negativeEvidence,
|
|
625
|
+
reasons: skill.reasons || []
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function calibrateSkillConfidence(score, {
|
|
630
|
+
prompt = "",
|
|
631
|
+
promptMatch,
|
|
632
|
+
dependencyEvidence,
|
|
633
|
+
fileEvidence,
|
|
634
|
+
negativePenalty = 0,
|
|
635
|
+
explicit = false
|
|
636
|
+
} = {}) {
|
|
637
|
+
let confidence = Math.max(0, Math.min(1, Number(score || 0)));
|
|
638
|
+
const hasDependencyEvidence = Boolean(dependencyEvidence?.matches?.length);
|
|
639
|
+
const hasFileEvidence = Boolean(fileEvidence?.matches?.length);
|
|
640
|
+
const hasPromptEvidence = Boolean(promptMatch?.matches?.length);
|
|
641
|
+
const hasProjectEvidence = hasDependencyEvidence || hasFileEvidence;
|
|
642
|
+
|
|
643
|
+
if (!hasProjectEvidence && !explicit) {
|
|
644
|
+
confidence = Math.min(confidence, 0.62);
|
|
645
|
+
}
|
|
646
|
+
if (isAmbiguousPrompt(prompt) && !(hasDependencyEvidence && hasFileEvidence) && !explicit) {
|
|
647
|
+
confidence = Math.min(confidence, 0.64);
|
|
648
|
+
}
|
|
649
|
+
if (hasPromptEvidence && hasProjectEvidence && confidence >= 0.45) {
|
|
650
|
+
confidence = Math.max(confidence, 0.68);
|
|
651
|
+
}
|
|
652
|
+
if (hasDependencyEvidence && hasFileEvidence) {
|
|
653
|
+
confidence = Math.max(confidence, 0.88);
|
|
654
|
+
}
|
|
655
|
+
if (negativePenalty > 0) {
|
|
656
|
+
confidence = Math.min(confidence, 0.74);
|
|
657
|
+
}
|
|
658
|
+
return Math.max(0, Math.min(1, confidence));
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function confidenceBand(confidence) {
|
|
662
|
+
const value = Number(confidence || 0);
|
|
663
|
+
if (value >= 0.85) return "high";
|
|
664
|
+
if (value >= 0.65) return "medium";
|
|
665
|
+
return "low";
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function isAmbiguousPrompt(prompt = "") {
|
|
669
|
+
const tokens = normalize(prompt).split(" ").filter(Boolean);
|
|
670
|
+
if (tokens.length <= 2) return true;
|
|
671
|
+
const generic = new Set(["fix", "add", "update", "debug", "deploy", "deployed", "auth", "cache", "test", "error"]);
|
|
672
|
+
return tokens.length <= 4 && tokens.every((token) => generic.has(token));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function detectProjectEvidence({ cwd = process.cwd() } = {}) {
|
|
676
|
+
const dependencies = new Set();
|
|
677
|
+
const scripts = new Set();
|
|
678
|
+
const files = new Set();
|
|
679
|
+
for (const packagePath of workspacePackagePaths(cwd)) {
|
|
680
|
+
const packageDir = path.dirname(packagePath);
|
|
681
|
+
const packageJson = readJson(packagePath);
|
|
682
|
+
files.add(normalizeFile(path.relative(cwd, packagePath) || "package.json"));
|
|
683
|
+
for (const name of Object.keys({
|
|
684
|
+
...(packageJson?.dependencies || {}),
|
|
685
|
+
...(packageJson?.devDependencies || {}),
|
|
686
|
+
...(packageJson?.peerDependencies || {})
|
|
687
|
+
})) dependencies.add(normalizeDependency(name));
|
|
688
|
+
for (const name of Object.keys(packageJson?.scripts || {})) scripts.add(normalize(name));
|
|
689
|
+
for (const fileName of knownProjectConfigFiles()) {
|
|
690
|
+
if (fs.existsSync(path.join(packageDir, fileName))) files.add(normalizeFile(path.relative(cwd, path.join(packageDir, fileName))));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
for (const fileName of knownProjectConfigFiles()) {
|
|
694
|
+
if (fs.existsSync(path.join(cwd, fileName))) files.add(normalizeFile(fileName));
|
|
695
|
+
}
|
|
696
|
+
const pubspecPath = path.join(cwd, "pubspec.yaml");
|
|
697
|
+
if (fs.existsSync(pubspecPath)) {
|
|
698
|
+
files.add("pubspec.yaml");
|
|
699
|
+
for (const dependency of readPubspecDependencies(pubspecPath)) dependencies.add(normalizeDependency(dependency));
|
|
700
|
+
}
|
|
701
|
+
collectExistingFiles(cwd, [".github/workflows"], files);
|
|
702
|
+
return {
|
|
703
|
+
dependencies: [...dependencies],
|
|
704
|
+
scripts: [...scripts],
|
|
705
|
+
files: [...files]
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function knownProjectConfigFiles() {
|
|
710
|
+
return [
|
|
711
|
+
"eas.json",
|
|
712
|
+
"app.json",
|
|
713
|
+
"app.config.js",
|
|
714
|
+
"app.config.ts",
|
|
715
|
+
"vercel.json",
|
|
716
|
+
"next.config.js",
|
|
717
|
+
"next.config.ts",
|
|
718
|
+
"firebase.json",
|
|
719
|
+
"railway.json",
|
|
720
|
+
"render.yaml",
|
|
721
|
+
"Dockerfile",
|
|
722
|
+
"docker-compose.yml",
|
|
723
|
+
"jest.config.js",
|
|
724
|
+
"jest.config.ts",
|
|
725
|
+
"playwright.config.js",
|
|
726
|
+
"playwright.config.ts"
|
|
727
|
+
];
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function readPubspecDependencies(filePath) {
|
|
731
|
+
const dependencies = [];
|
|
732
|
+
let inDependencies = false;
|
|
733
|
+
try {
|
|
734
|
+
for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
|
|
735
|
+
if (/^dependencies:\s*$/.test(line)) {
|
|
736
|
+
inDependencies = true;
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (inDependencies && /^\S/.test(line) && !/^dependencies:\s*$/.test(line)) break;
|
|
740
|
+
const match = line.match(/^\s{2}([A-Za-z0-9_-]+):/);
|
|
741
|
+
if (inDependencies && match) dependencies.push(match[1]);
|
|
742
|
+
}
|
|
743
|
+
} catch {
|
|
744
|
+
return [];
|
|
745
|
+
}
|
|
746
|
+
return dependencies;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function collectExistingFiles(cwd, relativeDirs, files) {
|
|
750
|
+
for (const relativeDir of relativeDirs) {
|
|
751
|
+
const directory = path.join(cwd, relativeDir);
|
|
752
|
+
let entries = [];
|
|
753
|
+
try {
|
|
754
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
755
|
+
} catch {
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
files.add(normalizeFile(relativeDir));
|
|
759
|
+
for (const entry of entries) {
|
|
760
|
+
if (entry.isFile()) files.add(normalizeFile(path.join(relativeDir, entry.name)));
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function matchTextTriggers(text, triggers = []) {
|
|
766
|
+
const normalizedText = normalize(text);
|
|
767
|
+
const matches = [];
|
|
768
|
+
for (const trigger of triggers || []) {
|
|
769
|
+
const normalizedTrigger = normalize(trigger);
|
|
770
|
+
if (!normalizedTrigger) continue;
|
|
771
|
+
if (normalizedText.includes(normalizedTrigger)) matches.push(trigger);
|
|
772
|
+
}
|
|
773
|
+
return { score: matches.length ? 1 : 0, matches };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function matchList(values = [], triggers = []) {
|
|
777
|
+
const valueSet = new Set(values.map(normalizeDependency));
|
|
778
|
+
const matches = [];
|
|
779
|
+
for (const trigger of triggers || []) {
|
|
780
|
+
const normalizedTrigger = normalizeDependency(trigger);
|
|
781
|
+
if (valueSet.has(normalizedTrigger)) matches.push(trigger);
|
|
782
|
+
}
|
|
783
|
+
return { score: matches.length ? 1 : 0, matches };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function matchFiles(files = [], triggers = []) {
|
|
787
|
+
const normalizedFiles = files.map(normalizeFile);
|
|
788
|
+
const matches = [];
|
|
789
|
+
for (const trigger of triggers || []) {
|
|
790
|
+
const normalizedTrigger = normalizeFile(trigger);
|
|
791
|
+
if (!normalizedTrigger) continue;
|
|
792
|
+
if (normalizedTrigger.endsWith("/*")) {
|
|
793
|
+
const prefix = normalizedTrigger.slice(0, -1);
|
|
794
|
+
if (normalizedFiles.some((file) => file.startsWith(prefix))) matches.push(trigger);
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
if (normalizedFiles.some((file) => file === normalizedTrigger || file.endsWith(`/${normalizedTrigger}`))) matches.push(trigger);
|
|
798
|
+
}
|
|
799
|
+
return { score: matches.length ? 1 : 0, matches };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function metadataHasSignals(metadata = {}) {
|
|
803
|
+
return [
|
|
804
|
+
metadata.positivePrompts,
|
|
805
|
+
metadata.files,
|
|
806
|
+
metadata.dependencies,
|
|
807
|
+
metadata.negativePrompts,
|
|
808
|
+
metadata.negativeFiles,
|
|
809
|
+
metadata.negativeDependencies
|
|
810
|
+
].some((items) => Array.isArray(items) && items.length > 0);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function asArray(value) {
|
|
814
|
+
if (Array.isArray(value)) return value.map(String).filter(Boolean);
|
|
815
|
+
if (value === undefined || value === null || value === "") return [];
|
|
816
|
+
return [String(value)];
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function parseScalar(value) {
|
|
820
|
+
const trimmed = String(value || "").trim();
|
|
821
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
822
|
+
return trimmed.slice(1, -1).split(",").map((item) => item.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
823
|
+
}
|
|
824
|
+
return trimmed.replace(/^["']|["']$/g, "");
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function nextMeaningfulLine(lines, currentRawLine) {
|
|
828
|
+
const start = lines.indexOf(currentRawLine);
|
|
829
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
830
|
+
const line = lines[index];
|
|
831
|
+
if (line.trim() && !line.trimStart().startsWith("#")) return line;
|
|
832
|
+
}
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function normalizeFile(value) {
|
|
837
|
+
return String(value || "").replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function normalizeDependency(value) {
|
|
841
|
+
return String(value || "").toLowerCase().trim();
|
|
842
|
+
}
|
|
843
|
+
|
|
423
844
|
function addHintText(hints, value) {
|
|
424
845
|
for (const token of normalize(value).split(/\s+/).filter(Boolean)) hints.add(token);
|
|
425
846
|
}
|
|
@@ -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
|
-
|
|
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
|
+
}
|