@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 +6 -0
- package/README.md +23 -2
- package/bin/ctx.js +45 -1
- package/package.json +1 -1
- package/plugins/ctx/.codex-plugin/plugin.json +1 -1
- package/plugins/ctx/lib/analyzer.js +13 -4
- package/plugins/ctx/lib/ctx-mcp-client.js +52 -2
- package/plugins/ctx/lib/embedding-scorer.js +23 -0
- package/plugins/ctx/lib/file-embedding-retriever.js +43 -1
- package/plugins/ctx/lib/prompt-hook.js +26 -4
- package/plugins/ctx/lib/skill-discoverer.js +101 -2
- package/plugins/ctx/mcp/contextos-server.js +125 -1
- package/plugins/ctx/mcp/server.js +5 -1
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=
|
|
781
|
-
CONTEXTOS_MCP_BRIDGE_TIMEOUT_MS=
|
|
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
|
@@ -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 =
|
|
8
|
-
const DEFAULT_CONNECT_TIMEOUT_MS =
|
|
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 ||
|
|
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)
|
|
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|
|
|
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
|
-
|
|
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
|
-
|
|
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);
|