@minhpnq1807/contextos 0.6.7 → 0.6.8

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.8
4
+
5
+ - **Fallback context recovery:** Prompt-hook direct fallback now keeps ContextOS useful when `ctx-mcp` is unavailable by using indexed file text matches and lightweight evidence-based skill scoring instead of returning only workflows or an empty context.
6
+ - **MCP bridge hardening:** Increased default bridge/connect timeouts, added `ctx health`, added bounded ctx-mcp daemon auto-start when the private socket is missing, and records retrieval mode in runtime telemetry/debug output.
7
+ - **Agent-callable read-only MCP tools:** Added safe MCP tools for `ctx debug`, `ctx doctor`, `ctx skills doctor`, `ctx report`, `ctx evidence`, and `ctx stats` so agents can inspect ContextOS state without shelling out or exposing setup/install/sync write commands.
8
+
3
9
  ## 0.6.7
4
10
 
5
11
  - **MCP proxy test stability:** Fixed the MCP proxy telemetry smoke test so it waits for the telemetry file before reading it and uses a stable stdin/stdout echo child. This removes the CI race where `telemetry.jsonl` could be read before it existed.
package/README.md CHANGED
@@ -391,6 +391,21 @@ Hook lightweight fallback: 0.69s
391
391
  MCP embedding hot startup: 477ms
392
392
  ```
393
393
 
394
+ Agents can call read-only ContextOS MCP tools directly:
395
+
396
+ ```text
397
+ ctx_health
398
+ ctx_score_context
399
+ ctx_debug_context
400
+ ctx_doctor_repo
401
+ ctx_skills_doctor
402
+ ctx_report_last_task
403
+ ctx_evidence_last_task
404
+ ctx_stats_workspace
405
+ ```
406
+
407
+ Write commands such as `ctx setup`, `ctx install`, `ctx refresh`, and `ctx sync` are not exposed as MCP tools by default.
408
+
394
409
  During install, ContextOS prints a 0-100 progress indicator. The longest stage is usually embedding warmup; if the model is already cached, install skips the download and only refreshes vectors.
395
410
 
396
411
  Verify the published package in any project:
@@ -591,6 +606,7 @@ This warning comes from a transitive dependency in the local embedding/WASM stac
591
606
  | `ctx setup --no-skills` | Skips skillshare sync during setup. | You do not want shared skills configured. | Does not run `ctx sync --skills`. |
592
607
  | `ctx setup --quiet` | Runs setup in measurement-only mode. | You want reports/stats without visible injected prompt context. | Installs hooks with prompt context injection disabled. |
593
608
  | `ctx debug -- "task"` | Runs the scheduler locally for a fake prompt. | You want to see which AGENTS.md rules and files ContextOS would inject before using Codex. | Prints rule scores, scoring reasons, suggested files, and final `additionalContext`. |
609
+ | `ctx health` | Checks local ctx-mcp bridge/model/index readiness. | Hooks are falling back or suggestions look stale. | Prints bridge connection, model hot status, and whether local indexes exist. |
594
610
  | `ctx doctor` | Scores repository ContextOS readiness. | You want to add or verify a `ContextOS Ready` badge. | Prints Rules, Skills, Workflows, Overall tier, evidence, and next recommendations. |
595
611
  | `ctx doctor --fix` | Generates starter ContextOS project context. | `ctx doctor` says skills/workflows are missing and you want explicit local scaffolding. | Detects package/config evidence, creates up to three shared project skills plus `.agents/workflows/primary.md`, then prints the updated readiness score. |
596
612
  | `ctx report` | Shows the last Stop-hook compliance report for the current workspace. | An agent task has finished and you want the summary again. | Prints sectioned tables for summary, rule outcomes, suggested files, and runtime telemetry from `~/.ctx/contextos/workspaces/<workspace-id>/last-report.json`. |
@@ -696,6 +712,8 @@ Prompt-time file suggestions do not walk the repository. `ctx install` and `ctx
696
712
 
697
713
  If a prompt has no usable context candidates, the hook fails open without emitting an empty `hook context` block, records `emptyContextReason` in the workspace runtime file, and starts a detached `autowarm` rebuild with a cooldown. That background rebuild refreshes prepared indexes for the next prompt while keeping repository walking out of the current prompt path.
698
714
 
715
+ If hooks fall back because `ctx-mcp` is unavailable or not hot yet, ContextOS still uses indexed text matches for files and lightweight evidence scoring for skills. It does not cold-load embeddings inside the prompt hook. Run `ctx debug -- "task"` to inspect retrieval mode, including bridge status, embedding status, file fallback, and skill fallback.
716
+
699
717
  Use `ctx --config` to choose which prompt sections ContextOS injects and how many suggestions each section may show. Interactive `ctx setup` includes the same section picker and limit prompts, while `ctx setup --yes` keeps the current saved config for automation. The panel supports multiple selection with `Space` and persists the global choice in `~/.ctx/contextos/output-config.json`. Defaults are five suggested files, five skills, and five workflows; caps are 20 files, 10 skills, and 5 workflows. Disabling rules hides both critical and additional relevant rule sections; compliance metadata remains available for reports.
700
718
 
