@minhpnq1807/contextos 0.6.5 → 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 +16 -0
- package/README.md +30 -8
- 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/certification.js +49 -17
- 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/project-context-generator.js +5 -3
- package/plugins/ctx/lib/prompt-hook.js +26 -4
- package/plugins/ctx/lib/skill-discoverer.js +142 -9
- package/plugins/ctx/lib/workflow-discoverer.js +2 -0
- package/plugins/ctx/mcp/contextos-server.js +125 -1
- package/plugins/ctx/mcp/proxy.js +31 -10
- package/plugins/ctx/mcp/server.js +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.6.7
|
|
10
|
+
|
|
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.
|
|
12
|
+
|
|
13
|
+
## 0.6.6
|
|
14
|
+
|
|
15
|
+
- **Global-first skill coverage:** `ctx doctor` now treats global and community skills as valid skill coverage instead of requiring project-local skill packs. Project skills are reported separately as optional project overrides, so repositories with synced global skills no longer show `Skills: 0` or `Not Ready` just because `.codex/skills` is absent.
|
|
16
|
+
- **Evidence-gated skill routing:** Updated Skill Router scoring so global skills are first-class candidates, community skills get a small source boost, and project skills get an override boost without penalizing global catalogs. Prompt+semantic matches without project evidence now remain medium-confidence candidates instead of disappearing.
|
|
17
|
+
- **Shared project context:** `ctx doctor --fix` and `ctx setup --generate-project-context` now scaffold shared project context under `.agents/skills/` and `.agents/workflows/` instead of Codex-only `.codex/` paths. Workflow discovery and certification now read `.agents/workflows` so the same generated context can be synced to Codex, Claude Code, Gemini, and Antigravity.
|
|
18
|
+
|
|
3
19
|
## 0.6.5
|
|
4
20
|
|
|
5
21
|
- **Generated skill frontmatter:** Fixed `ctx doctor --fix` starter `SKILL.md` output so generated project skills include YAML frontmatter with `name` and `description`. This prevents invalid-skill warnings after generated skills are synced through skillshare.
|
package/README.md
CHANGED
|
@@ -246,14 +246,15 @@ ctx doctor
|
|
|
246
246
|
Repository Score
|
|
247
247
|
|
|
248
248
|
Rules: 92
|
|
249
|
-
|
|
249
|
+
Skill Coverage: 88
|
|
250
|
+
Project Skill Overrides: 0
|
|
250
251
|
Workflows: 84
|
|
251
252
|
|
|
252
253
|
Overall:
|
|
253
|
-
ContextOS Ready
|
|
254
|
+
ContextOS Ready Silver
|
|
254
255
|
```
|
|
255
256
|
|
|
256
|
-
The score checks project `AGENTS.md` rules,
|
|
257
|
+
The score checks project `AGENTS.md` rules, global/community skill coverage, optional project skill overrides, and project workflows. Shared project context should live under `.agents/skills/` and `.agents/workflows/`; ContextOS can sync it to Codex, Claude Code, Gemini, and Antigravity agent roots. Use the badge only after `ctx doctor` reports Bronze, Silver, or Gold.
|
|
257
258
|
|
|
258
259
|
## Quick Commands
|
|
259
260
|
|
|
@@ -390,6 +391,21 @@ Hook lightweight fallback: 0.69s
|
|
|
390
391
|
MCP embedding hot startup: 477ms
|
|
391
392
|
```
|
|
392
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
|
+
|
|
393
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.
|
|
394
410
|
|
|
395
411
|
Verify the published package in any project:
|
|
@@ -584,14 +600,15 @@ This warning comes from a transitive dependency in the local embedding/WASM stac
|
|
|
584
600
|
| `ctx install --copy` | Copies only the plugin payload to `$CODEX_HOME/plugins/ctx`. | Legacy local development or manual plugin experiments. | Does not sync the active marketplace, rebuild indexes, register MCP, or install global hooks. Prefer `ctx refresh` for active local updates. |
|
|
585
601
|
| `ctx setup` | Runs the first-run setup wizard. | You want the recommended onboarding flow after `npm install -g @minhpnq1807/contextos`. | Installs selected agents, optionally syncs Ruler rules/MCP and skillshare skills, asks which prompt sections to show, then prints next steps. |
|
|
586
602
|
| `ctx setup --yes` | Runs setup with defaults non-interactively. | You want scriptable Codex setup. | Uses `codex`, enables injection, syncs rules, syncs skills, skips interactive community-skill installation when no TTY is available, and passes `--yes` to dependency setup prompts. Use `--agents codex,claude,agy` for multi-agent setup. |
|
|
587
|
-
| `ctx setup --generate-project-context` | Generates starter project skills and workflow during setup. | Your repo has rules but `ctx doctor` reports missing skills/workflows. | Creates `.
|
|
603
|
+
| `ctx setup --generate-project-context` | Generates starter shared project skills and workflow during setup. | Your repo has rules but `ctx doctor` reports missing skills/workflows. | Creates `.agents/skills/<detected-skill>/SKILL.md`, matching `skill.yaml`, and `.agents/workflows/primary.md` without overwriting existing files. Run `ctx sync --skills` and `ctx sync --workflows` to push them to selected agents. |
|
|
588
604
|
| `ctx setup --agents <list>` | Runs setup for selected agents. | You want only part of the default set. | Accepts comma-separated `codex`, `claude`, `agy`, or `antigravity`. |
|
|
589
605
|
| `ctx setup --no-rules` | Skips Ruler sync during setup. | You only want hooks/MCP install and maybe skill sync. | Does not run `ctx sync --rules`. |
|
|
590
606
|
| `ctx setup --no-skills` | Skips skillshare sync during setup. | You do not want shared skills configured. | Does not run `ctx sync --skills`. |
|
|
591
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. |
|
|
592
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. |
|
|
593
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. |
|
|
594
|
-
| `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
|
|
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. |
|
|
595
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`. |
|
|
596
613
|
| `ctx evidence` | Shows detailed evidence behind the last report for the current workspace. | You want to inspect why a rule was marked `followed`, `ignored`, `unknown`, or `unmeasurable`. | Prints a compact evidence table plus per-rule detail tables. |
|
|
597
614
|
| `ctx stats` | Shows aggregate runtime metrics for the current workspace. | You want to know whether ContextOS is active and useful over time. | Prints sectioned tables for prompt/report counts, injection rate, efficiency, rule outcomes, hook events, last prompt, and last report. |
|
|
@@ -611,7 +628,7 @@ This warning comes from a transitive dependency in the local embedding/WASM stac
|
|
|
611
628
|
| `ctx sync --skills --no-collect` | Skips collecting existing agent skills into skillshare. | You already manage `~/.config/skillshare/skills` and only want to push it out. | Initializes/syncs skillshare without running `skillshare backup` or `skillshare collect --all`. |
|
|
612
629
|
| `ctx sync --skills --no-embeddings` | Skips ContextOS skill embedding rebuild after skillshare sync. | You have a very large skill catalog and want sync to finish quickly. | Runs skillshare sync, then leaves embeddings to a later `ctx embeddings warm -- "task"` run. |
|
|
613
630
|
| `ctx sync --skills --verbose` | Shows native skillshare token budget warnings during sync. | You are diagnosing skillshare path overlap or always-loaded context size. | Omits ContextOS' default `skillshare sync --quiet` behavior. |
|
|
614
|
-
| `ctx sync --workflows` | Syncs and indexes agent workflow markdown files for prompt-time workflow suggestions. | You use `.claude/workflows/`, `.codex/workflows/`, or Antigravity workflow folders and want every agent to see the same deduped workflow set. | Scans project/global workflow folders, dedupes by workflow name, copies unique workflows to selected global agent roots, warms workflow embeddings, and makes `ctx debug`/prompt hooks show relevant workflow hints. |
|
|
631
|
+
| `ctx sync --workflows` | Syncs and indexes agent workflow markdown files for prompt-time workflow suggestions. | You use `.agents/workflows/`, `.claude/workflows/`, `.codex/workflows/`, or Gemini/Antigravity workflow folders and want every agent to see the same deduped workflow set. | Scans project/global workflow folders, dedupes by workflow name, copies unique workflows to selected global agent roots, warms workflow embeddings, and makes `ctx debug`/prompt hooks show relevant workflow hints. |
|
|
615
632
|
| `ctx sync --workflows --agents <list>` | Syncs workflows only for selected agents. | You want a subset such as `codex,claude` or `codex,claude,agy`. | Accepts comma-separated `codex`, `claude`, `agy`, or `antigravity`; `agy` writes the Gemini/Antigravity workflow roots. |
|
|
616
633
|
| `ctx sync --workflows --dry-run` | Previews workflow sync without writing files. | You want to inspect source workflows and target roots first. | Prints planned sync/index output and skips copying target files. |
|
|
617
634
|
| `ctx skills` | Installs community skill libraries. | You want curated skills without running the full setup wizard. | Opens the community installer, uses a portable shell on Windows/Linux/macOS, repairs unsafe skill symlinks, and syncs installed skills to selected agents. |
|
|
@@ -695,6 +712,8 @@ Prompt-time file suggestions do not walk the repository. `ctx install` and `ctx
|
|
|
695
712
|
|
|
696
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.
|
|
697
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
|
+
|
|
698
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.
|
|
699
718
|
|
|
700
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.
|
|
@@ -776,8 +795,10 @@ CONTEXTOS_GRAPH_RETRIEVAL=0 disable graph-backed file retrieval
|
|
|
776
795
|
CONTEXTOS_GRAPH_TIMEOUT_MS=80 graph lookup timeout
|
|
777
796
|
CONTEXTOS_CRG_PYTHON=/path/python Python with code_review_graph installed
|
|
778
797
|
CONTEXTOS_EMBEDDINGS=0 disable embedding rule scoring
|
|
779
|
-
CONTEXTOS_MCP_CONNECT_TIMEOUT_MS=
|
|
780
|
-
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
|
|
781
802
|
CONTEXTOS_HOOK_DEADLINE_MS=8500 hard fail-open deadline for prompt hooks
|
|
782
803
|
CONTEXTOS_DIRECT_FALLBACK_TIMEOUT_MS=2500 direct scoring timeout when the bridge is unavailable
|
|
783
804
|
CONTEXTOS_HOOK_EMBEDDING_TIMEOUT_MS=500 rule embedding timeout during hook direct fallback
|
|
@@ -794,6 +815,7 @@ CONTEXTOS_FILE_EMBEDDING_TIMEOUT_MS=1000 file-path embedding retrieval timeout
|
|
|
794
815
|
```text
|
|
795
816
|
Codex prompt
|
|
796
817
|
-> UserPromptSubmit hook
|
|
818
|
+
-> auto-start ctx-mcp daemon if the private bridge socket is missing
|
|
797
819
|
-> call ctx-mcp through private bridge
|
|
798
820
|
-> ctx-mcp scores rules and relevant files
|
|
799
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({
|
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
|
|
5
5
|
import { filterActionableRules, parseRules } from "./analyzer.js";
|
|
6
6
|
import { readAgentsChain } from "./reader.js";
|
|
7
|
-
import { scanSkills } from "./skill-discoverer.js";
|
|
7
|
+
import { scanSkills, skillSearchRoots } from "./skill-discoverer.js";
|
|
8
8
|
import { scanWorkflows } from "./workflow-discoverer.js";
|
|
9
9
|
|
|
10
10
|
const PROJECT_SKILL_ROOTS = [
|
|
@@ -17,6 +17,7 @@ const PROJECT_SKILL_ROOTS = [
|
|
|
17
17
|
];
|
|
18
18
|
|
|
19
19
|
const PROJECT_WORKFLOW_ROOTS = [
|
|
20
|
+
[".agents", "workflows"],
|
|
20
21
|
[".claude", "workflows"],
|
|
21
22
|
[".codex", "workflows"],
|
|
22
23
|
[".gemini", "workflows"],
|
|
@@ -27,7 +28,7 @@ const PROJECT_WORKFLOW_ROOTS = [
|
|
|
27
28
|
export function inspectContextOSReady({ cwd = process.cwd(), home = os.homedir() } = {}) {
|
|
28
29
|
const root = findProjectRoot(cwd);
|
|
29
30
|
const rules = inspectRules({ cwd, root, home });
|
|
30
|
-
const skills = inspectSkills({ root });
|
|
31
|
+
const skills = inspectSkills({ root, home });
|
|
31
32
|
const workflows = inspectWorkflows({ root });
|
|
32
33
|
const overall = Math.round((rules.score + skills.score + workflows.score) / 3);
|
|
33
34
|
const tier = readinessTier(overall, { rules, skills, workflows });
|
|
@@ -48,7 +49,8 @@ export function formatContextOSReady(result) {
|
|
|
48
49
|
"Repository Score",
|
|
49
50
|
"",
|
|
50
51
|
`Rules: ${result.rules.score}`,
|
|
51
|
-
`
|
|
52
|
+
`Skill Coverage: ${result.skills.score}`,
|
|
53
|
+
`Project Skill Overrides: ${result.skills.projectOverrideScore}`,
|
|
52
54
|
`Workflows: ${result.workflows.score}`,
|
|
53
55
|
"",
|
|
54
56
|
"Overall:",
|
|
@@ -56,7 +58,8 @@ export function formatContextOSReady(result) {
|
|
|
56
58
|
"",
|
|
57
59
|
"Evidence:",
|
|
58
60
|
`- Rules: ${result.rules.summary}`,
|
|
59
|
-
`-
|
|
61
|
+
`- Skill Coverage: ${result.skills.summary}`,
|
|
62
|
+
`- Project Skill Overrides: ${result.skills.projectSummary}`,
|
|
60
63
|
`- Workflows: ${result.workflows.summary}`
|
|
61
64
|
];
|
|
62
65
|
|
|
@@ -108,10 +111,15 @@ function inspectRules({ cwd, root, home }) {
|
|
|
108
111
|
};
|
|
109
112
|
}
|
|
110
113
|
|
|
111
|
-
function inspectSkills({ root }) {
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
const
|
|
114
|
+
function inspectSkills({ root, home }) {
|
|
115
|
+
const projectRoots = PROJECT_SKILL_ROOTS.map((parts) => path.join(root, ...parts));
|
|
116
|
+
const roots = skillSearchRoots({ cwd: root, home });
|
|
117
|
+
const skills = scanSkills({ cwd: root, roots, maxSkills: 5000 });
|
|
118
|
+
const projectSkills = skills.filter((skill) => skill.scope === "project");
|
|
119
|
+
const sharedSkills = skills.filter((skill) => skill.scope !== "project");
|
|
120
|
+
const communitySkills = skills.filter((skill) => isCommunitySkillPath(skill.path));
|
|
121
|
+
const globalSkills = sharedSkills.filter((skill) => !isCommunitySkillPath(skill.path));
|
|
122
|
+
const metadataFiles = findFiles(projectRoots, (filePath) => /skill\.ya?ml$/i.test(path.basename(filePath)));
|
|
115
123
|
const richMetadata = metadataFiles.filter((filePath) => {
|
|
116
124
|
const content = safeRead(filePath);
|
|
117
125
|
return /^positive_triggers:/m.test(content)
|
|
@@ -123,29 +131,52 @@ function inspectSkills({ root }) {
|
|
|
123
131
|
const recommendations = [];
|
|
124
132
|
|
|
125
133
|
if (skills.length) score += 50;
|
|
126
|
-
else recommendations.push("
|
|
134
|
+
else recommendations.push("Sync or install global skills with `ctx setup` or `ctx sync --skills`.");
|
|
127
135
|
|
|
128
|
-
if (
|
|
129
|
-
else recommendations.push("Add skill.yaml metadata beside important SKILL.md files.");
|
|
136
|
+
if (sharedSkills.length || projectSkills.length >= 3) score += 25;
|
|
130
137
|
|
|
131
|
-
if (
|
|
132
|
-
else recommendations.push("Include positive_triggers, negative_triggers, evidence, and workflow in skill.yaml.");
|
|
138
|
+
if (projectSkills.length) score += 10;
|
|
133
139
|
|
|
134
|
-
if (
|
|
135
|
-
|
|
140
|
+
if (metadataFiles.length) score += 5;
|
|
141
|
+
if (richMetadata.length) score += 10;
|
|
142
|
+
if (projectSkills.length && !metadataFiles.length) {
|
|
143
|
+
recommendations.push("Add skill.yaml metadata beside project-specific SKILL.md files.");
|
|
144
|
+
}
|
|
145
|
+
if (projectSkills.length && !richMetadata.length) {
|
|
146
|
+
recommendations.push("Include positive_triggers, negative_triggers, evidence, and workflow in project skill.yaml files.");
|
|
147
|
+
}
|
|
136
148
|
|
|
137
149
|
return {
|
|
138
150
|
score: Math.min(100, score),
|
|
139
151
|
count: skills.length,
|
|
152
|
+
globalCount: globalSkills.length,
|
|
153
|
+
communityCount: communitySkills.length,
|
|
154
|
+
sharedCount: sharedSkills.length,
|
|
155
|
+
projectCount: projectSkills.length,
|
|
156
|
+
projectOverrideScore: projectSkillOverrideScore(projectSkills),
|
|
140
157
|
metadataCount: metadataFiles.length,
|
|
141
158
|
richMetadataCount: richMetadata.length,
|
|
142
159
|
summary: skills.length
|
|
143
|
-
? `${skills.length} skill(s), ${
|
|
144
|
-
: "missing project skill
|
|
160
|
+
? `${skills.length} skill(s): ${globalSkills.length} global, ${communitySkills.length} community/shared, ${projectSkills.length} project override(s)`
|
|
161
|
+
: "missing global/community/project skill catalog",
|
|
162
|
+
projectSummary: projectSkills.length
|
|
163
|
+
? `${projectSkills.length} project override skill(s), ${metadataFiles.length} metadata file(s)`
|
|
164
|
+
: "0 project override skill(s); global/community skills remain valid",
|
|
145
165
|
recommendations
|
|
146
166
|
};
|
|
147
167
|
}
|
|
148
168
|
|
|
169
|
+
function projectSkillOverrideScore(projectSkills = []) {
|
|
170
|
+
if (projectSkills.length >= 3) return 100;
|
|
171
|
+
if (projectSkills.length === 2) return 70;
|
|
172
|
+
if (projectSkills.length === 1) return 40;
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isCommunitySkillPath(filePath = "") {
|
|
177
|
+
return String(filePath || "").includes(`${path.sep}.config${path.sep}skillshare${path.sep}skills${path.sep}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
149
180
|
function inspectWorkflows({ root }) {
|
|
150
181
|
const roots = PROJECT_WORKFLOW_ROOTS.map((parts) => path.join(root, ...parts));
|
|
151
182
|
const workflows = scanWorkflows({ cwd: root, roots });
|
|
@@ -175,6 +206,7 @@ function inspectWorkflows({ root }) {
|
|
|
175
206
|
|
|
176
207
|
function readinessTier(overall, { rules, skills, workflows }) {
|
|
177
208
|
if (rules.score < 50 || skills.score < 50 || workflows.score < 50) return "Not Ready";
|
|
209
|
+
if (!skills.projectCount && overall >= 85) return "Silver";
|
|
178
210
|
if (overall >= 85) return "Gold";
|
|
179
211
|
if (overall >= 70) return "Silver";
|
|
180
212
|
if (overall >= 50) return "Bronze";
|
|
@@ -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) => {
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { clearSkillScanCache } from "./skill-discoverer.js";
|
|
5
5
|
|
|
6
6
|
const STARTER_SKILL_LIMIT = 3;
|
|
7
|
+
const SHARED_PROJECT_CONTEXT_DIR = ".agents";
|
|
7
8
|
|
|
8
9
|
export function generateProjectContext({ cwd = process.cwd(), force = false } = {}) {
|
|
9
10
|
const root = findProjectRoot(cwd);
|
|
@@ -13,7 +14,7 @@ export function generateProjectContext({ cwd = process.cwd(), force = false } =
|
|
|
13
14
|
const skipped = [];
|
|
14
15
|
|
|
15
16
|
for (const skill of skills) {
|
|
16
|
-
const dir = path.join(root,
|
|
17
|
+
const dir = path.join(root, SHARED_PROJECT_CONTEXT_DIR, "skills", skill.id);
|
|
17
18
|
const skillPath = path.join(dir, "SKILL.md");
|
|
18
19
|
const yamlPath = path.join(dir, "skill.yaml");
|
|
19
20
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -21,7 +22,7 @@ export function generateProjectContext({ cwd = process.cwd(), force = false } =
|
|
|
21
22
|
writeFile({ filePath: yamlPath, content: renderSkillYaml(skill), force, created, skipped });
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
const workflowPath = path.join(root,
|
|
25
|
+
const workflowPath = path.join(root, SHARED_PROJECT_CONTEXT_DIR, "workflows", "primary.md");
|
|
25
26
|
fs.mkdirSync(path.dirname(workflowPath), { recursive: true });
|
|
26
27
|
writeFile({
|
|
27
28
|
filePath: workflowPath,
|
|
@@ -61,7 +62,8 @@ export function formatProjectContextGeneration(result) {
|
|
|
61
62
|
for (const filePath of result.skipped) lines.push(`- ${path.relative(result.root, filePath)}`);
|
|
62
63
|
}
|
|
63
64
|
lines.push("", "Next:");
|
|
64
|
-
lines.push("- Review generated skills/workflow and edit project-specific wording.");
|
|
65
|
+
lines.push("- Review generated shared skills/workflow and edit project-specific wording.");
|
|
66
|
+
lines.push("- Run: ctx sync --skills && ctx sync --workflows");
|
|
65
67
|
lines.push("- Run: ctx doctor");
|
|
66
68
|
lines.push("- Run: ctx debug -- \"your task\"");
|
|
67
69
|
return lines.join("\n");
|
|
@@ -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();
|
|
@@ -551,7 +613,16 @@ function inferSkillMetadata(skill = {}) {
|
|
|
551
613
|
}
|
|
552
614
|
if (/\bnext|app router\b/.test(text)) {
|
|
553
615
|
metadata.dependencies.push("next", "react");
|
|
554
|
-
metadata.positivePrompts.push("frontend", "ui", "role", "dashboard", "app router");
|
|
616
|
+
metadata.positivePrompts.push("frontend", "ui", "role", "dashboard", "app router", "page", "webapp");
|
|
617
|
+
}
|
|
618
|
+
if (/\b(frontend|react|ui|component|layout|design)\b/.test(text)) {
|
|
619
|
+
metadata.dependencies.push("react");
|
|
620
|
+
metadata.positivePrompts.push("frontend", "ui", "component", "page", "layout", "button", "modal");
|
|
621
|
+
}
|
|
622
|
+
if (/\b(forum|chat|realtime|websocket|socket)\b/.test(text)) {
|
|
623
|
+
metadata.positivePrompts.push("forum", "topic", "new topic", "trending", "chat", "chatting", "message", "realtime", "websocket");
|
|
624
|
+
metadata.files.push("package.json", "webapp/package.json", "services/*/package.json");
|
|
625
|
+
metadata.dependencies.push("next", "react", "socket.io", "ws", "@nestjs/websockets");
|
|
555
626
|
}
|
|
556
627
|
return metadata;
|
|
557
628
|
}
|
|
@@ -570,16 +641,18 @@ function hybridSkillScore(skill, { prompt, projectEvidence }) {
|
|
|
570
641
|
const projectEvidenceScore = dependencyEvidence.score;
|
|
571
642
|
const fileConfigScore = fileEvidence.score;
|
|
572
643
|
const importGraphScore = 0;
|
|
644
|
+
const sourceBoostScore = skillSourceBoostScore(skill);
|
|
573
645
|
const externalGraphScore = 0;
|
|
574
646
|
const memoryScore = 0;
|
|
575
647
|
const hybridScore = Math.max(0, Math.min(1,
|
|
576
|
-
semanticScore * 0.
|
|
648
|
+
semanticScore * 0.25
|
|
577
649
|
+ promptMatch.score * 0.20
|
|
578
|
-
+ projectEvidenceScore * 0.
|
|
650
|
+
+ projectEvidenceScore * 0.25
|
|
579
651
|
+ fileConfigScore * 0.10
|
|
580
652
|
+ importGraphScore * 0.10
|
|
581
|
-
+
|
|
582
|
-
+
|
|
653
|
+
+ sourceBoostScore * 0.05
|
|
654
|
+
+ externalGraphScore * 0.03
|
|
655
|
+
+ memoryScore * 0.02
|
|
583
656
|
- negativePenalty * 0.20
|
|
584
657
|
));
|
|
585
658
|
const explicit = (skill.reasons || []).includes("explicit-skill");
|
|
@@ -590,13 +663,15 @@ function hybridSkillScore(skill, { prompt, projectEvidence }) {
|
|
|
590
663
|
dependencyEvidence,
|
|
591
664
|
fileEvidence,
|
|
592
665
|
negativePenalty,
|
|
593
|
-
explicit
|
|
666
|
+
explicit,
|
|
667
|
+
semanticScore
|
|
594
668
|
});
|
|
595
669
|
const evidence = [...new Set([
|
|
596
670
|
...(skill.reasons || []),
|
|
597
671
|
...promptMatch.matches.map((item) => `prompt:${item}`),
|
|
598
672
|
...dependencyEvidence.matches.map((item) => `dependency:${item}`),
|
|
599
|
-
...fileEvidence.matches.map((item) => `file:${item}`)
|
|
673
|
+
...fileEvidence.matches.map((item) => `file:${item}`),
|
|
674
|
+
...(sourceBoostScore ? [`source:${skillSourceLabel(skill)}`] : [])
|
|
600
675
|
])];
|
|
601
676
|
const negativeEvidence = [
|
|
602
677
|
...negativeDependencies.matches.map((item) => `dependency:${item}`),
|
|
@@ -618,6 +693,7 @@ function hybridSkillScore(skill, { prompt, projectEvidence }) {
|
|
|
618
693
|
projectEvidenceScore,
|
|
619
694
|
fileConfigScore,
|
|
620
695
|
importGraphScore,
|
|
696
|
+
sourceBoostScore,
|
|
621
697
|
externalGraphScore,
|
|
622
698
|
memoryScore,
|
|
623
699
|
graphScore: externalGraphScore,
|
|
@@ -636,7 +712,8 @@ function calibrateSkillConfidence(score, {
|
|
|
636
712
|
dependencyEvidence,
|
|
637
713
|
fileEvidence,
|
|
638
714
|
negativePenalty = 0,
|
|
639
|
-
explicit = false
|
|
715
|
+
explicit = false,
|
|
716
|
+
semanticScore = 0
|
|
640
717
|
} = {}) {
|
|
641
718
|
let confidence = Math.max(0, Math.min(1, Number(score || 0)));
|
|
642
719
|
const hasDependencyEvidence = Boolean(dependencyEvidence?.matches?.length);
|
|
@@ -647,6 +724,9 @@ function calibrateSkillConfidence(score, {
|
|
|
647
724
|
if (!hasProjectEvidence && !explicit) {
|
|
648
725
|
confidence = Math.min(confidence, 0.62);
|
|
649
726
|
}
|
|
727
|
+
if (!hasProjectEvidence && hasPromptEvidence && Number(semanticScore || 0) >= 0.75 && !explicit) {
|
|
728
|
+
confidence = Math.max(confidence, 0.56);
|
|
729
|
+
}
|
|
650
730
|
if (isAmbiguousPrompt(prompt) && !(hasDependencyEvidence && hasFileEvidence) && !explicit) {
|
|
651
731
|
confidence = Math.min(confidence, 0.64);
|
|
652
732
|
}
|
|
@@ -662,6 +742,22 @@ function calibrateSkillConfidence(score, {
|
|
|
662
742
|
return Math.max(0, Math.min(1, confidence));
|
|
663
743
|
}
|
|
664
744
|
|
|
745
|
+
function skillSourceBoostScore(skill = {}) {
|
|
746
|
+
if (skill.scope === "project") return 1;
|
|
747
|
+
if (isCommunitySkill(skill)) return 0.4;
|
|
748
|
+
return 0;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function skillSourceLabel(skill = {}) {
|
|
752
|
+
if (skill.scope === "project") return "project";
|
|
753
|
+
if (isCommunitySkill(skill)) return "community";
|
|
754
|
+
return "global";
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function isCommunitySkill(skill = {}) {
|
|
758
|
+
return String(skill.path || "").includes(`${path.sep}.config${path.sep}skillshare${path.sep}skills${path.sep}`);
|
|
759
|
+
}
|
|
760
|
+
|
|
665
761
|
function confidenceBand(confidence) {
|
|
666
762
|
const value = Number(confidence || 0);
|
|
667
763
|
if (value >= 0.85) return "high";
|
|
@@ -676,6 +772,43 @@ function isAmbiguousPrompt(prompt = "") {
|
|
|
676
772
|
return tokens.length <= 4 && tokens.every((token) => generic.has(token));
|
|
677
773
|
}
|
|
678
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
|
+
|
|
679
812
|
function detectProjectEvidence({ cwd = process.cwd() } = {}) {
|
|
680
813
|
const dependencies = new Set();
|
|
681
814
|
const scripts = new Set();
|
|
@@ -30,11 +30,13 @@ const KNOWN_AGENT_NAMES = new Set([
|
|
|
30
30
|
|
|
31
31
|
export function workflowSearchRoots({ cwd = process.cwd(), home = os.homedir() } = {}) {
|
|
32
32
|
return [
|
|
33
|
+
path.join(cwd, ".agents", "workflows"),
|
|
33
34
|
path.join(cwd, ".claude", "workflows"),
|
|
34
35
|
path.join(cwd, ".codex", "workflows"),
|
|
35
36
|
path.join(cwd, ".gemini", "workflows"),
|
|
36
37
|
path.join(cwd, ".gemini", "antigravity", "workflows"),
|
|
37
38
|
path.join(cwd, ".gemini", "antigravity-cli", "workflows"),
|
|
39
|
+
path.join(home, ".agents", "workflows"),
|
|
38
40
|
path.join(home, ".claude", "workflows"),
|
|
39
41
|
path.join(home, ".codex", "workflows"),
|
|
40
42
|
path.join(home, ".gemini", "workflows"),
|
|
@@ -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,
|
package/plugins/ctx/mcp/proxy.js
CHANGED
|
@@ -9,6 +9,9 @@ const { serverName, command, args } = parseArgs(process.argv.slice(2));
|
|
|
9
9
|
const cwd = process.cwd();
|
|
10
10
|
const telemetryPath = path.join(workspaceDataDir({ cwd }), "telemetry.jsonl");
|
|
11
11
|
let inspectBuffer = "";
|
|
12
|
+
let childExit = null;
|
|
13
|
+
let pendingStdinWrites = 0;
|
|
14
|
+
let stdinEnded = false;
|
|
12
15
|
|
|
13
16
|
const child = spawn(command, args, {
|
|
14
17
|
cwd,
|
|
@@ -18,20 +21,32 @@ const child = spawn(command, args, {
|
|
|
18
21
|
|
|
19
22
|
process.stdin.on("data", (chunk) => {
|
|
20
23
|
inspectClientChunk(chunk);
|
|
21
|
-
|
|
24
|
+
pendingStdinWrites += 1;
|
|
25
|
+
if (!child.stdin.write(chunk, onChildStdinWrite)) process.stdin.pause();
|
|
22
26
|
});
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
child.stdin.on("drain", () => {
|
|
29
|
+
process.stdin.resume();
|
|
26
30
|
});
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
process.stdin.on("end", () => {
|
|
33
|
+
stdinEnded = true;
|
|
34
|
+
maybeEndChildStdin();
|
|
30
35
|
});
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
function onChildStdinWrite() {
|
|
38
|
+
pendingStdinWrites -= 1;
|
|
39
|
+
maybeEndChildStdin();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function maybeEndChildStdin() {
|
|
43
|
+
if (stdinEnded && pendingStdinWrites === 0 && !child.stdin.destroyed) {
|
|
44
|
+
child.stdin.end();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
child.stdout.pipe(process.stdout);
|
|
49
|
+
child.stderr.pipe(process.stderr);
|
|
35
50
|
|
|
36
51
|
child.on("error", (error) => {
|
|
37
52
|
process.stderr.write(`contextos mcp proxy failed to start ${serverName}: ${error?.message || String(error)}\n`);
|
|
@@ -39,10 +54,16 @@ child.on("error", (error) => {
|
|
|
39
54
|
});
|
|
40
55
|
|
|
41
56
|
child.on("exit", (code, signal) => {
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
childExit = { code, signal };
|
|
58
|
+
maybeFinish();
|
|
44
59
|
});
|
|
45
60
|
|
|
61
|
+
function maybeFinish() {
|
|
62
|
+
if (!childExit) return;
|
|
63
|
+
if (childExit.signal) process.kill(process.pid, childExit.signal);
|
|
64
|
+
else process.exitCode = childExit.code ?? 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
46
67
|
function inspectClientChunk(chunk) {
|
|
47
68
|
inspectBuffer += chunk.toString("utf8");
|
|
48
69
|
const lines = inspectBuffer.split(/\r?\n/);
|
|
@@ -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);
|