@minhpnq1807/contextos 0.5.50 → 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 (58) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +114 -9
  3. package/bin/ctx.js +64 -8
  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/analyzer.js +17 -2
  45. package/plugins/ctx/lib/auto-warm.js +1 -0
  46. package/plugins/ctx/lib/ctx-mcp-client.js +21 -0
  47. package/plugins/ctx/lib/embedding-scorer.js +34 -0
  48. package/plugins/ctx/lib/hook-io.js +11 -1
  49. package/plugins/ctx/lib/package-install.js +1 -1
  50. package/plugins/ctx/lib/project-profiler.js +5 -1
  51. package/plugins/ctx/lib/prompt-hook.js +17 -2
  52. package/plugins/ctx/lib/score-context.js +13 -2
  53. package/plugins/ctx/lib/setup-wizard.js +8 -3
  54. package/plugins/ctx/lib/skill-discoverer.js +480 -27
  55. package/plugins/ctx/lib/skillshare-sync.js +112 -0
  56. package/plugins/ctx/lib/workflow-discoverer.js +3 -1
  57. package/plugins/ctx/mcp/contextos-server.js +29 -1
  58. package/plugins/ctx/mcp/server.js +50 -4
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { suggestSkills } from "../../plugins/ctx/lib/skill-discoverer.js";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const evalRoot = __dirname;
10
+
11
+ export async function runSkillRoutingEval({
12
+ rootDir = path.resolve(evalRoot, "..", ".."),
13
+ casesPath = path.join(evalRoot, "cases.yaml"),
14
+ topK = 3,
15
+ threshold = 0.5
16
+ } = {}) {
17
+ const config = parseEvalYaml(fs.readFileSync(casesPath, "utf8"));
18
+ const skills = config.skills.map((skill) => ({
19
+ name: skill.id,
20
+ description: skill.description,
21
+ path: path.join(evalRoot, "skills", skill.id, "SKILL.md"),
22
+ metadata: skillToMetadata(skill)
23
+ }));
24
+
25
+ const rows = [];
26
+ for (const testCase of config.cases) {
27
+ const cwd = testCase.fixture === "contextos"
28
+ ? rootDir
29
+ : path.join(evalRoot, "fixtures", testCase.fixture);
30
+ const scores = semanticScores({ prompt: testCase.prompt, skills });
31
+ const suggestions = await suggestSkills({
32
+ cwd,
33
+ prompt: testCase.prompt,
34
+ skills,
35
+ limit: Math.max(topK, 10),
36
+ dataDir: path.join(evalRoot, ".tmp"),
37
+ indexedSearcher: async () => ({
38
+ status: "enabled",
39
+ items: skills.map((skill) => ({
40
+ id: normalizeSkillId(skill.name),
41
+ text: skill.name,
42
+ embeddingScore: scores.get(skill.name) || 0.45
43
+ }))
44
+ })
45
+ });
46
+ const selected = suggestions
47
+ .filter((skill) => Number(skill.confidence || skill.score || 0) >= threshold)
48
+ .slice(0, topK);
49
+ rows.push({
50
+ prompt: testCase.prompt,
51
+ fixture: testCase.fixture,
52
+ expected: testCase.expected,
53
+ allowed: testCase.allowed || [],
54
+ forbidden: testCase.forbidden,
55
+ selected,
56
+ selectedIds: selected.map((skill) => skill.name)
57
+ });
58
+ }
59
+
60
+ return summarizeRows(rows, { topK });
61
+ }
62
+
63
+ export function formatSkillRoutingBenchmark(result) {
64
+ const percent = (value) => `${(value * 100).toFixed(1)}%`;
65
+ const failures = result.rows.filter((row) => row.failed);
66
+ const lines = [
67
+ "Skill Routing Benchmark",
68
+ `Cases: ${result.caseCount}`,
69
+ `Top-1 Accuracy: ${percent(result.top1Accuracy)}`,
70
+ `Top-3 Recall: ${percent(result.top3Recall)}`,
71
+ `False Positive Rate: ${percent(result.falsePositiveRate)}`,
72
+ `Confidence Calibration: ${percent(result.confidenceCalibration)}`,
73
+ `Negative Gate Accuracy: ${percent(result.negativeGateAccuracy)}`,
74
+ "",
75
+ failures.length ? "Failures:" : "Failures: none"
76
+ ];
77
+ for (const row of failures) {
78
+ lines.push(`- ${row.fixture}: "${row.prompt}"`);
79
+ lines.push(` expected: ${row.expected.join(", ") || "(none)"}`);
80
+ if (row.allowed.length) {
81
+ lines.push(` allowed: ${row.allowed.join(", ")}`);
82
+ }
83
+ lines.push(` selected: ${row.selectedIds.join(", ") || "(none)"}`);
84
+ if (row.forbidden.length) {
85
+ lines.push(` rejected: ${row.forbidden.join(", ")}`);
86
+ }
87
+ }
88
+ return lines.join("\n");
89
+ }
90
+
91
+ function summarizeRows(rows, { topK }) {
92
+ let top1Hits = 0;
93
+ let recallHits = 0;
94
+ let recallTotal = 0;
95
+ let falsePositiveHits = 0;
96
+ let falsePositiveTotal = 0;
97
+ let negativeGateHits = 0;
98
+ let negativeGateTotal = 0;
99
+ let calibrationHits = 0;
100
+ let calibrationTotal = 0;
101
+
102
+ for (const row of rows) {
103
+ const expected = new Set(row.expected);
104
+ const accepted = new Set([...row.expected, ...row.allowed]);
105
+ const selected = row.selectedIds.slice(0, topK);
106
+ row.failed = false;
107
+ if (!row.expected.length) {
108
+ if (!selected.length) {
109
+ top1Hits += 1;
110
+ }
111
+ } else if (expected.has(selected[0])) {
112
+ top1Hits += 1;
113
+ }
114
+
115
+ recallTotal += row.expected.length;
116
+ for (const skill of row.expected) {
117
+ if (selected.includes(skill)) {
118
+ recallHits += 1;
119
+ } else {
120
+ row.failed = true;
121
+ }
122
+ }
123
+
124
+ falsePositiveTotal += selected.length;
125
+ for (const skill of selected) {
126
+ if (!accepted.has(skill)) {
127
+ falsePositiveHits += 1;
128
+ row.failed = true;
129
+ }
130
+ }
131
+
132
+ negativeGateTotal += row.forbidden.length;
133
+ for (const skill of row.forbidden) {
134
+ if (!selected.includes(skill)) {
135
+ negativeGateHits += 1;
136
+ } else {
137
+ row.failed = true;
138
+ }
139
+ }
140
+
141
+ for (const skill of row.selected) {
142
+ calibrationTotal += 1;
143
+ const acceptedSelected = accepted.has(skill.name);
144
+ const confident = Number(skill.confidence || 0) >= 0.65;
145
+ if (acceptedSelected === confident) calibrationHits += 1;
146
+ }
147
+ if (!row.selected.length && !row.expected.length) {
148
+ calibrationTotal += 1;
149
+ calibrationHits += 1;
150
+ }
151
+ }
152
+
153
+ return {
154
+ caseCount: rows.length,
155
+ top1Accuracy: rows.length ? top1Hits / rows.length : 0,
156
+ top3Recall: recallTotal ? recallHits / recallTotal : 1,
157
+ falsePositiveRate: falsePositiveTotal ? falsePositiveHits / falsePositiveTotal : 0,
158
+ confidenceCalibration: calibrationTotal ? calibrationHits / calibrationTotal : 1,
159
+ negativeGateAccuracy: negativeGateTotal ? negativeGateHits / negativeGateTotal : 1,
160
+ rows
161
+ };
162
+ }
163
+
164
+ function skillToMetadata(skill) {
165
+ return {
166
+ id: skill.id,
167
+ name: skill.id,
168
+ positivePrompts: skill.positive_triggers?.prompts || [],
169
+ files: skill.positive_triggers?.files || [],
170
+ dependencies: skill.positive_triggers?.dependencies || [],
171
+ negativePrompts: skill.negative_triggers?.prompts || [],
172
+ negativeFiles: skill.negative_triggers?.files || [],
173
+ negativeDependencies: skill.negative_triggers?.dependencies || [],
174
+ relatedSkills: skill.related_skills || []
175
+ };
176
+ }
177
+
178
+ function semanticScores({ prompt, skills }) {
179
+ const promptTokens = new Set(tokenize(prompt));
180
+ const scores = new Map();
181
+ for (const skill of skills) {
182
+ const tokens = new Set(tokenize(`${skill.name} ${skill.description}`));
183
+ const overlap = [...promptTokens].filter((token) => tokens.has(token)).length;
184
+ scores.set(skill.name, Math.max(0.45, Math.min(0.92, 0.5 + overlap * 0.1)));
185
+ }
186
+ return scores;
187
+ }
188
+
189
+ function tokenize(value) {
190
+ return String(value || "").toLowerCase().split(/[^a-z0-9@.-]+/).filter((token) => token.length > 2);
191
+ }
192
+
193
+ function normalizeSkillId(name) {
194
+ return String(name || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
195
+ }
196
+
197
+ export function parseEvalYaml(content) {
198
+ const lines = String(content || "").split(/\r?\n/);
199
+ const result = { skills: [], cases: [] };
200
+ let section = null;
201
+ let current = null;
202
+ let triggerGroup = null;
203
+
204
+ for (const rawLine of lines) {
205
+ if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) continue;
206
+ const line = rawLine.trim();
207
+ if (line === "skills:") {
208
+ section = "skills";
209
+ current = null;
210
+ continue;
211
+ }
212
+ if (line === "cases:") {
213
+ section = "cases";
214
+ current = null;
215
+ continue;
216
+ }
217
+ if (line.startsWith("- id:")) {
218
+ current = {
219
+ id: scalar(line.slice(5)),
220
+ description: "",
221
+ positive_triggers: {},
222
+ negative_triggers: {},
223
+ related_skills: []
224
+ };
225
+ result.skills.push(current);
226
+ triggerGroup = null;
227
+ continue;
228
+ }
229
+ if (line.startsWith("- prompt:")) {
230
+ current = {
231
+ prompt: scalar(line.slice(9)),
232
+ fixture: "",
233
+ expected: [],
234
+ allowed: [],
235
+ forbidden: []
236
+ };
237
+ result.cases.push(current);
238
+ triggerGroup = null;
239
+ continue;
240
+ }
241
+ if (!current) continue;
242
+ if (line === "positive_triggers:") {
243
+ triggerGroup = current.positive_triggers;
244
+ continue;
245
+ }
246
+ if (line === "negative_triggers:") {
247
+ triggerGroup = current.negative_triggers;
248
+ continue;
249
+ }
250
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
251
+ if (!match) continue;
252
+ const [, key, rawValue] = match;
253
+ const normalizedKey = key === "rejected" ? "forbidden" : key;
254
+ const target = triggerGroup && ["prompts", "files", "dependencies"].includes(key)
255
+ ? triggerGroup
256
+ : current;
257
+ target[normalizedKey] = parseValue(rawValue);
258
+ }
259
+
260
+ return result;
261
+ }
262
+
263
+ function parseValue(value) {
264
+ const trimmed = String(value || "").trim();
265
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
266
+ return trimmed.slice(1, -1).split(",").map(scalar).filter(Boolean);
267
+ }
268
+ return scalar(trimmed);
269
+ }
270
+
271
+ function scalar(value) {
272
+ return String(value || "").trim().replace(/^["']|["']$/g, "");
273
+ }
274
+
275
+ if (import.meta.url === `file://${process.argv[1]}`) {
276
+ const result = await runSkillRoutingEval();
277
+ console.log(formatSkillRoutingBenchmark(result));
278
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhpnq1807/contextos",
3
- "version": "0.5.50",
3
+ "version": "0.5.52",
4
4
  "description": "Task-aware AGENTS.md context injection and compliance reporting for Codex, Claude Code, and Antigravity.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "README.md",
15
15
  "DEMO.md",
16
16
  "LAUNCH.md",
17
+ "eval/",
17
18
  "docs/",
18
19
  "LICENSE",
19
20
  "CHANGELOG.md"
@@ -22,6 +23,7 @@
22
23
  "test": "vitest run test",
23
24
  "build": "node bin/ctx.js --version",
24
25
  "validate:plugin": "node test/validate-plugin.js",
26
+ "benchmark:skills": "node bin/ctx.js benchmark --skills",
25
27
  "test:mcp": "node test/mcp-protocol-smoke.js"
26
28
  },
27
29
  "engines": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctx",
3
- "version": "0.5.50",
3
+ "version": "0.5.52",
4
4
  "description": "Inject task-relevant AGENTS.md rules into Codex through plugin hooks.",
5
5
  "author": {
6
6
  "name": "ContextOS"
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { findGraphRelevantFiles, mergeRelevantFiles } from "./graph-retriever.js";
4
4
  import { expandImportGraph } from "./import-graph.js";
5
5
  import { findEmbeddingRelevantFiles } from "./file-embedding-retriever.js";
6
+ import { workspacePackagePaths } from "./project-profiler.js";
6
7
 
7
8
  const STOP_WORDS = new Set([
8
9
  "a", "an", "and", "are", "as", "at", "be", "by", "cho", "co", "cua", "do", "fix", "for",
@@ -433,9 +434,9 @@ function addAll(target, values) {
433
434
  export function findExplicitPromptFiles({ cwd = process.cwd(), task = "", limit = 6 } = {}) {
434
435
  const candidates = new Set();
435
436
  const normalizedTask = String(task || "").replace(/\/\s+/g, "/");
436
- const matches = normalizedTask.match(/[A-Za-z0-9_.()[\]@~:-]+(?:\/[A-Za-z0-9_.()[\]@~:-]+)+/g) || [];
437
+ const matches = normalizedTask.match(/[A-Za-z0-9_.()[\]@~:,-]+(?:\/[A-Za-z0-9_.()[\]@~:,-]+)+/g) || [];
437
438
  for (const match of matches) {
438
- const cleaned = match.replace(/[),.;:]+$/g, "");
439
+ const cleaned = cleanPromptFilePath(match);
439
440
  for (const filePath of resolvePromptPathCandidates({ cwd, promptPath: cleaned })) {
440
441
  candidates.add(filePath);
441
442
  if (candidates.size >= limit) break;
@@ -450,6 +451,13 @@ export function findExplicitPromptFiles({ cwd = process.cwd(), task = "", limit
450
451
  }));
451
452
  }
452
453
 
454
+ function cleanPromptFilePath(value) {
455
+ return String(value || "")
456
+ .replace(/\.(tsx?|jsx?|mjs|cjs|json|md|sql|py)\(\d+(?:,\d+)?\)[),.;:]*$/i, ".$1")
457
+ .replace(/\.(tsx?|jsx?|mjs|cjs|json|md|sql|py):\d+(?::\d+)?[),.;:]*$/i, ".$1")
458
+ .replace(/[),.;:]+$/g, "");
459
+ }
460
+
453
461
  function resolvePromptPathCandidates({ cwd, promptPath }) {
454
462
  if (!promptPath || promptPath.includes("://")) return [];
455
463
  const relative = promptPath.replace(/^\.?\//, "");
@@ -470,6 +478,13 @@ function resolvePromptPathCandidates({ cwd, promptPath }) {
470
478
  if (isSourceFile(candidate)) resolved.push(path.relative(cwd, candidate));
471
479
  }
472
480
  }
481
+ if (!resolved.length && !relative.startsWith("..")) {
482
+ for (const packagePath of workspacePackagePaths(cwd).slice(1)) {
483
+ const packageDir = path.dirname(packagePath);
484
+ const candidate = path.join(packageDir, relative);
485
+ if (isSourceFile(candidate)) resolved.push(path.relative(cwd, candidate));
486
+ }
487
+ }
473
488
  return resolved;
474
489
  }
475
490
 
@@ -57,6 +57,7 @@ export function maybeAutoWarmWorkspace({
57
57
  function shouldAutoWarm(reason) {
58
58
  if (reason === "no-context-candidates") return true;
59
59
  if (reason === "enabled-sections-empty-after-formatting") return true;
60
+ if (String(reason || "").startsWith("enabled-sections-missing-candidates:")) return true;
60
61
  return false;
61
62
  }
62
63
 
@@ -29,6 +29,25 @@ export async function callCtxScoreContext(payload, {
29
29
  connectTimeoutMs = Number(process.env.CONTEXTOS_MCP_CONNECT_TIMEOUT_MS || DEFAULT_CONNECT_TIMEOUT_MS),
30
30
  createConnection = net.createConnection
31
31
  } = {}) {
32
+ return callBridge(payload, { dataDir, timeoutMs, connectTimeoutMs, createConnection });
33
+ }
34
+
35
+ export async function callCtxHealth({
36
+ dataDir = defaultDataDir(),
37
+ timeoutMs = Number(process.env.CONTEXTOS_MCP_HEALTH_TIMEOUT_MS || 250),
38
+ connectTimeoutMs = Number(process.env.CONTEXTOS_MCP_CONNECT_TIMEOUT_MS || DEFAULT_CONNECT_TIMEOUT_MS),
39
+ createConnection = net.createConnection
40
+ } = {}) {
41
+ const response = await callBridge({ type: "health" }, { dataDir, timeoutMs, connectTimeoutMs, createConnection });
42
+ return response.health || {};
43
+ }
44
+
45
+ async function callBridge(payload, {
46
+ dataDir,
47
+ timeoutMs,
48
+ connectTimeoutMs,
49
+ createConnection
50
+ }) {
32
51
  const socketPath = ctxMcpSocketPath(dataDir);
33
52
  if (!fs.existsSync(socketPath)) {
34
53
  throw new Error(`ctx-mcp bridge socket not found: ${socketPath}`);
@@ -40,6 +59,7 @@ export async function callCtxScoreContext(payload, {
40
59
  let raw = "";
41
60
  let responseTimer;
42
61
  const connectTimer = setTimeout(() => {
62
+ invalidateSocketIfUnchanged(socketPath, socketIdentity);
43
63
  client.destroy();
44
64
  reject(new Error(`ctx-mcp bridge connect timed out after ${connectTimeoutMs}ms`));
45
65
  }, connectTimeoutMs);
@@ -47,6 +67,7 @@ export async function callCtxScoreContext(payload, {
47
67
  client.on("connect", () => {
48
68
  clearTimeout(connectTimer);
49
69
  responseTimer = setTimeout(() => {
70
+ invalidateSocketIfUnchanged(socketPath, socketIdentity);
50
71
  client.destroy();
51
72
  reject(new Error(`ctx-mcp bridge timed out after ${timeoutMs}ms`));
52
73
  }, timeoutMs);
@@ -152,6 +152,40 @@ export async function warmIndexedEmbeddings({
152
152
  }
153
153
  }
154
154
 
155
+ export async function preloadEmbeddingPipeline({
156
+ dataDir = defaultDataRoot(),
157
+ allowRemote = false,
158
+ warmText = "contextos warmup"
159
+ } = {}) {
160
+ if (!allowRemote && !isModelCacheReady(dataDir)) {
161
+ return { status: "missing-model", loaded: false, cachePath: path.join(dataDir, "embeddings.db") };
162
+ }
163
+ const started = Date.now();
164
+ try {
165
+ const embedder = await getExtractor({ allowRemote, dataDir });
166
+ await embedder(String(warmText || "contextos warmup"), {
167
+ pooling: "mean",
168
+ normalize: true
169
+ });
170
+ return {
171
+ status: "loaded",
172
+ loaded: true,
173
+ model: DEFAULT_MODEL,
174
+ cachePath: path.join(dataDir, "embeddings.db"),
175
+ elapsedMs: Date.now() - started
176
+ };
177
+ } catch (error) {
178
+ return {
179
+ status: "load-failed",
180
+ loaded: false,
181
+ model: DEFAULT_MODEL,
182
+ cachePath: path.join(dataDir, "embeddings.db"),
183
+ elapsedMs: Date.now() - started,
184
+ error: error?.message || String(error)
185
+ };
186
+ }
187
+ }
188
+
155
189
  async function enhanceRuleScores(rules, task, { dataDir, sources, allowRemote }) {
156
190
  const cache = await openEmbeddingCache(dataDir);
157
191
  const embedder = await getExtractor({ allowRemote, dataDir });
@@ -3,6 +3,8 @@ import path from "node:path";
3
3
  import { writeJsonFile } from "./fs-utils.js";
4
4
  import { defaultDataRoot, workspaceDataDir } from "./workspace-data.js";
5
5
 
6
+ let pendingStdoutWrites = 0;
7
+
6
8
  export async function readStdinJson() {
7
9
  const chunks = [];
8
10
  for await (const chunk of process.stdin) chunks.push(chunk);
@@ -13,13 +15,21 @@ export async function readStdinJson() {
13
15
 
14
16
  export function writeJson(value) {
15
17
  try {
16
- process.stdout.write(`${JSON.stringify(value)}\n`);
18
+ pendingStdoutWrites += 1;
19
+ process.stdout.write(`${JSON.stringify(value)}\n`, () => {
20
+ pendingStdoutWrites = Math.max(0, pendingStdoutWrites - 1);
21
+ });
17
22
  } catch (error) {
23
+ pendingStdoutWrites = Math.max(0, pendingStdoutWrites - 1);
18
24
  if (error?.code !== "EPIPE") throw error;
19
25
  }
20
26
  }
21
27
 
22
28
  export function exitAfterStdout(code = 0) {
29
+ if (pendingStdoutWrites > 0) {
30
+ setImmediate(() => exitAfterStdout(code));
31
+ return;
32
+ }
23
33
  if (process.stdout.writableNeedDrain) {
24
34
  process.stdout.once("drain", () => process.exit(code));
25
35
  return;
@@ -28,7 +28,7 @@ export function copyPath(src, dest) {
28
28
 
29
29
  export function copyPackageRoot({ rootDir, targetRoot }) {
30
30
  fs.rmSync(targetRoot, { recursive: true, force: true });
31
- for (const entry of [".agents", "bin", "plugins", "package.json", "package-lock.json", "README.md", "LICENSE", "node_modules"]) {
31
+ for (const entry of [".agents", "bin", "plugins", "eval", "docs", "package.json", "package-lock.json", "README.md", "CHANGELOG.md", "DEMO.md", "LAUNCH.md", "LICENSE", "node_modules"]) {
32
32
  const src = path.join(rootDir, entry);
33
33
  if (fs.existsSync(src)) copyPath(src, path.join(targetRoot, entry));
34
34
  }
@@ -6,6 +6,7 @@ const PROFILE_CACHE_FILE = "project-profile.json";
6
6
  const MAX_DEPENDENCIES = 80;
7
7
  const MAX_SCRIPTS = 30;
8
8
  const MAX_RECENT_FILES = 20;
9
+ const MAX_FUSED_PROFILE_CHARS = 500;
9
10
 
10
11
  export function projectProfile({ cwd = process.cwd(), dataDir } = {}) {
11
12
  const fingerprint = projectFingerprint(cwd);
@@ -30,7 +31,10 @@ export function projectProfile({ cwd = process.cwd(), dataDir } = {}) {
30
31
 
31
32
  export function fusedProjectQuery({ prompt = "", cwd = process.cwd(), dataDir } = {}) {
32
33
  const profile = projectProfile({ cwd, dataDir });
33
- return [String(prompt || "").trim(), profile.embeddableString].filter(Boolean).join("\n");
34
+ const profileSignal = profile.embeddableString
35
+ ? profile.embeddableString.slice(0, MAX_FUSED_PROFILE_CHARS)
36
+ : "";
37
+ return [String(prompt || "").trim(), profileSignal].filter(Boolean).join("\n");
34
38
  }
35
39
 
36
40
  function readCachedProfile(cachePath, fingerprint) {
@@ -1,7 +1,7 @@
1
1
  import { scheduleContext } from "./scheduler.js";
2
2
  import { appendJsonLine, writeJsonFile } from "./fs-utils.js";
3
3
  import { maybeAutoWarmWorkspace } from "./auto-warm.js";
4
- import { callCtxScoreContext } from "./ctx-mcp-client.js";
4
+ import { callCtxHealth, callCtxScoreContext } from "./ctx-mcp-client.js";
5
5
  import { resolveHookCwd } from "./hook-io.js";
6
6
  import { loadOutputConfig, outputConfigLimits } from "./output-config.js";
7
7
  import { scoreContext as scoreContextDirect } from "./score-context.js";
@@ -17,11 +17,13 @@ export async function handlePromptPayload(
17
17
  started = Date.now(),
18
18
  injectContext = process.env.CONTEXTOS_INJECT !== "0",
19
19
  scoreContextClient = callCtxScoreContext,
20
+ healthContextClient = callCtxHealth,
20
21
  scoreContextDirectClient = scoreContextDirect,
21
22
  autoWarmWorkspace = maybeAutoWarmWorkspace,
22
23
  mcpDataDir,
23
24
  outputConfig,
24
- directFallbackTimeoutMs = Number(process.env.CONTEXTOS_DIRECT_FALLBACK_TIMEOUT_MS || 6000)
25
+ directFallbackTimeoutMs = Number(process.env.CONTEXTOS_DIRECT_FALLBACK_TIMEOUT_MS || 2500),
26
+ requireHotMcp = scoreContextClient === callCtxScoreContext
25
27
  } = {}
26
28
  ) {
27
29
  const prompt = payload.prompt || payload.message || payload.user_prompt || "";
@@ -34,6 +36,15 @@ export async function handlePromptPayload(
34
36
 
35
37
  let scored;
36
38
  try {
39
+ if (requireHotMcp) {
40
+ const health = await healthContextClient({
41
+ dataDir: mcpDataDir || dataDir,
42
+ timeoutMs: Number(process.env.CONTEXTOS_MCP_HEALTH_TIMEOUT_MS || 250)
43
+ });
44
+ if (!health.embedding_pipeline_loaded) {
45
+ throw new Error(`ctx-mcp scorer not hot: ${health.preload_status || "unknown"}`);
46
+ }
47
+ }
37
48
  scored = await scoreContextClient({
38
49
  cwd,
39
50
  prompt,
@@ -55,6 +66,7 @@ export async function handlePromptPayload(
55
66
  maxSkills: promptLimits.skills,
56
67
  maxWorkflows: promptLimits.workflows,
57
68
  dataDir: mcpDataDir || dataDir,
69
+ allowEmbeddings: false,
58
70
  embeddingTimeoutMs: Number(process.env.CONTEXTOS_HOOK_EMBEDDING_TIMEOUT_MS || 500),
59
71
  fileEmbeddingTimeoutMs: Number(process.env.CONTEXTOS_HOOK_FILE_EMBEDDING_TIMEOUT_MS || 1000),
60
72
  skillEmbeddingTimeoutMs: Number(process.env.CONTEXTOS_HOOK_SKILL_EMBEDDING_TIMEOUT_MS || 2000)
@@ -197,6 +209,9 @@ function emptyContextReason({ scheduled, outputConfig, injectContext }) {
197
209
  if (scheduled.suggestedSkills?.length) available.push("skills");
198
210
  if (scheduled.suggestedWorkflows?.length) available.push("workflows");
199
211
  if (!available.length) return "no-context-candidates";
212
+ const enabledMissing = ["rules", "files", "skills", "workflows"]
213
+ .filter((section) => sections[section] !== false && !available.includes(section));
214
+ if (enabledMissing.length) return `enabled-sections-missing-candidates:${enabledMissing.join(",")}`;
200
215
  const enabled = available.filter((section) => sections[section] !== false);
201
216
  return enabled.length ? "enabled-sections-empty-after-formatting" : `available-sections-disabled:${available.join(",")}`;
202
217
  }
@@ -19,7 +19,8 @@ export async function scoreContext({
19
19
  embeddingTimeoutMs = 5000,
20
20
  fileEmbeddingTimeoutMs = Number(process.env.CONTEXTOS_FILE_EMBEDDING_TIMEOUT_MS || 1000),
21
21
  skillEmbeddingTimeoutMs = Number(process.env.CONTEXTOS_SKILL_EMBEDDING_TIMEOUT_MS || embeddingTimeoutMs),
22
- skillSearchOptions = {}
22
+ skillSearchOptions = {},
23
+ allowEmbeddings = true
23
24
  } = {}) {
24
25
  const started = Date.now();
25
26
  const ruleInputsPromise = Promise.resolve().then(() => {
@@ -35,6 +36,14 @@ export async function scoreContext({
35
36
  });
36
37
 
37
38
  const rulesPromise = ruleInputsPromise.then(({ merged, baseScoredRules }) => {
39
+ if (!allowEmbeddings) {
40
+ return {
41
+ rules: baseScoredRules,
42
+ status: "disabled",
43
+ model: null,
44
+ cachePath: dataDir
45
+ };
46
+ }
38
47
  return enhanceRuleScoresWithEmbeddings(baseScoredRules, prompt, {
39
48
  dataDir,
40
49
  sources: merged.sources,
@@ -52,6 +61,7 @@ export async function scoreContext({
52
61
  limit: maxFiles,
53
62
  fileEmbeddingTimeoutMs,
54
63
  fileEmbeddingOptions: {
64
+ enabled: allowEmbeddings,
55
65
  allowRemote: false
56
66
  }
57
67
  });
@@ -68,6 +78,7 @@ export async function scoreContext({
68
78
  dataDir,
69
79
  limit: maxSkills,
70
80
  timeoutMs: skillEmbeddingTimeoutMs,
81
+ embeddingsEnabled: allowEmbeddings,
71
82
  ...skillSearchOptions
72
83
  })
73
84
  };
@@ -77,7 +88,7 @@ export async function scoreContext({
77
88
  const catalog = Array.isArray(workflows) ? workflows : scanWorkflows({ cwd });
78
89
  return {
79
90
  catalog,
80
- suggestions: await suggestWorkflows({ prompt, workflows: catalog, dataDir, limit: maxWorkflows })
91
+ suggestions: await suggestWorkflows({ prompt, workflows: catalog, dataDir, limit: maxWorkflows, embeddingsEnabled: allowEmbeddings })
81
92
  };
82
93
  });
83
94
 
@@ -1,17 +1,22 @@
1
- // No agents pre-selected by default user must choose explicitly
1
+ // Interactive setup starts empty so users choose intentionally. Non-interactive
2
+ // --yes needs a deterministic target, so it defaults to Codex.
2
3
  const DEFAULT_AGENTS = [];
4
+ const DEFAULT_YES_AGENTS = ["codex"];
3
5
 
4
6
  export function parseSetupArgs(args = []) {
5
7
  const agentsFlag = args.indexOf("--agents");
6
8
  const agentsProvided = agentsFlag >= 0;
9
+ const yes = args.includes("--yes") || args.includes("-y");
7
10
  const agents = agentsFlag >= 0
8
11
  ? parseAgentList(args[agentsFlag + 1])
9
- : DEFAULT_AGENTS;
12
+ : yes
13
+ ? DEFAULT_YES_AGENTS
14
+ : DEFAULT_AGENTS;
10
15
 
11
16
  return {
12
17
  agents,
13
18
  agentsProvided,
14
- yes: args.includes("--yes") || args.includes("-y"),
19
+ yes,
15
20
  quiet: args.includes("--quiet"),
16
21
  syncRules: !args.includes("--no-rules"),
17
22
  syncSkills: !args.includes("--no-skills")