701
719
  Injected prompt sections are intentionally compact: rules show only detected rule text, files show a comma-separated inline list of basenames without paths, skills show unique `$skill-name` activations as a comma-separated inline list without descriptions, and workflows show names with their agent chain. Stop hooks persist reports silently; run `ctx report` or `ctx evidence` when you want the detailed compliance output.
@@ -777,8 +795,10 @@ CONTEXTOS_GRAPH_RETRIEVAL=0 disable graph-backed file retrieval
777
795
  CONTEXTOS_GRAPH_TIMEOUT_MS=80 graph lookup timeout
778
796
  CONTEXTOS_CRG_PYTHON=/path/python Python with code_review_graph installed
779
797
  CONTEXTOS_EMBEDDINGS=0 disable embedding rule scoring
780
- CONTEXTOS_MCP_CONNECT_TIMEOUT_MS=100 stale ctx-mcp socket connect timeout
781
- CONTEXTOS_MCP_BRIDGE_TIMEOUT_MS=2000 ctx-mcp hook bridge timeout
798
+ CONTEXTOS_MCP_CONNECT_TIMEOUT_MS=500 stale ctx-mcp socket connect timeout
799
+ CONTEXTOS_MCP_BRIDGE_TIMEOUT_MS=5000 ctx-mcp hook bridge timeout
800
+ CONTEXTOS_MCP_AUTOSTART=1 auto-start ctx-mcp daemon when the private bridge socket is missing
801
+ CONTEXTOS_MCP_AUTOSTART_WAIT_MS=1500 max hook wait for auto-started ctx-mcp before fallback
782
802
  CONTEXTOS_HOOK_DEADLINE_MS=8500 hard fail-open deadline for prompt hooks
783
803
  CONTEXTOS_DIRECT_FALLBACK_TIMEOUT_MS=2500 direct scoring timeout when the bridge is unavailable
784
804
  CONTEXTOS_HOOK_EMBEDDING_TIMEOUT_MS=500 rule embedding timeout during hook direct fallback
