@minhpnq1807/contextos 0.1.0 → 0.1.3
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 +63 -82
- package/bin/ctx.js +21 -4
- package/package.json +1 -1
- package/plugins/ctx/bin/on-prompt.js +7 -3
- package/plugins/ctx/bin/on-session-start.js +4 -1
- package/plugins/ctx/bin/on-stop.js +8 -4
- package/plugins/ctx/lib/ctx-mcp-client.js +3 -2
- package/plugins/ctx/lib/embedding-scorer.js +16 -5
- package/plugins/ctx/lib/hook-io.js +19 -7
- package/plugins/ctx/lib/measure.js +21 -5
- package/plugins/ctx/lib/prompt-hook.js +3 -2
- package/plugins/ctx/lib/reporter.js +17 -1
- package/plugins/ctx/lib/stop-hook.js +15 -3
- package/plugins/ctx/lib/telemetry.js +188 -0
- package/plugins/ctx/lib/workspace-data.js +83 -0
- package/plugins/ctx/mcp/server.js +2 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.3
|
|
4
|
+
|
|
5
|
+
- Separates runtime prompt/report/stats files per workspace under `~/.ctx/contextos/workspaces/<workspace-id>`.
|
|
6
|
+
- Adds a local `.contextos/workspace.json` marker and `.gitignore` entry so workspace identity is stable without being pushed.
|
|
7
|
+
- Keeps MCP/model caches shared at the ContextOS data root while isolating report, evidence, stats, and telemetry by project path.
|
|
8
|
+
|
|
9
|
+
## 0.1.2
|
|
10
|
+
|
|
11
|
+
- Adds local runtime telemetry for hook-visible tool, MCP, and command signals.
|
|
12
|
+
- Uses telemetry evidence in Stop reports so runtime-only rules can be marked `followed` instead of staying `unknown` when matching tool/command signals are observed.
|
|
13
|
+
- Shows runtime telemetry summaries in `ctx report` output.
|
|
14
|
+
|
|
15
|
+
## 0.1.1
|
|
16
|
+
|
|
17
|
+
- Fixes `sql.js` WASM resolution when ContextOS is executed from the published npm package through `npm exec` or `npx`.
|
|
18
|
+
|
|
3
19
|
## 0.1.0
|
|
4
20
|
|
|
5
21
|
Initial public release.
|
package/README.md
CHANGED
|
@@ -4,10 +4,13 @@ ContextOS (`ctx`) is a Codex companion plugin for task-aware project context.
|
|
|
4
4
|
|
|
5
5
|
It reads `AGENTS.md` guidance, scores the rules against the current prompt, suggests relevant files, records what context would have been injected, and reports lightweight compliance evidence after the task finishes.
|
|
6
6
|
|
|
7
|
+
Published package: [`@minhpnq1807/contextos`](https://www.npmjs.com/package/@minhpnq1807/contextos)
|
|
8
|
+
|
|
7
9
|
## Quick Start
|
|
8
10
|
|
|
9
11
|
```bash
|
|
10
12
|
npm install -g @minhpnq1807/contextos
|
|
13
|
+
ctx --version
|
|
11
14
|
ctx install
|
|
12
15
|
```
|
|
13
16
|
|
|
@@ -16,7 +19,7 @@ Restart Codex after installing, then use Codex normally. ContextOS runs through
|
|
|
16
19
|
You can also run without a global install:
|
|
17
20
|
|
|
18
21
|
```bash
|
|
19
|
-
npx @minhpnq1807/contextos install
|
|
22
|
+
npx @minhpnq1807/contextos@latest install
|
|
20
23
|
```
|
|
21
24
|
|
|
22
25
|
## Demo Flow
|
|
@@ -67,8 +70,8 @@ With ContextOS, each prompt gets a compact block:
|
|
|
67
70
|
- first, local prompt/file heuristics create seed candidates;
|
|
68
71
|
- then, if `.code-review-graph/graph.db` exists, ContextOS queries `code-review-graph` semantic search and re-ranks graph-backed matches;
|
|
69
72
|
- if no graph exists or graph lookup times out, it falls back to local heuristics.
|
|
70
|
-
- Stores scheduled context and hook telemetry under
|
|
71
|
-
- Reports rule outcomes as `followed`, `ignored`, or `unknown
|
|
73
|
+
- Stores scheduled context and hook telemetry per workspace under `~/.ctx/contextos/workspaces/<workspace-id>`.
|
|
74
|
+
- Reports rule outcomes as `followed`, `ignored`, or `unknown`, using runtime telemetry for tool/command rules when available.
|
|
72
75
|
- Injects `additionalContext` into Codex by default.
|
|
73
76
|
|
|
74
77
|
By default, ContextOS runs in injection mode. It adds task-relevant rules and files to the model context so the agent has the right project guidance at the moment it starts working.
|
|
@@ -78,27 +81,41 @@ By default, ContextOS runs in injection mode. It adds task-relevant rules and fi
|
|
|
78
81
|
From the package:
|
|
79
82
|
|
|
80
83
|
```bash
|
|
81
|
-
|
|
84
|
+
npm install -g @minhpnq1807/contextos
|
|
85
|
+
ctx install
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Without a global install:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npx @minhpnq1807/contextos@latest install
|
|
82
92
|
```
|
|
83
93
|
|
|
84
94
|
From this repository during local development:
|
|
85
95
|
|
|
86
96
|
```bash
|
|
87
|
-
|
|
97
|
+
node bin/ctx.js install
|
|
88
98
|
```
|
|
89
99
|
|
|
90
100
|
`ctx install` does three things:
|
|
91
101
|
|
|
92
102
|
1. Copies this package into `$CODEX_HOME/marketplaces/contextos`.
|
|
93
103
|
2. Registers and installs `ctx@contextos` through Codex plugin marketplace commands.
|
|
94
|
-
3. Downloads and caches the required local MiniLM embedding model under
|
|
95
|
-
4. Warms
|
|
104
|
+
3. Downloads and caches the required local MiniLM embedding model under `~/.ctx/contextos/models`.
|
|
105
|
+
4. Warms `~/.ctx/contextos/embeddings.db` for AGENTS rules and project file paths.
|
|
96
106
|
5. Registers the `ctx-mcp` MCP server and merges ContextOS global hooks into `$CODEX_HOME/hooks.json`.
|
|
97
107
|
|
|
98
108
|
Restart Codex after installing.
|
|
99
109
|
|
|
100
110
|
The embedding model is mandatory. `ctx install` intentionally fails if the model cannot be prepared, because otherwise the first prompt hook would have to cold-load or download the model.
|
|
101
111
|
|
|
112
|
+
Verify the published package in any project:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npm exec --yes --package=@minhpnq1807/contextos@latest -- ctx --version
|
|
116
|
+
npm exec --yes --package=@minhpnq1807/contextos@latest -- ctx debug -- "fix upload moderation flow"
|
|
117
|
+
```
|
|
118
|
+
|
|
102
119
|
## Modes
|
|
103
120
|
|
|
104
121
|
Injection mode is the default:
|
|
@@ -153,86 +170,39 @@ Run at least one Codex task with ContextOS enabled and let the task finish so th
|
|
|
153
170
|
|
|
154
171
|
### `Average efficiency: unknown`
|
|
155
172
|
|
|
156
|
-
ContextOS only reports efficiency when
|
|
157
|
-
|
|
158
|
-
## Commands
|
|
159
|
-
|
|
160
|
-
```bash
|
|
161
|
-
ctx install
|
|
162
|
-
ctx install --quiet
|
|
163
|
-
ctx install --inject
|
|
164
|
-
ctx install --copy
|
|
165
|
-
ctx debug -- "fix auth login bug"
|
|
166
|
-
ctx report
|
|
167
|
-
ctx evidence
|
|
168
|
-
ctx stats
|
|
169
|
-
ctx embeddings warm -- "fix upload moderation flow"
|
|
170
|
-
ctx --version
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
### `ctx debug`
|
|
174
|
-
|
|
175
|
-
Runs ContextOS scheduling locally for a fake task and prints rule scores plus the final context that would be injected.
|
|
176
|
-
|
|
177
|
-
```bash
|
|
178
|
-
ctx debug -- "fix upload moderation flow"
|
|
179
|
-
```
|
|
173
|
+
ContextOS only reports efficiency when it has concrete evidence. Diff-based rules are measured from git diff/status. Runtime-only rules, such as tool usage order, are measured from local hook telemetry when Codex exposes tool or command metadata. If neither source proves the outcome, the rule remains `unknown`.
|
|
180
174
|
|
|
181
|
-
### `
|
|
175
|
+
### `npm warn deprecated prebuild-install@7.1.3`
|
|
182
176
|
|
|
183
|
-
|
|
177
|
+
This warning comes from a transitive dependency in the local embedding/WASM stack. It does not block installation or runtime commands. ContextOS still runs normally if npm exits with code `0`.
|
|
184
178
|
|
|
185
|
-
|
|
186
|
-
ctx report
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
### `ctx evidence`
|
|
190
|
-
|
|
191
|
-
Shows detailed rule-by-rule evidence for the last report:
|
|
192
|
-
|
|
193
|
-
- status
|
|
194
|
-
- rule text
|
|
195
|
-
- source file
|
|
196
|
-
- score
|
|
197
|
-
- evidence reason
|
|
198
|
-
|
|
199
|
-
```bash
|
|
200
|
-
ctx evidence
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
### `ctx stats`
|
|
204
|
-
|
|
205
|
-
Shows aggregate runtime stats:
|
|
206
|
-
|
|
207
|
-
- prompts analyzed
|
|
208
|
-
- reports generated
|
|
209
|
-
- injected vs quiet prompts
|
|
210
|
-
- average prompt analysis time
|
|
211
|
-
- average efficiency
|
|
212
|
-
- followed / ignored / unknown counts
|
|
213
|
-
- hook event counts
|
|
214
|
-
- last prompt and suggested files
|
|
179
|
+
## Commands
|
|
215
180
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
181
|
+
| Command | Meaning | Use when | Output / side effect |
|
|
182
|
+
| --- | --- | --- | --- |
|
|
183
|
+
| `ctx install` | Installs ContextOS into Codex with prompt context injection enabled. | Normal setup after installing the npm package. | Copies the plugin into `$CODEX_HOME/marketplaces/contextos`, registers `ctx@contextos`, registers `ctx-mcp`, installs global hooks, downloads the embedding model, and warms caches. |
|
|
184
|
+
| `ctx install --quiet` | Installs ContextOS in measurement-only mode. | You want reports and stats but do not want a visible `hook context` block in Codex. | Installs the same plugin/hooks, but prompt hooks return empty `additionalContext`. |
|
|
185
|
+
| `ctx install --inject` | Installs ContextOS with explicit injection mode. | You want to be explicit in scripts or docs. | Same runtime behavior as `ctx install`. |
|
|
186
|
+
| `ctx install --copy` | Copies only the plugin payload to `$CODEX_HOME/plugins/ctx`. | Local development or manual plugin experiments. | Does not register marketplace, MCP, or global hooks. |
|
|
187
|
+
| `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`. |
|
|
188
|
+
| `ctx report` | Shows the last Stop-hook compliance report for the current workspace. | A Codex task has finished and you want the summary again. | Reads `~/.ctx/contextos/workspaces/<workspace-id>/last-report.json`. |
|
|
189
|
+
| `ctx evidence` | Shows detailed evidence behind the last report for the current workspace. | You want to inspect why a rule was marked `followed`, `ignored`, or `unknown`. | Prints rule text, source file, score, status, and evidence reason. |
|
|
190
|
+
| `ctx stats` | Shows aggregate runtime metrics for the current workspace. | You want to know whether ContextOS is active and useful over time. | Prints prompt count, report count, injected/quiet ratio, average prompt analysis time, efficiency, rule outcomes, hook events, and last suggested files for the current workspace only. |
|
|
191
|
+
| `ctx embeddings warm -- "task"` | Prepares local semantic embedding caches. | First install, CI smoke checks, or after changing AGENTS.md/project files. | Loads/downloads `Xenova/all-MiniLM-L6-v2` and writes vectors to `~/.ctx/contextos/embeddings.db`. |
|
|
192
|
+
| `ctx --version` | Prints the installed ContextOS CLI version. | You want to confirm which npm version is being executed. | Prints the version from package metadata. |
|
|
219
193
|
|
|
220
|
-
|
|
194
|
+
## Runtime Files
|
|
221
195
|
|
|
222
|
-
|
|
196
|
+
ContextOS writes shared caches to:
|
|
223
197
|
|
|
224
|
-
```
|
|
225
|
-
ctx
|
|
198
|
+
```text
|
|
199
|
+
~/.ctx/contextos/
|
|
226
200
|
```
|
|
227
201
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
## Runtime Files
|
|
231
|
-
|
|
232
|
-
ContextOS writes runtime data to:
|
|
202
|
+
Runtime prompt/report files are isolated by workspace:
|
|
233
203
|
|
|
234
204
|
```text
|
|
235
|
-
|
|
205
|
+
~/.ctx/contextos/workspaces/<workspace-id>/
|
|
236
206
|
```
|
|
237
207
|
|
|
238
208
|
Important files:
|
|
@@ -244,8 +214,17 @@ last-prompt-context.json latest scheduled context
|
|
|
244
214
|
last-report.json latest compliance report
|
|
245
215
|
prompt-history.jsonl prompt scheduling history
|
|
246
216
|
report-history.jsonl report history
|
|
217
|
+
telemetry.jsonl local runtime signals from hooks, tools, and commands
|
|
247
218
|
```
|
|
248
219
|
|
|
220
|
+
The workspace id is stored in the target repo at:
|
|
221
|
+
|
|
222
|
+
```text
|
|
223
|
+
.contextos/workspace.json
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
ContextOS also adds `.contextos/` to the repo `.gitignore` so the local marker is not pushed. If the marker cannot be written, ContextOS falls back to a deterministic id generated from the workspace real path.
|
|
227
|
+
|
|
249
228
|
These files are local telemetry only. Hooks do not make network calls.
|
|
250
229
|
|
|
251
230
|
## Project Understanding
|
|
@@ -299,7 +278,7 @@ Codex prompt
|
|
|
299
278
|
|
|
300
279
|
## Rule Outcomes
|
|
301
280
|
|
|
302
|
-
ContextOS uses
|
|
281
|
+
ContextOS uses heuristic evidence collection from git diff/status plus local runtime telemetry.
|
|
303
282
|
|
|
304
283
|
```text
|
|
305
284
|
followed = evidence in the diff suggests the rule was applied
|
|
@@ -307,32 +286,34 @@ ignored = evidence in the diff suggests the rule was violated
|
|
|
307
286
|
unknown = the rule was relevant, but the diff does not prove either way
|
|
308
287
|
```
|
|
309
288
|
|
|
310
|
-
|
|
289
|
+
For runtime-only rules, ContextOS also checks `telemetry.jsonl` for hook-visible tool names, MCP server names, and command metadata. A rule like "use code-review-graph before reading files" can be marked `followed` when telemetry contains a matching `code-review-graph` signal.
|
|
290
|
+
|
|
291
|
+
Example `unknown`: a rule says shell commands must run as a specific OS user, but neither git diff nor hook telemetry records that user identity. ContextOS cannot prove the rule was followed from available evidence alone.
|
|
311
292
|
|
|
312
293
|
## Development
|
|
313
294
|
|
|
314
295
|
Install dependencies:
|
|
315
296
|
|
|
316
297
|
```bash
|
|
317
|
-
|
|
298
|
+
npm install
|
|
318
299
|
```
|
|
319
300
|
|
|
320
301
|
Run tests:
|
|
321
302
|
|
|
322
303
|
```bash
|
|
323
|
-
|
|
304
|
+
npm test
|
|
324
305
|
```
|
|
325
306
|
|
|
326
307
|
Run MCP protocol and warm performance smoke:
|
|
327
308
|
|
|
328
309
|
```bash
|
|
329
|
-
|
|
310
|
+
npm run test:mcp
|
|
330
311
|
```
|
|
331
312
|
|
|
332
313
|
Validate plugin schema:
|
|
333
314
|
|
|
334
315
|
```bash
|
|
335
|
-
|
|
316
|
+
npm run validate:plugin
|
|
336
317
|
```
|
|
337
318
|
|
|
338
319
|
Check the npm package contents:
|
package/bin/ctx.js
CHANGED
|
@@ -13,6 +13,7 @@ import { formatStats, loadStats } from "../plugins/ctx/lib/stats.js";
|
|
|
13
13
|
import { modelCacheDir, warmRuleEmbeddings } from "../plugins/ctx/lib/embedding-scorer.js";
|
|
14
14
|
import { warmFileEmbeddings } from "../plugins/ctx/lib/file-embedding-retriever.js";
|
|
15
15
|
import { scoreContext } from "../plugins/ctx/lib/score-context.js";
|
|
16
|
+
import { defaultDataRoot, workspaceDataDir, workspaceMarkerPath } from "../plugins/ctx/lib/workspace-data.js";
|
|
16
17
|
|
|
17
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
19
|
const rootDir = path.resolve(__dirname, "..");
|
|
@@ -35,6 +36,15 @@ Usage:
|
|
|
35
36
|
`;
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
function packageVersion() {
|
|
40
|
+
try {
|
|
41
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
|
42
|
+
return packageJson.version || "0.0.0";
|
|
43
|
+
} catch {
|
|
44
|
+
return "0.0.0";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
function codexHome() {
|
|
39
49
|
return process.env.CODEX_HOME || path.join(process.env.HOME || process.cwd(), ".codex");
|
|
40
50
|
}
|
|
@@ -145,8 +155,9 @@ function runCodex(args) {
|
|
|
145
155
|
}
|
|
146
156
|
|
|
147
157
|
function loadLastReport() {
|
|
158
|
+
const workspaceDir = contextOSWorkspaceDataDir();
|
|
148
159
|
const candidates = [
|
|
149
|
-
|
|
160
|
+
path.join(workspaceDir, "last-report.json"),
|
|
150
161
|
path.join(codexHome(), "contextos", "last-report.json"),
|
|
151
162
|
path.join(codexHome(), "marketplaces", "contextos", "plugins", "ctx", ".data", "last-report.json"),
|
|
152
163
|
path.join(codexHome(), "plugins", "ctx", ".data", "last-report.json"),
|
|
@@ -162,7 +173,11 @@ function loadLastReport() {
|
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
function contextOSDataDir() {
|
|
165
|
-
return
|
|
176
|
+
return defaultDataRoot();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function contextOSWorkspaceDataDir(cwd = process.cwd()) {
|
|
180
|
+
return workspaceDataDir({ cwd, dataRoot: contextOSDataDir() });
|
|
166
181
|
}
|
|
167
182
|
|
|
168
183
|
async function debug(task) {
|
|
@@ -180,6 +195,8 @@ async function debug(task) {
|
|
|
180
195
|
|
|
181
196
|
console.log("ContextOS debug");
|
|
182
197
|
console.log(`cwd: ${cwd}`);
|
|
198
|
+
console.log(`workspace data: ${contextOSWorkspaceDataDir(cwd)}`);
|
|
199
|
+
console.log(`workspace marker: ${workspaceMarkerPath(cwd)}`);
|
|
183
200
|
console.log(`rules: ${rules.length}`);
|
|
184
201
|
console.log(`mcp scorer: ${scored.telemetry.modelStatus}${scored.telemetry.model ? ` (${scored.telemetry.model})` : ""}`);
|
|
185
202
|
console.log(`elapsed: ${scored.telemetry.elapsedMs}ms`);
|
|
@@ -230,7 +247,7 @@ try {
|
|
|
230
247
|
if (!command || command === "--help" || command === "-h") {
|
|
231
248
|
console.log(usage());
|
|
232
249
|
} else if (command === "--version" || command === "-v") {
|
|
233
|
-
console.log(
|
|
250
|
+
console.log(packageVersion());
|
|
234
251
|
} else if (command === "install") {
|
|
235
252
|
await install({ copy: args.includes("--copy"), inject: !args.includes("--quiet") });
|
|
236
253
|
} else if (command === "debug") {
|
|
@@ -251,7 +268,7 @@ try {
|
|
|
251
268
|
} else if (command === "evidence") {
|
|
252
269
|
console.log(formatEvidence(loadLastReport()));
|
|
253
270
|
} else if (command === "stats") {
|
|
254
|
-
console.log(formatStats(loadStats(
|
|
271
|
+
console.log(formatStats(loadStats(contextOSWorkspaceDataDir())));
|
|
255
272
|
} else {
|
|
256
273
|
throw new Error(`Unknown command: ${command}\n\n${usage()}`);
|
|
257
274
|
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readStdinJson, writeJson, failOpen, logDebug,
|
|
2
|
+
import { readStdinJson, writeJson, failOpen, logDebug, pluginRuntimeFile, pluginDataRoot } from "../lib/hook-io.js";
|
|
3
3
|
import { handlePromptPayload } from "../lib/prompt-hook.js";
|
|
4
|
+
import { appendTelemetry } from "../lib/telemetry.js";
|
|
4
5
|
|
|
5
6
|
const started = Date.now();
|
|
6
7
|
|
|
7
8
|
try {
|
|
8
9
|
const payload = await readStdinJson();
|
|
10
|
+
const cwd = payload.cwd || payload.working_directory;
|
|
9
11
|
|
|
10
12
|
logDebug("UserPromptSubmit", payload);
|
|
13
|
+
appendTelemetry({ telemetryPath: pluginRuntimeFile("telemetry.jsonl", cwd), event: "UserPromptSubmit", payload });
|
|
11
14
|
writeJson(await handlePromptPayload(payload, {
|
|
12
|
-
dataPath:
|
|
13
|
-
historyPath:
|
|
15
|
+
dataPath: pluginRuntimeFile("last-prompt-context.json", cwd),
|
|
16
|
+
historyPath: pluginRuntimeFile("prompt-history.jsonl", cwd),
|
|
17
|
+
mcpDataDir: pluginDataRoot(),
|
|
14
18
|
started
|
|
15
19
|
}));
|
|
16
20
|
} catch (error) {
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readStdinJson, writeJson, failOpen, logDebug } from "../lib/hook-io.js";
|
|
2
|
+
import { readStdinJson, writeJson, failOpen, logDebug, pluginRuntimeFile } from "../lib/hook-io.js";
|
|
3
|
+
import { appendTelemetry } from "../lib/telemetry.js";
|
|
3
4
|
|
|
4
5
|
try {
|
|
5
6
|
const payload = await readStdinJson();
|
|
7
|
+
const cwd = payload.cwd || payload.working_directory;
|
|
6
8
|
logDebug("SessionStart", payload);
|
|
9
|
+
appendTelemetry({ telemetryPath: pluginRuntimeFile("telemetry.jsonl", cwd), event: "SessionStart", payload });
|
|
7
10
|
writeJson({
|
|
8
11
|
continue: true,
|
|
9
12
|
suppressOutput: true,
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readStdinJson, writeJson, failOpen, logDebug,
|
|
2
|
+
import { readStdinJson, writeJson, failOpen, logDebug, pluginRuntimeFile } from "../lib/hook-io.js";
|
|
3
3
|
import { handleStopPayload } from "../lib/stop-hook.js";
|
|
4
|
+
import { appendTelemetry } from "../lib/telemetry.js";
|
|
4
5
|
|
|
5
6
|
try {
|
|
6
7
|
const payload = await readStdinJson();
|
|
8
|
+
const cwd = payload.cwd || payload.working_directory;
|
|
7
9
|
logDebug("Stop", payload);
|
|
10
|
+
appendTelemetry({ telemetryPath: pluginRuntimeFile("telemetry.jsonl", cwd), event: "Stop", payload });
|
|
8
11
|
writeJson(handleStopPayload(payload, {
|
|
9
|
-
contextPath:
|
|
10
|
-
reportPath:
|
|
11
|
-
historyPath:
|
|
12
|
+
contextPath: pluginRuntimeFile("last-prompt-context.json", cwd),
|
|
13
|
+
reportPath: pluginRuntimeFile("last-report.json", cwd),
|
|
14
|
+
historyPath: pluginRuntimeFile("report-history.jsonl", cwd),
|
|
15
|
+
telemetryPath: pluginRuntimeFile("telemetry.jsonl", cwd)
|
|
12
16
|
}));
|
|
13
17
|
} catch (error) {
|
|
14
18
|
failOpen("Stop", error, {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import net from "node:net";
|
|
3
|
-
import os from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
|
|
5
|
+
import { defaultDataRoot } from "./workspace-data.js";
|
|
6
|
+
|
|
6
7
|
const DEFAULT_TIMEOUT_MS = 1000;
|
|
7
8
|
|
|
8
9
|
export function ctxMcpSocketPath(dataDir = defaultDataDir()) {
|
|
@@ -48,5 +49,5 @@ export async function callCtxScoreContext(payload, {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
function defaultDataDir() {
|
|
51
|
-
return
|
|
52
|
+
return defaultDataRoot();
|
|
52
53
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
|
+
import { defaultDataRoot } from "./workspace-data.js";
|
|
8
|
+
|
|
7
9
|
const DEFAULT_MODEL = "Xenova/all-MiniLM-L6-v2";
|
|
8
10
|
const DEFAULT_TIMEOUT_MS = 800;
|
|
9
11
|
const SEMANTIC_HIGH_THRESHOLD = 0.5;
|
|
@@ -13,12 +15,13 @@ let sqlPromise = null;
|
|
|
13
15
|
|
|
14
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
17
|
const repoRoot = path.resolve(__dirname, "..", "..", "..");
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
16
19
|
|
|
17
20
|
export async function enhanceRuleScoresWithEmbeddings(
|
|
18
21
|
rules,
|
|
19
22
|
task,
|
|
20
23
|
{
|
|
21
|
-
dataDir =
|
|
24
|
+
dataDir = defaultDataRoot(),
|
|
22
25
|
sources = [],
|
|
23
26
|
timeoutMs = Number(process.env.CONTEXTOS_EMBEDDING_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
|
|
24
27
|
allowRemote = process.env.CONTEXTOS_EMBEDDING_ALLOW_REMOTE === "1",
|
|
@@ -50,7 +53,7 @@ export async function enhanceRuleScoresWithEmbeddings(
|
|
|
50
53
|
export async function warmRuleEmbeddings({
|
|
51
54
|
rules = [],
|
|
52
55
|
task = "",
|
|
53
|
-
dataDir =
|
|
56
|
+
dataDir = defaultDataRoot(),
|
|
54
57
|
sources = [],
|
|
55
58
|
allowRemote = true
|
|
56
59
|
} = {}) {
|
|
@@ -124,7 +127,7 @@ async function getExtractor({ allowRemote, dataDir }) {
|
|
|
124
127
|
return extractorPromises.get(key);
|
|
125
128
|
}
|
|
126
129
|
|
|
127
|
-
export function modelCacheDir(dataDir =
|
|
130
|
+
export function modelCacheDir(dataDir = defaultDataRoot()) {
|
|
128
131
|
return path.join(dataDir, "models");
|
|
129
132
|
}
|
|
130
133
|
|
|
@@ -189,13 +192,21 @@ async function getSql() {
|
|
|
189
192
|
sqlPromise = (async () => {
|
|
190
193
|
const initSqlJs = (await import("sql.js")).default;
|
|
191
194
|
return initSqlJs({
|
|
192
|
-
locateFile:
|
|
195
|
+
locateFile: locateSqlJsFile
|
|
193
196
|
});
|
|
194
197
|
})();
|
|
195
198
|
}
|
|
196
199
|
return sqlPromise;
|
|
197
200
|
}
|
|
198
201
|
|
|
202
|
+
function locateSqlJsFile(file) {
|
|
203
|
+
try {
|
|
204
|
+
return require.resolve(`sql.js/dist/${file}`);
|
|
205
|
+
} catch {
|
|
206
|
+
return path.join(repoRoot, "node_modules", "sql.js", "dist", file);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
199
210
|
function cacheKey(text, sources) {
|
|
200
211
|
return crypto
|
|
201
212
|
.createHash("sha256")
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { writeJsonFile } from "./fs-utils.js";
|
|
4
|
-
|
|
5
|
-
function codexHome() {
|
|
6
|
-
return process.env.CODEX_HOME || path.join(process.env.HOME || process.cwd(), ".codex");
|
|
7
|
-
}
|
|
4
|
+
import { defaultDataRoot, workspaceDataDir } from "./workspace-data.js";
|
|
8
5
|
|
|
9
6
|
export async function readStdinJson() {
|
|
10
7
|
const chunks = [];
|
|
@@ -18,8 +15,19 @@ export function writeJson(value) {
|
|
|
18
15
|
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
19
16
|
}
|
|
20
17
|
|
|
21
|
-
export function pluginDataDir(fileName = "") {
|
|
22
|
-
|
|
18
|
+
export function pluginDataDir(fileName = "", cwd = process.cwd()) {
|
|
19
|
+
let root;
|
|
20
|
+
try {
|
|
21
|
+
root = workspaceDataDir({ cwd });
|
|
22
|
+
fs.mkdirSync(root, { recursive: true });
|
|
23
|
+
} catch {
|
|
24
|
+
return path.join(process.cwd(), ".contextos", fileName);
|
|
25
|
+
}
|
|
26
|
+
return path.join(root, fileName);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function pluginDataRoot(fileName = "") {
|
|
30
|
+
const root = defaultDataRoot();
|
|
23
31
|
try {
|
|
24
32
|
fs.mkdirSync(root, { recursive: true });
|
|
25
33
|
} catch {
|
|
@@ -28,10 +36,14 @@ export function pluginDataDir(fileName = "") {
|
|
|
28
36
|
return path.join(root, fileName);
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
export function pluginRuntimeFile(fileName = "", cwd) {
|
|
40
|
+
return cwd ? pluginDataDir(fileName, cwd) : pluginDataRoot(fileName);
|
|
41
|
+
}
|
|
42
|
+
|
|
31
43
|
export function logDebug(event, payload) {
|
|
32
44
|
const line = JSON.stringify({ at: new Date().toISOString(), event, payload });
|
|
33
45
|
try {
|
|
34
|
-
fs.appendFileSync(
|
|
46
|
+
fs.appendFileSync(pluginRuntimeFile("debug.log", payload?.cwd || payload?.working_directory), `${line}\n`, "utf8");
|
|
35
47
|
} catch {
|
|
36
48
|
// Logging must never break Codex hooks.
|
|
37
49
|
}
|
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
import { tokenize } from "./analyzer.js";
|
|
6
|
+
import { findRuntimeEvidence, runtimeKeywordsForRule } from "./telemetry.js";
|
|
6
7
|
|
|
7
8
|
const COMPLIANCE_STOPWORDS = new Set([
|
|
8
9
|
"always",
|
|
@@ -156,24 +157,38 @@ export function parseGitDiff(diff) {
|
|
|
156
157
|
};
|
|
157
158
|
}
|
|
158
159
|
|
|
159
|
-
export function checkCompliance({ rules = [], addedLines = [] } = {}) {
|
|
160
|
+
export function checkCompliance({ rules = [], addedLines = [], runtimeEvidence = {} } = {}) {
|
|
160
161
|
const results = [];
|
|
161
162
|
|
|
162
163
|
for (const rule of rules) {
|
|
163
164
|
const lower = rule.content.toLowerCase();
|
|
164
165
|
const keywords = extractComplianceKeywords(rule.content);
|
|
165
166
|
const isRuntimeOnly = needsRuntimeEvidence(rule.content);
|
|
167
|
+
const runtimeMatch = isRuntimeOnly ? findRuntimeEvidence(rule, runtimeEvidence) : null;
|
|
166
168
|
const kind = lower.includes("no ") || lower.includes("never") || lower.includes("khong")
|
|
167
169
|
? "forbidden"
|
|
168
170
|
: "required";
|
|
169
171
|
|
|
172
|
+
if (isRuntimeOnly && runtimeMatch) {
|
|
173
|
+
results.push({
|
|
174
|
+
rule,
|
|
175
|
+
status: "followed",
|
|
176
|
+
kind: "runtime",
|
|
177
|
+
keywords: [...new Set([...keywords, ...runtimeKeywordsForRule(rule.content)])],
|
|
178
|
+
evidence: runtimeMatch.evidence
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
170
183
|
if (!keywords.length || !addedLines.length) {
|
|
171
184
|
results.push({
|
|
172
185
|
rule,
|
|
173
186
|
status: "unknown",
|
|
174
|
-
kind,
|
|
187
|
+
kind: isRuntimeOnly ? "runtime" : kind,
|
|
175
188
|
keywords,
|
|
176
|
-
evidence:
|
|
189
|
+
evidence: isRuntimeOnly
|
|
190
|
+
? "requires runtime/tool-call telemetry; no matching runtime signal observed"
|
|
191
|
+
: (!addedLines.length ? "no added lines in git diff" : "no concrete compliance keywords found")
|
|
177
192
|
});
|
|
178
193
|
continue;
|
|
179
194
|
}
|
|
@@ -184,7 +199,7 @@ export function checkCompliance({ rules = [], addedLines = [] } = {}) {
|
|
|
184
199
|
status: "unknown",
|
|
185
200
|
kind: "runtime",
|
|
186
201
|
keywords,
|
|
187
|
-
evidence: "requires runtime/tool-call
|
|
202
|
+
evidence: "requires runtime/tool-call telemetry; no matching runtime signal observed"
|
|
188
203
|
});
|
|
189
204
|
continue;
|
|
190
205
|
}
|
|
@@ -235,7 +250,8 @@ export function checkCompliance({ rules = [], addedLines = [] } = {}) {
|
|
|
235
250
|
}
|
|
236
251
|
|
|
237
252
|
function needsRuntimeEvidence(content) {
|
|
238
|
-
return RUNTIME_EVIDENCE_PATTERNS.some((pattern) => pattern.test(content))
|
|
253
|
+
return RUNTIME_EVIDENCE_PATTERNS.some((pattern) => pattern.test(content))
|
|
254
|
+
|| runtimeKeywordsForRule(content).length > 0;
|
|
239
255
|
}
|
|
240
256
|
|
|
241
257
|
function findKeywordEvidence(lines, keywords) {
|
|
@@ -11,7 +11,8 @@ export async function handlePromptPayload(
|
|
|
11
11
|
now = new Date(),
|
|
12
12
|
started = Date.now(),
|
|
13
13
|
injectContext = process.env.CONTEXTOS_INJECT !== "0",
|
|
14
|
-
scoreContextClient = callCtxScoreContext
|
|
14
|
+
scoreContextClient = callCtxScoreContext,
|
|
15
|
+
mcpDataDir
|
|
15
16
|
} = {}
|
|
16
17
|
) {
|
|
17
18
|
const prompt = payload.prompt || payload.message || payload.user_prompt || "";
|
|
@@ -25,7 +26,7 @@ export async function handlePromptPayload(
|
|
|
25
26
|
openFiles,
|
|
26
27
|
maxFiles: 3
|
|
27
28
|
}, {
|
|
28
|
-
dataDir,
|
|
29
|
+
dataDir: mcpDataDir || dataDir,
|
|
29
30
|
timeoutMs: Number(process.env.CONTEXTOS_MCP_BRIDGE_TIMEOUT_MS || 1000)
|
|
30
31
|
});
|
|
31
32
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export function buildReport({ cwd, prompt, relevantFiles, scheduled, gitSnapshot, compliance }) {
|
|
1
|
+
export function buildReport({ cwd, prompt, relevantFiles, scheduled, gitSnapshot, compliance, runtimeEvidence }) {
|
|
2
2
|
const followed = compliance.filter((item) => item.status === "followed");
|
|
3
3
|
const ignored = compliance.filter((item) => item.status === "ignored");
|
|
4
4
|
const unknown = compliance.filter((item) => item.status === "unknown");
|
|
@@ -13,6 +13,7 @@ export function buildReport({ cwd, prompt, relevantFiles, scheduled, gitSnapshot
|
|
|
13
13
|
relevantFiles,
|
|
14
14
|
changedFiles: gitSnapshot.changedFiles,
|
|
15
15
|
warnings: gitSnapshot.warnings || [],
|
|
16
|
+
runtimeEvidence: summarizeRuntimeEvidence(runtimeEvidence),
|
|
16
17
|
followed,
|
|
17
18
|
ignored,
|
|
18
19
|
unknown,
|
|
@@ -34,6 +35,9 @@ export function formatReport(report) {
|
|
|
34
35
|
if (report.relevantFiles?.length) {
|
|
35
36
|
lines.push(`Suggested files: ${report.relevantFiles.map((file) => file.path).join(", ")}`);
|
|
36
37
|
}
|
|
38
|
+
if (report.runtimeEvidence?.signals?.length) {
|
|
39
|
+
lines.push(`Runtime telemetry: ${report.runtimeEvidence.signals.join(", ")}`);
|
|
40
|
+
}
|
|
37
41
|
|
|
38
42
|
for (const warning of report.warnings || []) lines.push(`Warning: ${warning}`);
|
|
39
43
|
|
|
@@ -103,3 +107,15 @@ function truncate(value, max) {
|
|
|
103
107
|
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
|
104
108
|
return normalized.length > max ? `${normalized.slice(0, max - 3)}...` : normalized;
|
|
105
109
|
}
|
|
110
|
+
|
|
111
|
+
function summarizeRuntimeEvidence(runtimeEvidence = {}) {
|
|
112
|
+
const signals = [
|
|
113
|
+
...(runtimeEvidence.toolSignals || []),
|
|
114
|
+
...(runtimeEvidence.commandSignals || []),
|
|
115
|
+
...(runtimeEvidence.signals || [])
|
|
116
|
+
];
|
|
117
|
+
return {
|
|
118
|
+
signals: [...new Set(signals)].slice(0, 20),
|
|
119
|
+
sources: (runtimeEvidence.sources || []).slice(0, 10)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -3,8 +3,9 @@ import fs from "node:fs";
|
|
|
3
3
|
import { appendJsonLine, readJsonFile, writeJsonFile } from "./fs-utils.js";
|
|
4
4
|
import { readGitSnapshot, checkCompliance } from "./measure.js";
|
|
5
5
|
import { buildReport, formatReport } from "./reporter.js";
|
|
6
|
+
import { loadRuntimeEvidence } from "./telemetry.js";
|
|
6
7
|
|
|
7
|
-
export function handleStopPayload(payload, { contextPath, reportPath, historyPath } = {}) {
|
|
8
|
+
export function handleStopPayload(payload, { contextPath, reportPath, historyPath, telemetryPath } = {}) {
|
|
8
9
|
const cwd = payload.cwd || payload.working_directory || process.cwd();
|
|
9
10
|
const promptContext = contextPath && fs.existsSync(contextPath) ? readJsonFile(contextPath) : null;
|
|
10
11
|
const scheduledRules = [
|
|
@@ -12,14 +13,25 @@ export function handleStopPayload(payload, { contextPath, reportPath, historyPat
|
|
|
12
13
|
...(promptContext?.scheduled?.midRules || [])
|
|
13
14
|
];
|
|
14
15
|
const gitSnapshot = readGitSnapshot({ cwd });
|
|
15
|
-
const
|
|
16
|
+
const runtimeEvidence = loadRuntimeEvidence({
|
|
17
|
+
telemetryPath,
|
|
18
|
+
since: promptContext?.at,
|
|
19
|
+
cwd,
|
|
20
|
+
payload
|
|
21
|
+
});
|
|
22
|
+
const compliance = checkCompliance({
|
|
23
|
+
rules: scheduledRules,
|
|
24
|
+
addedLines: gitSnapshot.addedLines,
|
|
25
|
+
runtimeEvidence
|
|
26
|
+
});
|
|
16
27
|
const report = buildReport({
|
|
17
28
|
cwd,
|
|
18
29
|
prompt: promptContext?.prompt || "",
|
|
19
30
|
relevantFiles: promptContext?.relevantFiles || [],
|
|
20
31
|
scheduled: promptContext?.scheduled || null,
|
|
21
32
|
gitSnapshot,
|
|
22
|
-
compliance
|
|
33
|
+
compliance,
|
|
34
|
+
runtimeEvidence
|
|
23
35
|
});
|
|
24
36
|
|
|
25
37
|
if (reportPath) writeJsonFile(reportPath, report);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { appendJsonLine } from "./fs-utils.js";
|
|
4
|
+
|
|
5
|
+
const MAX_TEXT_VALUES = 80;
|
|
6
|
+
const MAX_TEXT_LENGTH = 500;
|
|
7
|
+
const MAX_EVENTS = 500;
|
|
8
|
+
|
|
9
|
+
const TOOL_SIGNAL_KEYS = new Set([
|
|
10
|
+
"tool",
|
|
11
|
+
"tool_name",
|
|
12
|
+
"toolName",
|
|
13
|
+
"name",
|
|
14
|
+
"server",
|
|
15
|
+
"mcp",
|
|
16
|
+
"command",
|
|
17
|
+
"cmd"
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const SIGNAL_PATTERNS = [
|
|
21
|
+
/\bcode-review-graph\b/i,
|
|
22
|
+
/\brtk\b/i,
|
|
23
|
+
/\bdetect_changes\b/i,
|
|
24
|
+
/\bget_review_context\b/i,
|
|
25
|
+
/\bget_impact_radius\b/i,
|
|
26
|
+
/\bget_affected_flows\b/i,
|
|
27
|
+
/\bquery_graph\b/i,
|
|
28
|
+
/\bsemantic_search_nodes\b/i,
|
|
29
|
+
/\bget_architecture_overview\b/i,
|
|
30
|
+
/\blist_communities\b/i,
|
|
31
|
+
/\bagentmemory\b/i
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export function appendTelemetry({ telemetryPath, event, payload, extra = {} }) {
|
|
35
|
+
if (!telemetryPath) return;
|
|
36
|
+
try {
|
|
37
|
+
appendJsonLine(telemetryPath, buildTelemetryEvent({ event, payload, extra }));
|
|
38
|
+
} catch {
|
|
39
|
+
// Telemetry is diagnostic; hooks must stay fail-open.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildTelemetryEvent({ event, payload, extra = {}, at = new Date() }) {
|
|
44
|
+
const extracted = extractPayloadSignals(payload);
|
|
45
|
+
return {
|
|
46
|
+
at: at.toISOString(),
|
|
47
|
+
event,
|
|
48
|
+
cwd: payload?.cwd || payload?.working_directory || null,
|
|
49
|
+
...extra,
|
|
50
|
+
signals: extracted.signals,
|
|
51
|
+
toolSignals: extracted.toolSignals,
|
|
52
|
+
commandSignals: extracted.commandSignals
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function loadRuntimeEvidence({ telemetryPath, since, cwd, payload } = {}) {
|
|
57
|
+
const events = [];
|
|
58
|
+
if (telemetryPath && fs.existsSync(telemetryPath)) {
|
|
59
|
+
const lines = fs.readFileSync(telemetryPath, "utf8").split(/\r?\n/).filter(Boolean).slice(-MAX_EVENTS);
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
try {
|
|
62
|
+
events.push(JSON.parse(line));
|
|
63
|
+
} catch {
|
|
64
|
+
// Ignore corrupt telemetry lines.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (payload) {
|
|
70
|
+
events.push(buildTelemetryEvent({ event: payload.hook_event_name || "Stop", payload }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const sinceMs = since ? Date.parse(since) : null;
|
|
74
|
+
const filtered = events.filter((event) => {
|
|
75
|
+
if (cwd && event.cwd && event.cwd !== cwd) return false;
|
|
76
|
+
if (sinceMs && event.at && Date.parse(event.at) < sinceMs) return false;
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const signals = new Set();
|
|
81
|
+
const toolSignals = new Set();
|
|
82
|
+
const commandSignals = new Set();
|
|
83
|
+
const sources = [];
|
|
84
|
+
|
|
85
|
+
for (const event of filtered) {
|
|
86
|
+
for (const signal of event.signals || []) signals.add(signal);
|
|
87
|
+
for (const signal of event.toolSignals || []) toolSignals.add(signal);
|
|
88
|
+
for (const signal of event.commandSignals || []) commandSignals.add(signal);
|
|
89
|
+
if ((event.signals?.length || event.toolSignals?.length || event.commandSignals?.length) && sources.length < 20) {
|
|
90
|
+
sources.push({ at: event.at, event: event.event, cwd: event.cwd });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
signals: [...signals],
|
|
96
|
+
toolSignals: [...toolSignals],
|
|
97
|
+
commandSignals: [...commandSignals],
|
|
98
|
+
sources
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function findRuntimeEvidence(rule, runtimeEvidence = {}) {
|
|
103
|
+
const content = String(rule?.content || "");
|
|
104
|
+
const candidates = [
|
|
105
|
+
...(runtimeEvidence.toolSignals || []),
|
|
106
|
+
...(runtimeEvidence.commandSignals || []),
|
|
107
|
+
...(runtimeEvidence.signals || [])
|
|
108
|
+
];
|
|
109
|
+
const lowerCandidates = candidates.map((value) => String(value || "").toLowerCase());
|
|
110
|
+
|
|
111
|
+
const wanted = runtimeKeywordsForRule(content);
|
|
112
|
+
for (const keyword of wanted) {
|
|
113
|
+
const lowerKeyword = keyword.toLowerCase();
|
|
114
|
+
const match = lowerCandidates.find((candidate) => candidate.includes(lowerKeyword));
|
|
115
|
+
if (match) {
|
|
116
|
+
return {
|
|
117
|
+
keyword,
|
|
118
|
+
evidence: `runtime telemetry observed ${keyword}`
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function runtimeKeywordsForRule(content) {
|
|
127
|
+
const lower = String(content || "").toLowerCase();
|
|
128
|
+
const keywords = [];
|
|
129
|
+
for (const pattern of SIGNAL_PATTERNS) {
|
|
130
|
+
const match = lower.match(pattern);
|
|
131
|
+
if (match?.[0]) keywords.push(match[0]);
|
|
132
|
+
}
|
|
133
|
+
const backticks = [...String(content || "").matchAll(/`([^`]+)`/g)].map((match) => match[1]);
|
|
134
|
+
for (const value of backticks) {
|
|
135
|
+
if (SIGNAL_PATTERNS.some((pattern) => pattern.test(value))) keywords.push(value);
|
|
136
|
+
}
|
|
137
|
+
return [...new Set(keywords)];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function extractPayloadSignals(payload) {
|
|
141
|
+
const values = [];
|
|
142
|
+
const toolSignals = [];
|
|
143
|
+
const commandSignals = [];
|
|
144
|
+
|
|
145
|
+
walk(payload, [], (path, value) => {
|
|
146
|
+
if (values.length < MAX_TEXT_VALUES) values.push(value);
|
|
147
|
+
const key = path.at(-1) || "";
|
|
148
|
+
if (TOOL_SIGNAL_KEYS.has(key)) {
|
|
149
|
+
toolSignals.push(value);
|
|
150
|
+
if (key === "command" || key === "cmd") commandSignals.push(value);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const signals = [];
|
|
155
|
+
for (const value of values) {
|
|
156
|
+
for (const pattern of SIGNAL_PATTERNS) {
|
|
157
|
+
const match = String(value).match(pattern);
|
|
158
|
+
if (match?.[0]) signals.push(match[0]);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
signals: [...new Set(signals)],
|
|
164
|
+
toolSignals: [...new Set(toolSignals.filter(hasSignalPattern))],
|
|
165
|
+
commandSignals: [...new Set(commandSignals.filter(hasSignalPattern))]
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function walk(value, path, onText) {
|
|
170
|
+
if (value == null) return;
|
|
171
|
+
if (typeof value === "string") {
|
|
172
|
+
const text = value.length > MAX_TEXT_LENGTH ? value.slice(0, MAX_TEXT_LENGTH) : value;
|
|
173
|
+
onText(path, text);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (typeof value !== "object") return;
|
|
177
|
+
if (Array.isArray(value)) {
|
|
178
|
+
value.slice(0, 100).forEach((item, index) => walk(item, [...path, String(index)], onText));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
for (const [key, item] of Object.entries(value).slice(0, 100)) {
|
|
182
|
+
walk(item, [...path, key], onText);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function hasSignalPattern(value) {
|
|
187
|
+
return SIGNAL_PATTERNS.some((pattern) => pattern.test(String(value || "")));
|
|
188
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const MARKER_DIR = ".contextos";
|
|
7
|
+
const MARKER_FILE = "workspace.json";
|
|
8
|
+
const IGNORE_ENTRY = ".contextos/";
|
|
9
|
+
|
|
10
|
+
export function defaultDataRoot() {
|
|
11
|
+
return process.env.PLUGIN_DATA
|
|
12
|
+
|| process.env.CONTEXTOS_HOME
|
|
13
|
+
|| path.join(os.homedir(), ".ctx", "contextos");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function workspaceDataDir({ cwd = process.cwd(), dataRoot = defaultDataRoot(), createMarker = true } = {}) {
|
|
17
|
+
const id = workspaceId({ cwd, createMarker });
|
|
18
|
+
const dir = path.join(dataRoot, "workspaces", id);
|
|
19
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function workspaceId({ cwd = process.cwd(), createMarker = true } = {}) {
|
|
24
|
+
const root = resolveWorkspaceRoot(cwd);
|
|
25
|
+
const markerPath = path.join(root, MARKER_DIR, MARKER_FILE);
|
|
26
|
+
const existing = readMarker(markerPath);
|
|
27
|
+
if (existing?.id) return existing.id;
|
|
28
|
+
|
|
29
|
+
const id = deterministicWorkspaceId(root);
|
|
30
|
+
if (createMarker) writeMarker({ root, markerPath, id });
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveWorkspaceRoot(cwd = process.cwd()) {
|
|
35
|
+
try {
|
|
36
|
+
return fs.realpathSync(cwd);
|
|
37
|
+
} catch {
|
|
38
|
+
return path.resolve(cwd);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function workspaceMarkerPath(cwd = process.cwd()) {
|
|
43
|
+
return path.join(resolveWorkspaceRoot(cwd), MARKER_DIR, MARKER_FILE);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function deterministicWorkspaceId(root) {
|
|
47
|
+
const base = path.basename(root) || "workspace";
|
|
48
|
+
const slug = base.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace";
|
|
49
|
+
const hash = crypto.createHash("sha256").update(root).digest("hex").slice(0, 12);
|
|
50
|
+
return `${slug}-${hash}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readMarker(markerPath) {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(fs.readFileSync(markerPath, "utf8"));
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeMarker({ root, markerPath, id }) {
|
|
62
|
+
try {
|
|
63
|
+
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
64
|
+
fs.writeFileSync(markerPath, `${JSON.stringify({ id, root }, null, 2)}\n`, "utf8");
|
|
65
|
+
ensureGitignore(root);
|
|
66
|
+
} catch {
|
|
67
|
+
// Marker files are only for stable local identity; deterministic ids still work.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ensureGitignore(root) {
|
|
72
|
+
const gitignorePath = path.join(root, ".gitignore");
|
|
73
|
+
let content = "";
|
|
74
|
+
try {
|
|
75
|
+
content = fs.readFileSync(gitignorePath, "utf8");
|
|
76
|
+
} catch {
|
|
77
|
+
// Missing .gitignore is fine; create one below.
|
|
78
|
+
}
|
|
79
|
+
const lines = content.split(/\r?\n/).map((line) => line.trim());
|
|
80
|
+
if (lines.includes(IGNORE_ENTRY)) return;
|
|
81
|
+
const prefix = content && !content.endsWith("\n") ? "\n" : "";
|
|
82
|
+
fs.appendFileSync(gitignorePath, `${prefix}${IGNORE_ENTRY}\n`, "utf8");
|
|
83
|
+
}
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import net from "node:net";
|
|
4
|
-
import os from "node:os";
|
|
5
|
-
import path from "node:path";
|
|
6
4
|
|
|
7
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
6
|
|
|
9
7
|
import { modelCacheDir, warmRuleEmbeddings } from "../lib/embedding-scorer.js";
|
|
10
8
|
import { scoreContext } from "../lib/score-context.js";
|
|
11
9
|
import { ctxMcpSocketPath } from "../lib/ctx-mcp-client.js";
|
|
10
|
+
import { defaultDataRoot } from "../lib/workspace-data.js";
|
|
12
11
|
import { createContextOSMcpServer } from "./contextos-server.js";
|
|
13
12
|
|
|
14
|
-
const dataDir =
|
|
13
|
+
const dataDir = defaultDataRoot();
|
|
15
14
|
const socketPath = ctxMcpSocketPath(dataDir);
|
|
16
15
|
|
|
17
16
|
fs.mkdirSync(dataDir, { recursive: true });
|