@@ -795,6 +815,7 @@ CONTEXTOS_FILE_EMBEDDING_TIMEOUT_MS=1000 file-path embedding retrieval timeout
795
815
  ```text
796
816
  Codex prompt
797
817
  -> UserPromptSubmit hook
818
+ -> auto-start ctx-mcp daemon if the private bridge socket is missing
798
819
  -> call ctx-mcp through private bridge
799
820
  -> ctx-mcp scores rules and relevant files
800
821
  -> write last-prompt-context.json
package/bin/ctx.js CHANGED
@@ -41,10 +41,11 @@ import { configureOutputSections, enabledOutputSectionsLabel, loadOutputConfig,
41
41
  import { syncWorkflows, warmWorkflowEmbeddings } from "../plugins/ctx/lib/workflow-discoverer.js";
42
42
  import { checkForUpdate } from "../plugins/ctx/lib/update-notifier.js";
43
43
  import { fetchSkillsForAgents, printSkillRecommendations, getAllLibraries, getInstallCommands } from "../plugins/ctx/lib/skill-library.js";
44
- import { invalidateCtxMcpSocket } from "../plugins/ctx/lib/ctx-mcp-client.js";
44
+ import { callCtxHealth, ctxMcpSocketPath, invalidateCtxMcpSocket } from "../plugins/ctx/lib/ctx-mcp-client.js";
45
45
  import { runPrefixedCommand } from "../plugins/ctx/lib/shell-runner.js";
46
46
  import { formatContextOSReady, inspectContextOSReady } from "../plugins/ctx/lib/certification.js";
47
47
  import { formatProjectContextGeneration, generateProjectContext } from "../plugins/ctx/lib/project-context-generator.js";
48
+ import { retrievalMode } from "../plugins/ctx/lib/prompt-hook.js";
48
49
 
49
50
  /**
50
51
  * Run a shell command with all output lines prefixed by │
@@ -195,6 +196,7 @@ Usage:
195
196
  ctx setup --no-skills Skip skill sync
196
197
  ctx setup --quiet Quiet mode (minimal output)
197
198
  ctx debug -- "task" Debug a task with ContextOS tracing
199
+ ctx health Show ctx-mcp bridge/model/index health
198
200
  ctx doctor Score repository ContextOS readiness
199
201
  ctx doctor --fix Generate starter project skills/workflow
200
202
  ctx doctor --fix --force Regenerate starter project context files
@@ -640,6 +642,7 @@ async function debug(task) {
640
642
  console.log(`workspace marker: ${workspaceMarkerPath(cwd)}`);
641
643
  console.log(`rules: ${rules.length}`);
642
644
  console.log(`mcp scorer: ${scored.telemetry.modelStatus}${scored.telemetry.model ? ` (${scored.telemetry.model})` : ""}`);
645
+ printRetrievalMode(retrievalMode(scored.telemetry || {}));
643
646
  console.log(`elapsed: ${scored.telemetry.elapsedMs}ms`);
644
647
  console.log("");
645
648
  for (const rule of rules.slice(0, 20)) {
@@ -677,6 +680,45 @@ async function debug(task) {
677
680
  console.log(scheduled.additionalContext || "(empty)");
678
681
  }
679
682
 
683
+ async function health() {
684
+ const dataDir = contextOSDataDir();
685
+ const socketPath = ctxMcpSocketPath(dataDir);
686
+ const socketPresent = fs.existsSync(socketPath);
687
+ let bridgeConnected = false;
688
+ let bridgeHealth = {};
689
+ let bridgeError = null;
690
+ try {
691
+ bridgeHealth = await callCtxHealth({
692
+ dataDir,
693
+ timeoutMs: Number(process.env.CONTEXTOS_MCP_HEALTH_TIMEOUT_MS || 500),
694
+ connectTimeoutMs: Number(process.env.CONTEXTOS_MCP_CONNECT_TIMEOUT_MS || 500)
695
+ });
696
+ bridgeConnected = true;
697
+ } catch (error) {
698
+ bridgeError = error?.message || String(error);
699
+ }
700
+
701
+ const indexesReady = fs.existsSync(path.join(dataDir, "embeddings.db"));
702
+ const modelHot = Boolean(bridgeHealth.embedding_pipeline_loaded);
703
+ console.log("ContextOS health");
704
+ console.log(`ctx-mcp: ${socketPresent ? "running" : "not running"}`);
705
+ console.log(`bridge: ${bridgeConnected ? "connected" : "disconnected"}`);
706
+ console.log(`embedding_pipeline_loaded: ${Boolean(bridgeHealth.embedding_pipeline_loaded)}`);
707
+ console.log(`model_hot: ${modelHot}`);
708
+ console.log(`indexes_ready: ${indexesReady}`);
709
+ if (bridgeHealth.preload_status) console.log(`preload_status: ${bridgeHealth.preload_status}`);
710
+ if (bridgeHealth.loaded_at) console.log(`loaded_at: ${new Date(bridgeHealth.loaded_at).toISOString()}`);
711
+ if (bridgeHealth.error || bridgeError) console.log(`error: ${bridgeHealth.error || bridgeError}`);
712
+ }
713
+
714
+ function printRetrievalMode(mode = {}) {
715
+ console.log("retrieval mode:");
716
+ console.log(`- bridge: ${mode.bridge || "mcp"}${mode.bridgeError ? ` (${mode.bridgeError})` : ""}`);
717
+ console.log(`- embedding: ${mode.embedding || "enabled"}`);
718
+ console.log(`- file fallback: ${mode.fileFallback || "none"}`);
719
+ console.log(`- skill fallback: ${mode.skillFallback || "none"}`);
720
+ }
721
+
680
722
  async function skillsDoctor(task) {
681
723
  if (!String(task || "").trim()) throw new Error('Usage: ctx skills doctor -- "task"');
682
724
  const result = await diagnoseSkills({
@@ -1048,6 +1090,8 @@ try {
1048
1090
  const task = marker >= 0 ? args.slice(marker + 1).join(" ") : args.slice(1).join(" ");
1049
1091
  if (!task.trim()) throw new Error('Usage: ctx debug -- "task"');
1050
1092
  await debug(task);
1093
+ } else if (command === "health") {
1094
+ await health();
1051
1095
  } else if (command === "doctor") {
1052
1096
  if (args.includes("--fix")) {
1053
1097
  const generated = generateProjectContext({ cwd: process.cwd(), force: args.includes("--force") });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhpnq1807/contextos",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctx",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
4
4
  "description": "Inject task-relevant AGENTS.md rules into Codex through plugin hooks.",
5
5
  "author": {
6
6
  "name": "ContextOS"
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { findGraphRelevantFiles, mergeRelevantFiles } from "./graph-retriever.js";
4
4
  import { expandImportGraph } from "./import-graph.js";
5
- import { findEmbeddingRelevantFiles } from "./file-embedding-retriever.js";
5
+ import { findEmbeddingRelevantFiles, findIndexedFileTextMatches } from "./file-embedding-retriever.js";
6
6
  import { workspacePackagePaths } from "./project-profiler.js";
7
7
 
8
8
  const STOP_WORDS = new Set([
@@ -280,7 +280,8 @@ export async function findRelevantFiles({
280
280
  limit = 3,
281
281
  embeddingFileFinder = findEmbeddingRelevantFiles,
282
282
  fileEmbeddingTimeoutMs,
283
- fileEmbeddingOptions = {}
283
+ fileEmbeddingOptions = {},
284
+ indexedFileTextFinder = findIndexedFileTextMatches
284
285
  } = {}) {
285
286
  if (!String(task || "").trim()) return [];
286
287
 
@@ -295,13 +296,21 @@ export async function findRelevantFiles({
295
296
  embeddingOptions: fileEmbeddingOptions,
296
297
  limit: Math.max(limit * 2, 6)
297
298
  });
299
+ const indexedTextFiles = fileEmbeddingOptions?.enabled === false
300
+ ? await indexedFileTextFinder({
301
+ cwd,
302
+ task: retrievalTask,
303
+ dataDir,
304
+ limit: Math.max(limit * 2, 6)
305
+ })
306
+ : [];
298
307
  const importGraphFiles = expandImportGraph({
299
308
  cwd,
300
- seedFiles: [...explicitFiles, ...manifestFiles, ...embeddingFiles].slice(0, limit),
309
+ seedFiles: [...explicitFiles, ...manifestFiles, ...embeddingFiles, ...indexedTextFiles].slice(0, limit),
301
310
  dataDir,
302
311
  limit: Math.max(limit * 2, 6)
303
312
  });
304
- const seedFiles = mergeLocalFileCandidates([...explicitFiles, ...manifestFiles, ...embeddingFiles, ...importGraphFiles])
313
+ const seedFiles = mergeLocalFileCandidates([...explicitFiles, ...manifestFiles, ...embeddingFiles, ...indexedTextFiles, ...importGraphFiles])
305
314
  .slice(0, Math.max(limit * 3, 9));
306
315
 
307
316
  const graphFiles = findGraphRelevantFiles({
@@ -1,11 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import net from "node:net";
3
3
  import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
4
6
 
5
7
  import { defaultDataRoot } from "./workspace-data.js";
6
8
 
7
- const DEFAULT_TIMEOUT_MS = 2000;
8
- const DEFAULT_CONNECT_TIMEOUT_MS = 100;
9
+ const DEFAULT_TIMEOUT_MS = 5000;
10
+ const DEFAULT_CONNECT_TIMEOUT_MS = 500;
9
11
  export const CTX_MCP_BRIDGE_REVISION = 2;
10
12
 
11
13
  export function ctxMcpSocketPath(dataDir = defaultDataDir()) {
@@ -42,6 +44,50 @@ export async function callCtxHealth({
42
44
  return response.health || {};
43
45
  }
44
46
 
47
+ export async function ensureCtxMcpDaemon({
48
+ dataDir = defaultDataDir(),
49
+ waitMs = Number(process.env.CONTEXTOS_MCP_AUTOSTART_WAIT_MS || 1500),
50
+ enabled = process.env.CONTEXTOS_MCP_AUTOSTART !== "0",
51
+ socketPath = ctxMcpSocketPath(dataDir),
52
+ spawnProcess = spawn,
53
+ healthClient = callCtxHealth
54
+ } = {}) {
55
+ if (!enabled) return { started: false, status: "disabled" };
56
+ if (fs.existsSync(socketPath)) return { started: false, status: "socket-present" };
57
+
58
+ const serverPath = fileURLToPath(new URL("../mcp/server.js", import.meta.url));
59
+ const child = spawnProcess(process.execPath, [serverPath], {
60
+ detached: true,
61
+ stdio: "ignore",
62
+ env: {
63
+ ...process.env,
64
+ CONTEXTOS_MCP_DAEMON: "1"
65
+ }
66
+ });
67
+ child.unref?.();
68
+
69
+ const deadline = Date.now() + Math.max(0, waitMs);
70
+ let lastError = null;
71
+ while (Date.now() <= deadline) {
72
+ try {
73
+ const health = await healthClient({
74
+ dataDir,
75
+ timeoutMs: Math.min(250, Math.max(50, waitMs)),
76
+ connectTimeoutMs: Math.min(DEFAULT_CONNECT_TIMEOUT_MS, Math.max(50, waitMs))
77
+ });
78
+ return { started: true, status: "ready", health };
79
+ } catch (error) {
80
+ lastError = error;
81
+ await sleep(100);
82
+ }
83
+ }
84
+ return {
85
+ started: true,
86
+ status: "timeout",
87
+ error: lastError?.message || String(lastError || "ctx-mcp daemon did not become ready")
88
+ };
89
+ }
90
+
45
91
  async function callBridge(payload, {
46
92
  dataDir,
47
93
  timeoutMs,
@@ -121,3 +167,7 @@ function statIdentity(filePath) {
121
167
  function defaultDataDir() {
122
168
  return defaultDataRoot();
123
169
  }
170
+
171
+ function sleep(ms) {
172
+ return new Promise((resolve) => setTimeout(resolve, ms));
173
+ }
@@ -108,6 +108,29 @@ export async function searchIndexedEmbeddings({
108
108
  }
109
109
  }
110
110
 
111
+ export async function listIndexedEmbeddingItems({
112
+ kind,
113
+ dataDir = defaultDataRoot()
114
+ } = {}) {
115
+ if (!kind) return { items: [], status: "disabled" };
116
+ const cachePath = path.join(dataDir, "embeddings.db");
117
+ if (!fs.existsSync(cachePath)) return { items: [], status: "cold-cache", cachePath };
118
+ let cache;
119
+ try {
120
+ cache = await openEmbeddingCache(dataDir);
121
+ const items = cache.listIndexed(kind).map(({ id, text }) => ({ id, text }));
122
+ cache.close();
123
+ return { items, status: "enabled", cachePath };
124
+ } catch (error) {
125
+ try {
126
+ cache?.close();
127
+ } catch {
128
+ // best-effort close while reporting fallback status
129
+ }
130
+ return { items: [], status: "fallback", error: error?.message || String(error), cachePath };
131
+ }
132
+ }
133
+
111
134
  export async function warmIndexedEmbeddings({
112
135
  kind,
113
136
  items = [],
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { isModelCacheReady, searchIndexedEmbeddings, warmIndexedEmbeddings } from "./embedding-scorer.js";
3
+ import { isModelCacheReady, listIndexedEmbeddingItems, searchIndexedEmbeddings, warmIndexedEmbeddings } from "./embedding-scorer.js";
4
4
  import { rebuildImportGraphIndex } from "./import-graph.js";
5
5
 
6
6
  const SOURCE_EXTENSIONS = new Set([
@@ -49,6 +49,37 @@ export async function findEmbeddingRelevantFiles({
49
49
  }));
50
50
  }
51
51
 
52
+ export async function findIndexedFileTextMatches({
53
+ cwd = process.cwd(),
54
+ task = "",
55
+ dataDir,
56
+ limit = 10,
57
+ indexedLister = listIndexedEmbeddingItems
58
+ } = {}) {
59
+ if (!dataDir || !String(task || "").trim()) return [];
60
+ const result = await indexedLister({ kind: fileIndexKind(cwd), dataDir });
61
+ if (result.status !== "enabled" || !result.items.length) return [];
62
+ const queryTokens = meaningfulTokens(task);
63
+ if (!queryTokens.length) return [];
64
+ return result.items
65
+ .map((item) => {
66
+ const haystack = `${item.id || ""} ${item.text || ""}`.toLowerCase();
67
+ const matches = queryTokens.filter((token) => haystack.includes(token));
68
+ const basename = path.basename(String(item.id || "")).toLowerCase();
69
+ const basenameMatches = queryTokens.filter((token) => basename.includes(token));
70
+ const score = matches.length * 4 + basenameMatches.length * 3;
71
+ return {
72
+ path: item.id,
73
+ score,
74
+ source: "indexed-file-text",
75
+ reasons: matches.length ? [`indexed-file-text:${matches.slice(0, 5).join(",")}`] : []
76
+ };
77
+ })
78
+ .filter((item) => item.path && item.score > 0)
79
+ .sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
80
+ .slice(0, limit);
81
+ }
82
+
52
83
  export async function warmFileEmbeddings({
53
84
  cwd = process.cwd(),
54
85
  dataDir,
@@ -74,6 +105,17 @@ function fileIndexKind(cwd) {
74
105
  return `file:${path.resolve(cwd)}`;
75
106
  }
76
107
 
108
+ function meaningfulTokens(value) {
109
+ const stop = new Set(["the", "and", "for", "with", "this", "that", "task", "implement", "create", "update", "fix", "can", "not", "see", "any", "why", "allways", "always", "app", "src", "page"]);
110
+ return [...new Set(String(value || "")
111
+ .toLowerCase()
112
+ .replace(/[^a-z0-9/._-]+/g, " ")
113
+ .split(/\s+/)
114
+ .flatMap((token) => token.split(/[\\/._-]+/))
115
+ .map((token) => token.trim())
116
+ .filter((token) => token.length >= 3 && !stop.has(token)))];
117
+ }
118
+
77
119
  function listSourceFiles(cwd, { maxFiles }) {
78
120
  const files = [];
79
121
  walkFiles(cwd, (filePath) => {
@@ -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 { callCtxHealth, callCtxScoreContext } from "./ctx-mcp-client.js";
4
+ import { callCtxHealth, callCtxScoreContext, ensureCtxMcpDaemon } 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";
@@ -18,6 +18,7 @@ export async function handlePromptPayload(
18
18
  injectContext = process.env.CONTEXTOS_INJECT !== "0",
19
19
  scoreContextClient = callCtxScoreContext,
20
20
  healthContextClient = callCtxHealth,
21
+ ensureMcpDaemonClient = ensureCtxMcpDaemon,
21
22
  scoreContextDirectClient = scoreContextDirect,
22
23
  autoWarmWorkspace = maybeAutoWarmWorkspace,
23
24
  mcpDataDir,
@@ -35,8 +36,13 @@ export async function handlePromptPayload(
35
36
  const promptLimits = outputConfigLimits(effectiveOutputConfig);
36
37
 
37
38
  let scored;
39
+ let mcpDaemon = null;
38
40
  try {
39
41
  if (requireHotMcp) {
42
+ mcpDaemon = await ensureMcpDaemonClient({
43
+ dataDir: mcpDataDir || dataDir,
44
+ waitMs: Number(process.env.CONTEXTOS_MCP_AUTOSTART_WAIT_MS || 1500)
45
+ });
40
46
  const health = await healthContextClient({
41
47
  dataDir: mcpDataDir || dataDir,
42
48
  timeoutMs: Number(process.env.CONTEXTOS_MCP_HEALTH_TIMEOUT_MS || 250)
@@ -54,7 +60,7 @@ export async function handlePromptPayload(
54
60
  maxWorkflows: promptLimits.workflows
55
61
  }, {
56
62
  dataDir: mcpDataDir || dataDir,
57
- timeoutMs: Number(process.env.CONTEXTOS_MCP_BRIDGE_TIMEOUT_MS || 2000)
63
+ timeoutMs: Number(process.env.CONTEXTOS_MCP_BRIDGE_TIMEOUT_MS || 5000)
58
64
  });
59
65
  } catch (error) {
60
66
  try {
@@ -74,13 +80,15 @@ export async function handlePromptPayload(
74
80
  scored.telemetry = {
75
81
  ...(scored.telemetry || {}),
76
82
  bridgeStatus: "fallback",
77
- bridgeError: error?.message || String(error)
83
+ bridgeError: error?.message || String(error),
84
+ mcpDaemon
78
85
  };
79
86
  } catch (directError) {
80
87
  scored = emptyScore({
81
88
  bridgeStatus: "fallback-failed",
82
89
  bridgeError: error?.message || String(error),
83
- directFallbackError: directError?.message || String(directError)
90
+ directFallbackError: directError?.message || String(directError),
91
+ mcpDaemon
84
92
  });
85
93
  }
86
94
  }
@@ -114,6 +122,7 @@ export async function handlePromptPayload(
114
122
  suggestedWorkflows,
115
123
  telemetry: {
116
124
  ...(scored.telemetry || {}),
125
+ retrievalMode: retrievalMode(scored.telemetry || {}),
117
126
  rulesInjected: (scheduled.highRules?.length || 0) + (scheduled.midRules?.length || 0),
118
127
  filesSuggested: relevantFiles.length,
119
128
  skillsSuggested: suggestedSkills.length,
@@ -235,6 +244,19 @@ function emptyScore(telemetry = {}) {
235
244
  };
236
245
  }
237
246
 
247
+ export function retrievalMode(telemetry = {}) {
248
+ const bridge = telemetry.bridgeStatus || "mcp";
249
+ const embedding = telemetry.modelStatus === "disabled" ? "disabled" : "enabled";
250
+ const fallback = bridge === "fallback" || bridge === "fallback-failed" || embedding === "disabled";
251
+ return {
252
+ bridge,
253
+ bridgeError: telemetry.bridgeError || null,
254
+ embedding,
255
+ fileFallback: fallback ? "indexed-text-match" : null,
256
+ skillFallback: fallback ? "lightweight-evidence-score" : null
257
+ };
258
+ }
259
+
238
260
  function withTimeout(promise, timeoutMs, label) {
239
261
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
240
262
  let timer;
@@ -217,7 +217,12 @@ export async function suggestSkills({
217
217
  const byId = new Map(catalog.map((skill) => [skillIndexId(skill), skill]));
218
218
  const explicitSkills = explicitSkillSuggestions({ prompt, byId });
219
219
  const projectEvidence = detectProjectEvidence({ cwd });
220
- if (!embeddingsEnabled) return finalizeSkillScores(explicitSkills, limit, { cwd, prompt, projectEvidence });
220
+ if (!embeddingsEnabled) {
221
+ return finalizeSkillScores([
222
+ ...explicitSkills,
223
+ ...lightweightSkillSuggestions({ catalog, prompt, projectEvidence })
224
+ ], limit, { cwd, prompt, projectEvidence });
225
+ }
221
226
 
222
227
  if (dataDir) {
223
228
  const indexed = await searchSkillIndexes({ cwd, query, dataDir, timeoutMs, indexedSearcher });
@@ -264,6 +269,63 @@ function explicitSkillSuggestions({ prompt = "", byId = new Map() } = {}) {
264
269
  .map(({ skill, index }) => skillScoreFromEmbedding(skill, 1 - index * 0.0001, ["explicit-skill"]));
265
270
  }
266
271
 
272
+ function lightweightSkillSuggestions({ catalog = [], prompt = "", projectEvidence = {} } = {}) {
273
+ const promptTokens = new Set(meaningfulSkillTokens(prompt));
274
+ if (!promptTokens.size) return [];
275
+
276
+ return catalog
277
+ .map((skill) => {
278
+ const enriched = skill.searchTokens ? skill : enrichSkill(skill);
279
+ const metadata = enriched.metadata || inferSkillMetadata(enriched);
280
+ const promptMatch = matchTextTriggers(prompt, metadata.positivePrompts);
281
+ const dependencyEvidence = matchList(projectEvidence.dependencies, metadata.dependencies);
282
+ const fileEvidence = matchFiles(projectEvidence.files, metadata.files);
283
+ const negativeDependencies = matchList(projectEvidence.dependencies, metadata.negativeDependencies);
284
+ const negativeFiles = matchFiles(projectEvidence.files, metadata.negativeFiles);
285
+ const negativePrompts = matchTextTriggers(prompt, metadata.negativePrompts);
286
+ const negativePenalty = Math.max(negativeDependencies.score, negativeFiles.score, negativePrompts.score);
287
+ const nameMatches = meaningfulSkillTokens(enriched.name).filter((token) => promptTokens.has(token));
288
+ const tokenMatches = meaningfulSkillTokens(`${enriched.name} ${enriched.description}`).filter((token) => promptTokens.has(token));
289
+ const hasRouterEvidence = Boolean(
290
+ promptMatch.matches.length
291
+ || dependencyEvidence.matches.length
292
+ || fileEvidence.matches.length
293
+ );
294
+ const genericPromptOnly = promptMatch.matches.length > 0
295
+ && promptMatch.matches.every((item) => genericPromptTrigger(normalize(item)))
296
+ && !nameMatches.length
297
+ && !tokenMatches.length
298
+ && !dependencyEvidence.matches.length
299
+ && !fileEvidence.matches.length;
300
+ if (!hasRouterEvidence && !nameMatches.length && tokenMatches.length < 2) return null;
301
+ if (genericPromptOnly) return null;
302
+
303
+ const ecosystemPenalty = irrelevantEcosystemPenalty(enriched, { promptTokens, projectEvidence });
304
+ const lexicalScore = Math.min(1,
305
+ nameMatches.length * 0.20
306
+ + Math.min(tokenMatches.length, 5) * 0.08
307
+ + promptMatch.score * 0.45
308
+ + dependencyEvidence.score * 0.20
309
+ + fileEvidence.score * 0.12
310
+ + skillSourceBoostScore(enriched) * 0.03
311
+ - negativePenalty * 0.25
312
+ - ecosystemPenalty
313
+ );
314
+ if (lexicalScore < 0.35) return null;
315
+
316
+ const reasons = [
317
+ `lightweight:${lexicalScore.toFixed(2)}`,
318
+ ...nameMatches.slice(0, 3).map((item) => `name:${item}`),
319
+ ...tokenMatches.slice(0, 5).map((item) => `token:${item}`)
320
+ ];
321
+ return skillScoreFromEmbedding(enriched, lexicalScore, reasons);
322
+ })
323
+ .filter(Boolean)
324
+ .sort((a, b) => Number(b.embeddingScore || 0) - Number(a.embeddingScore || 0)
325
+ || scopePriority(b.scope) - scopePriority(a.scope)
326
+ || a.name.localeCompare(b.name));
327
+ }
328
+
267
329
  function extractExplicitSkillNames(prompt = "") {
268
330
  const names = [];
269
331
  const seen = new Set();
@@ -557,7 +619,7 @@ function inferSkillMetadata(skill = {}) {
557
619
  metadata.dependencies.push("react");
558
620
  metadata.positivePrompts.push("frontend", "ui", "component", "page", "layout", "button", "modal");
559
621
  }
560
- if (/\b(forum|topic|community|chat|message|realtime|websocket|socket|conversation)\b/.test(text)) {
622
+ if (/\b(forum|chat|realtime|websocket|socket)\b/.test(text)) {
561
623
  metadata.positivePrompts.push("forum", "topic", "new topic", "trending", "chat", "chatting", "message", "realtime", "websocket");
562
624
  metadata.files.push("package.json", "webapp/package.json", "services/*/package.json");
563
625
  metadata.dependencies.push("next", "react", "socket.io", "ws", "@nestjs/websockets");
@@ -710,6 +772,43 @@ function isAmbiguousPrompt(prompt = "") {
710
772
  return tokens.length <= 4 && tokens.every((token) => generic.has(token));
711
773
  }
712
774
 
775
+ function meaningfulSkillTokens(value) {
776
+ const stop = new Set([
777
+ "the", "and", "for", "with", "this", "that", "from", "into", "using", "use",
778
+ "task", "create", "update", "implement", "build", "fix", "debug", "page",
779
+ "app", "src", "file", "files", "skill", "skills", "suggested", "suggest", "new"
780
+ ]);
781
+ return normalize(value)
782
+ .split(/\s+/)
783
+ .map((token) => token.trim())
784
+ .filter((token) => token.length > 2 && !stop.has(token));
785
+ }
786
+
787
+ function genericPromptTrigger(trigger) {
788
+ return new Set(["frontend", "ui", "component", "page", "layout", "button", "modal"]).has(trigger);
789
+ }
790
+
791
+ function irrelevantEcosystemPenalty(skill = {}, { promptTokens = new Set(), projectEvidence = {} } = {}) {
792
+ const text = normalize(`${skill.name || ""} ${skill.description || ""}`);
793
+ const dependencies = new Set((projectEvidence.dependencies || []).map(normalizeDependency));
794
+ const ecosystems = [
795
+ { token: "azure", deps: ["@azure", "azure"], prompts: ["azure"] },
796
+ { token: "aws", deps: ["aws-sdk", "@aws-sdk"], prompts: ["aws"] },
797
+ { token: "java", deps: ["java"], prompts: ["java"] },
798
+ { token: "dotnet", deps: ["dotnet", "aspnet"], prompts: ["dotnet", "csharp"] },
799
+ { token: "python", deps: ["python"], prompts: ["python"] },
800
+ { token: "angular", deps: ["@angular/core", "angular"], prompts: ["angular"] }
801
+ ];
802
+ let penalty = 0;
803
+ for (const ecosystem of ecosystems) {
804
+ if (!text.includes(ecosystem.token)) continue;
805
+ const promptHas = ecosystem.prompts.some((token) => promptTokens.has(token));
806
+ const projectHas = ecosystem.deps.some((dependency) => [...dependencies].some((value) => value.includes(dependency)));
807
+ if (!promptHas && !projectHas) penalty += 0.55;
808
+ }
809
+ return Math.min(0.7, penalty);
810
+ }
811
+
713
812
  function detectProjectEvidence({ cwd = process.cwd() } = {}) {
714
813
  const dependencies = new Set();
715
814
  const scripts = new Set();
@@ -1,10 +1,14 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
+ import { spawn } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
3
5
 
4
6
  import { scoreContext } from "../lib/score-context.js";
5
7
  import { scheduleContext } from "../lib/scheduler.js";
6
8
 
7
- export function createContextOSMcpServer({ dataDir, getHealth = defaultHealth }) {
9
+ const CTX_BIN = fileURLToPath(new URL("../../../bin/ctx.js", import.meta.url));
10
+
11
+ export function createContextOSMcpServer({ dataDir, getHealth = defaultHealth, runCommand = runCliCommand } = {}) {
8
12
  const server = new McpServer({
9
13
  name: "ctx-mcp",
10
14
  version: "0.1.0"
@@ -107,9 +111,129 @@ export function createContextOSMcpServer({ dataDir, getHealth = defaultHealth })
107
111
  };
108
112
  });
109
113
 
114
+ registerCliTool(server, {
115
+ name: "ctx_debug_context",
116
+ title: "Debug ContextOS routing",
117
+ description: "Preview rules, files, skills, workflows, and final prompt context for a task.",
118
+ inputSchema: {
119
+ cwd: z.string().optional(),
120
+ prompt: z.string()
121
+ },
122
+ args: ({ prompt }) => ["debug", "--", prompt],
123
+ runCommand
124
+ });
125
+
126
+ registerCliTool(server, {
127
+ name: "ctx_doctor_repo",
128
+ title: "Inspect ContextOS repository readiness",
129
+ description: "Score repository ContextOS readiness without modifying files.",
130
+ inputSchema: {
131
+ cwd: z.string().optional()
132
+ },
133
+ args: () => ["doctor"],
134
+ runCommand
135
+ });
136
+
137
+ registerCliTool(server, {
138
+ name: "ctx_skills_doctor",
139
+ title: "Explain ContextOS skill routing",
140
+ description: "Explain which skills ContextOS would select for a prompt and why.",
141
+ inputSchema: {
142
+ cwd: z.string().optional(),
143
+ prompt: z.string()
144
+ },
145
+ args: ({ prompt }) => ["skills", "doctor", "--", prompt],
146
+ runCommand
147
+ });
148
+
149
+ registerCliTool(server, {
150
+ name: "ctx_report_last_task",
151
+ title: "Show last ContextOS task report",
152
+ description: "Read the latest local ContextOS compliance report for the workspace.",
153
+ inputSchema: {
154
+ cwd: z.string().optional()
155
+ },
156
+ args: () => ["report"],
157
+ runCommand
158
+ });
159
+
160
+ registerCliTool(server, {
161
+ name: "ctx_evidence_last_task",
162
+ title: "Show last ContextOS evidence",
163
+ description: "Read detailed evidence for the latest local ContextOS compliance report.",
164
+ inputSchema: {
165
+ cwd: z.string().optional()
166
+ },
167
+ args: () => ["evidence"],
168
+ runCommand
169
+ });
170
+
171
+ registerCliTool(server, {
172
+ name: "ctx_stats_workspace",
173
+ title: "Show ContextOS workspace stats",
174
+ description: "Summarize local ContextOS prompt, report, hook, and telemetry history.",
175
+ inputSchema: {
176
+ cwd: z.string().optional()
177
+ },
178
+ args: () => ["stats"],
179
+ runCommand
180
+ });
181
+
110
182
  return server;
111
183
  }
112
184
 
185
+ function registerCliTool(server, { name, title, description, inputSchema, args, runCommand }) {
186
+ server.registerTool(name, {
187
+ title,
188
+ description,
189
+ inputSchema,
190
+ outputSchema: {
191
+ code: z.number(),
192
+ stdout: z.string(),
193
+ stderr: z.string()
194
+ }
195
+ }, async (toolArgs) => {
196
+ const result = await runCommand(args(toolArgs), {
197
+ cwd: toolArgs.cwd || process.cwd()
198
+ });
199
+ const text = result.stdout || result.stderr || `(ctx command exited with code ${result.code})`;
200
+ return {
201
+ content: [{ type: "text", text }],
202
+ structuredContent: result
203
+ };
204
+ });
205
+ }
206
+
207
+ function runCliCommand(args, { cwd = process.cwd(), timeoutMs = Number(process.env.CONTEXTOS_MCP_CLI_TOOL_TIMEOUT_MS || 10000) } = {}) {
208
+ return new Promise((resolve) => {
209
+ const child = spawn(process.execPath, [CTX_BIN, ...args], {
210
+ cwd,
211
+ env: process.env,
212
+ stdio: ["ignore", "pipe", "pipe"]
213
+ });
214
+ let stdout = "";
215
+ let stderr = "";
216
+ const timer = setTimeout(() => {
217
+ child.kill("SIGTERM");
218
+ resolve({ code: 124, stdout, stderr: stderr || `ctx command timed out after ${timeoutMs}ms` });
219
+ }, timeoutMs);
220
+ child.stdout.on("data", (chunk) => {
221
+ stdout += chunk.toString("utf8");
222
+ });
223
+ child.stderr.on("data", (chunk) => {
224
+ stderr += chunk.toString("utf8");
225
+ });
226
+ child.on("error", (error) => {
227
+ clearTimeout(timer);
228
+ resolve({ code: 1, stdout, stderr: error?.message || String(error) });
229
+ });
230
+ child.on("close", (code) => {
231
+ clearTimeout(timer);
232
+ resolve({ code: code ?? 0, stdout, stderr });
233
+ });
234
+ });
235
+ }
236
+
113
237
  function defaultHealth() {
114
238
  return {
115
239
  model_cache_ready: false,
@@ -29,7 +29,11 @@ const keepAlive = setInterval(() => {}, 2 ** 31 - 1);
29
29
 
30
30
  const server = createContextOSMcpServer({ dataDir, getHealth: bridgeHealth });
31
31
  console.error("ctx-mcp ready");
32
- await server.connect(new StdioServerTransport());
32
+ if (process.env.CONTEXTOS_MCP_DAEMON === "1") {
33
+ console.error("ctx-mcp daemon mode");
34
+ } else {
35
+ await server.connect(new StdioServerTransport());
36
+ }
33
37
 
34
38
  async function ensureModelReady() {
35
39
  const modelDir = modelCacheDir(dataDir);