@minhpnq1807/contextos 0.1.1 → 0.1.5
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 +22 -0
- package/README.md +65 -83
- package/bin/ctx.js +13 -5
- 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/analyzer.js +25 -0
- package/plugins/ctx/lib/ctx-mcp-client.js +3 -2
- package/plugins/ctx/lib/embedding-scorer.js +5 -4
- 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 +42 -4
- package/plugins/ctx/lib/score-context.js +4 -2
- package/plugins/ctx/lib/stop-hook.js +30 -5
- 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,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.5
|
|
4
|
+
|
|
5
|
+
- Sanitizes stale Stop reports at display time so previously recorded system-user rules no longer appear in `ctx report` or `ctx evidence` after upgrading.
|
|
6
|
+
- Filters system-user rules again inside the Stop hook to protect reports created from older prompt contexts.
|
|
7
|
+
|
|
8
|
+
## 0.1.4
|
|
9
|
+
|
|
10
|
+
- Filters host/session user rules such as `sudo -u user`, `sudo su - user`, and "commands must run as user X" before scoring and injection.
|
|
11
|
+
- Prevents system-user setup instructions from inflating `unknown` outcomes or skewing ContextOS efficiency reports.
|
|
12
|
+
|
|
13
|
+
## 0.1.3
|
|
14
|
+
|
|
15
|
+
- Separates runtime prompt/report/stats files per workspace under `~/.ctx/contextos/workspaces/<workspace-id>`.
|
|
16
|
+
- Adds a local `.contextos/workspace.json` marker and `.gitignore` entry so workspace identity is stable without being pushed.
|
|
17
|
+
- Keeps MCP/model caches shared at the ContextOS data root while isolating report, evidence, stats, and telemetry by project path.
|
|
18
|
+
|
|
19
|
+
## 0.1.2
|
|
20
|
+
|
|
21
|
+
- Adds local runtime telemetry for hook-visible tool, MCP, and command signals.
|
|
22
|
+
- 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.
|
|
23
|
+
- Shows runtime telemetry summaries in `ctx report` output.
|
|
24
|
+
|
|
3
25
|
## 0.1.1
|
|
4
26
|
|
|
5
27
|
- Fixes `sql.js` WASM resolution when ContextOS is executed from the published npm package through `npm exec` or `npx`.
|
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
|
|
@@ -50,7 +53,7 @@ With ContextOS, each prompt gets a compact block:
|
|
|
50
53
|
```text
|
|
51
54
|
## Critical ContextOS rules
|
|
52
55
|
- Use code-review-graph before reading files.
|
|
53
|
-
-
|
|
56
|
+
- Check upload moderation flows before editing approval code.
|
|
54
57
|
|
|
55
58
|
## Suggested files to check
|
|
56
59
|
- services/content-service/src/infrastructure/services/content-moderation.service.ts
|
|
@@ -63,12 +66,13 @@ With ContextOS, each prompt gets a compact block:
|
|
|
63
66
|
- Registers a `ctx-mcp` MCP server that owns model loading and semantic scoring.
|
|
64
67
|
- Reads the active `AGENTS.md` chain for the current workspace.
|
|
65
68
|
- Scores rules by relevance to the user prompt.
|
|
69
|
+
- Filters host/session setup rules such as "run commands as user X" or `sudo -u user` because they are environment instructions, not project guidance.
|
|
66
70
|
- Finds likely relevant files with a hybrid retriever:
|
|
67
71
|
- first, local prompt/file heuristics create seed candidates;
|
|
68
72
|
- then, if `.code-review-graph/graph.db` exists, ContextOS queries `code-review-graph` semantic search and re-ranks graph-backed matches;
|
|
69
73
|
- 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
|
|
74
|
+
- Stores scheduled context and hook telemetry per workspace under `~/.ctx/contextos/workspaces/<workspace-id>`.
|
|
75
|
+
- Reports rule outcomes as `followed`, `ignored`, or `unknown`, using runtime telemetry for tool/command rules when available.
|
|
72
76
|
- Injects `additionalContext` into Codex by default.
|
|
73
77
|
|
|
74
78
|
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 +82,41 @@ By default, ContextOS runs in injection mode. It adds task-relevant rules and fi
|
|
|
78
82
|
From the package:
|
|
79
83
|
|
|
80
84
|
```bash
|
|
81
|
-
|
|
85
|
+
npm install -g @minhpnq1807/contextos
|
|
86
|
+
ctx install
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Without a global install:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npx @minhpnq1807/contextos@latest install
|
|
82
93
|
```
|
|
83
94
|
|
|
84
95
|
From this repository during local development:
|
|
85
96
|
|
|
86
97
|
```bash
|
|
87
|
-
|
|
98
|
+
node bin/ctx.js install
|
|
88
99
|
```
|
|
89
100
|
|
|
90
101
|
`ctx install` does three things:
|
|
91
102
|
|
|
92
103
|
1. Copies this package into `$CODEX_HOME/marketplaces/contextos`.
|
|
93
104
|
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
|
|
105
|
+
3. Downloads and caches the required local MiniLM embedding model under `~/.ctx/contextos/models`.
|
|
106
|
+
4. Warms `~/.ctx/contextos/embeddings.db` for AGENTS rules and project file paths.
|
|
96
107
|
5. Registers the `ctx-mcp` MCP server and merges ContextOS global hooks into `$CODEX_HOME/hooks.json`.
|
|
97
108
|
|
|
98
109
|
Restart Codex after installing.
|
|
99
110
|
|
|
100
111
|
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
112
|
|
|
113
|
+
Verify the published package in any project:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
npm exec --yes --package=@minhpnq1807/contextos@latest -- ctx --version
|
|
117
|
+
npm exec --yes --package=@minhpnq1807/contextos@latest -- ctx debug -- "fix upload moderation flow"
|
|
118
|
+
```
|
|
119
|
+
|
|
102
120
|
## Modes
|
|
103
121
|
|
|
104
122
|
Injection mode is the default:
|
|
@@ -153,86 +171,39 @@ Run at least one Codex task with ContextOS enabled and let the task finish so th
|
|
|
153
171
|
|
|
154
172
|
### `Average efficiency: unknown`
|
|
155
173
|
|
|
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
|
-
```
|
|
174
|
+
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
175
|
|
|
181
|
-
### `
|
|
176
|
+
### `npm warn deprecated prebuild-install@7.1.3`
|
|
182
177
|
|
|
183
|
-
|
|
178
|
+
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
179
|
|
|
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
|
|
180
|
+
## Commands
|
|
215
181
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
182
|
+
| Command | Meaning | Use when | Output / side effect |
|
|
183
|
+
| --- | --- | --- | --- |
|
|
184
|
+
| `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. |
|
|
185
|
+
| `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`. |
|
|
186
|
+
| `ctx install --inject` | Installs ContextOS with explicit injection mode. | You want to be explicit in scripts or docs. | Same runtime behavior as `ctx install`. |
|
|
187
|
+
| `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. |
|
|
188
|
+
| `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`. |
|
|
189
|
+
| `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`. |
|
|
190
|
+
| `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. |
|
|
191
|
+
| `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. |
|
|
192
|
+
| `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`. |
|
|
193
|
+
| `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
194
|
|
|
220
|
-
|
|
195
|
+
## Runtime Files
|
|
221
196
|
|
|
222
|
-
|
|
197
|
+
ContextOS writes shared caches to:
|
|
223
198
|
|
|
224
|
-
```
|
|
225
|
-
ctx
|
|
199
|
+
```text
|
|
200
|
+
~/.ctx/contextos/
|
|
226
201
|
```
|
|
227
202
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
## Runtime Files
|
|
231
|
-
|
|
232
|
-
ContextOS writes runtime data to:
|
|
203
|
+
Runtime prompt/report files are isolated by workspace:
|
|
233
204
|
|
|
234
205
|
```text
|
|
235
|
-
|
|
206
|
+
~/.ctx/contextos/workspaces/<workspace-id>/
|
|
236
207
|
```
|
|
237
208
|
|
|
238
209
|
Important files:
|
|
@@ -244,8 +215,17 @@ last-prompt-context.json latest scheduled context
|
|
|
244
215
|
last-report.json latest compliance report
|
|
245
216
|
prompt-history.jsonl prompt scheduling history
|
|
246
217
|
report-history.jsonl report history
|
|
218
|
+
telemetry.jsonl local runtime signals from hooks, tools, and commands
|
|
247
219
|
```
|
|
248
220
|
|
|
221
|
+
The workspace id is stored in the target repo at:
|
|
222
|
+
|
|
223
|
+
```text
|
|
224
|
+
.contextos/workspace.json
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
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.
|
|
228
|
+
|
|
249
229
|
These files are local telemetry only. Hooks do not make network calls.
|
|
250
230
|
|
|
251
231
|
## Project Understanding
|
|
@@ -299,7 +279,7 @@ Codex prompt
|
|
|
299
279
|
|
|
300
280
|
## Rule Outcomes
|
|
301
281
|
|
|
302
|
-
ContextOS uses
|
|
282
|
+
ContextOS uses heuristic evidence collection from git diff/status plus local runtime telemetry.
|
|
303
283
|
|
|
304
284
|
```text
|
|
305
285
|
followed = evidence in the diff suggests the rule was applied
|
|
@@ -307,32 +287,34 @@ ignored = evidence in the diff suggests the rule was violated
|
|
|
307
287
|
unknown = the rule was relevant, but the diff does not prove either way
|
|
308
288
|
```
|
|
309
289
|
|
|
310
|
-
|
|
290
|
+
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.
|
|
291
|
+
|
|
292
|
+
Host/session setup rules such as "run shell commands as user X", `sudo su - user`, `sudo -i -u user`, and `sudo -u user` are filtered before scoring. They are not injected and do not count toward `unknown` outcomes because they describe the agent runtime environment rather than project behavior.
|
|
311
293
|
|
|
312
294
|
## Development
|
|
313
295
|
|
|
314
296
|
Install dependencies:
|
|
315
297
|
|
|
316
298
|
```bash
|
|
317
|
-
|
|
299
|
+
npm install
|
|
318
300
|
```
|
|
319
301
|
|
|
320
302
|
Run tests:
|
|
321
303
|
|
|
322
304
|
```bash
|
|
323
|
-
|
|
305
|
+
npm test
|
|
324
306
|
```
|
|
325
307
|
|
|
326
308
|
Run MCP protocol and warm performance smoke:
|
|
327
309
|
|
|
328
310
|
```bash
|
|
329
|
-
|
|
311
|
+
npm run test:mcp
|
|
330
312
|
```
|
|
331
313
|
|
|
332
314
|
Validate plugin schema:
|
|
333
315
|
|
|
334
316
|
```bash
|
|
335
|
-
|
|
317
|
+
npm run validate:plugin
|
|
336
318
|
```
|
|
337
319
|
|
|
338
320
|
Check the npm package contents:
|
package/bin/ctx.js
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
6
|
|
|
7
7
|
import { readAgentsChain } from "../plugins/ctx/lib/reader.js";
|
|
8
|
-
import { parseRules, scoreRules } from "../plugins/ctx/lib/analyzer.js";
|
|
8
|
+
import { filterActionableRules, parseRules, scoreRules } from "../plugins/ctx/lib/analyzer.js";
|
|
9
9
|
import { scheduleContext } from "../plugins/ctx/lib/scheduler.js";
|
|
10
10
|
import { formatEvidence, formatReport } from "../plugins/ctx/lib/reporter.js";
|
|
11
11
|
import { installGlobalHooks } from "../plugins/ctx/lib/global-hooks.js";
|
|
@@ -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, "..");
|
|
@@ -154,8 +155,9 @@ function runCodex(args) {
|
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
function loadLastReport() {
|
|
158
|
+
const workspaceDir = contextOSWorkspaceDataDir();
|
|
157
159
|
const candidates = [
|
|
158
|
-
|
|
160
|
+
path.join(workspaceDir, "last-report.json"),
|
|
159
161
|
path.join(codexHome(), "contextos", "last-report.json"),
|
|
160
162
|
path.join(codexHome(), "marketplaces", "contextos", "plugins", "ctx", ".data", "last-report.json"),
|
|
161
163
|
path.join(codexHome(), "plugins", "ctx", ".data", "last-report.json"),
|
|
@@ -171,7 +173,11 @@ function loadLastReport() {
|
|
|
171
173
|
}
|
|
172
174
|
|
|
173
175
|
function contextOSDataDir() {
|
|
174
|
-
return
|
|
176
|
+
return defaultDataRoot();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function contextOSWorkspaceDataDir(cwd = process.cwd()) {
|
|
180
|
+
return workspaceDataDir({ cwd, dataRoot: contextOSDataDir() });
|
|
175
181
|
}
|
|
176
182
|
|
|
177
183
|
async function debug(task) {
|
|
@@ -189,6 +195,8 @@ async function debug(task) {
|
|
|
189
195
|
|
|
190
196
|
console.log("ContextOS debug");
|
|
191
197
|
console.log(`cwd: ${cwd}`);
|
|
198
|
+
console.log(`workspace data: ${contextOSWorkspaceDataDir(cwd)}`);
|
|
199
|
+
console.log(`workspace marker: ${workspaceMarkerPath(cwd)}`);
|
|
192
200
|
console.log(`rules: ${rules.length}`);
|
|
193
201
|
console.log(`mcp scorer: ${scored.telemetry.modelStatus}${scored.telemetry.model ? ` (${scored.telemetry.model})` : ""}`);
|
|
194
202
|
console.log(`elapsed: ${scored.telemetry.elapsedMs}ms`);
|
|
@@ -214,7 +222,7 @@ async function debug(task) {
|
|
|
214
222
|
async function warmEmbeddings(task) {
|
|
215
223
|
const cwd = process.cwd();
|
|
216
224
|
const merged = readAgentsChain({ cwd });
|
|
217
|
-
const rules = scoreRules(parseRules(merged.content), task, []);
|
|
225
|
+
const rules = scoreRules(filterActionableRules(parseRules(merged.content)), task, []);
|
|
218
226
|
const result = await warmRuleEmbeddings({
|
|
219
227
|
rules,
|
|
220
228
|
task,
|
|
@@ -260,7 +268,7 @@ try {
|
|
|
260
268
|
} else if (command === "evidence") {
|
|
261
269
|
console.log(formatEvidence(loadLastReport()));
|
|
262
270
|
} else if (command === "stats") {
|
|
263
|
-
console.log(formatStats(loadStats(
|
|
271
|
+
console.log(formatStats(loadStats(contextOSWorkspaceDataDir())));
|
|
264
272
|
} else {
|
|
265
273
|
throw new Error(`Unknown command: ${command}\n\n${usage()}`);
|
|
266
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, {
|
|
@@ -48,6 +48,20 @@ const SEMANTIC_ALIASES = {
|
|
|
48
48
|
|
|
49
49
|
const MODERATION_TOKENS = new Set(["moderation", "moderate", "content-moderation", "approval", "approved", "reject", "rejected", "needs_review"]);
|
|
50
50
|
|
|
51
|
+
const SYSTEM_USER_RULE_PATTERNS = [
|
|
52
|
+
/\ball\s+shell\s+commands?\s+must\s+run\s+as\b/i,
|
|
53
|
+
/\bcommands?\s+must\s+run\s+as\b/i,
|
|
54
|
+
/\bstrictly\s+follow\s+this\s+sequence\b/i,
|
|
55
|
+
/\bswitch\s+the\s+user\s+context\b/i,
|
|
56
|
+
/\bdo\s+not\s+prefix\b.*\bsudo\s+-u\b/i,
|
|
57
|
+
/\bsudo\s+su\s+-\s*[a-z_][a-z0-9_-]*\b/i,
|
|
58
|
+
/\bsudo\s+-i\s+-u\s+[a-z_][a-z0-9_-]*\b/i,
|
|
59
|
+
/\bsudo\s+-u\s+[a-z_][a-z0-9_-]*\b/i,
|
|
60
|
+
/\bsu\s+-\s+[a-z_][a-z0-9_-]*\b/i,
|
|
61
|
+
/[/\\]\.codex[/\\]RTK\.md\b/i,
|
|
62
|
+
/\bminh_dev\b/i
|
|
63
|
+
];
|
|
64
|
+
|
|
51
65
|
export function tokenize(value) {
|
|
52
66
|
const normalized = String(value || "")
|
|
53
67
|
.toLowerCase()
|
|
@@ -138,6 +152,17 @@ export function parseRules(markdown) {
|
|
|
138
152
|
return dedupeRules(rules);
|
|
139
153
|
}
|
|
140
154
|
|
|
155
|
+
export function filterActionableRules(rules = []) {
|
|
156
|
+
return rules
|
|
157
|
+
.filter((rule) => !isSystemUserRule(rule))
|
|
158
|
+
.map((rule, index) => ({ ...rule, id: `r${index + 1}`, originalOrder: index }));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function isSystemUserRule(rule) {
|
|
162
|
+
const content = typeof rule === "string" ? rule : rule?.content;
|
|
163
|
+
return SYSTEM_USER_RULE_PATTERNS.some((pattern) => pattern.test(String(content || "")));
|
|
164
|
+
}
|
|
165
|
+
|
|
141
166
|
function dedupeRules(rules) {
|
|
142
167
|
const seen = new Set();
|
|
143
168
|
return rules.filter((rule) => {
|
|
@@ -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,10 +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";
|
|
5
4
|
import { createRequire } from "node:module";
|
|
6
5
|
import { fileURLToPath } from "node:url";
|
|
7
6
|
|
|
7
|
+
import { defaultDataRoot } from "./workspace-data.js";
|
|
8
|
+
|
|
8
9
|
const DEFAULT_MODEL = "Xenova/all-MiniLM-L6-v2";
|
|
9
10
|
const DEFAULT_TIMEOUT_MS = 800;
|
|
10
11
|
const SEMANTIC_HIGH_THRESHOLD = 0.5;
|
|
@@ -20,7 +21,7 @@ export async function enhanceRuleScoresWithEmbeddings(
|
|
|
20
21
|
rules,
|
|
21
22
|
task,
|
|
22
23
|
{
|
|
23
|
-
dataDir =
|
|
24
|
+
dataDir = defaultDataRoot(),
|
|
24
25
|
sources = [],
|
|
25
26
|
timeoutMs = Number(process.env.CONTEXTOS_EMBEDDING_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
|
|
26
27
|
allowRemote = process.env.CONTEXTOS_EMBEDDING_ALLOW_REMOTE === "1",
|
|
@@ -52,7 +53,7 @@ export async function enhanceRuleScoresWithEmbeddings(
|
|
|
52
53
|
export async function warmRuleEmbeddings({
|
|
53
54
|
rules = [],
|
|
54
55
|
task = "",
|
|
55
|
-
dataDir =
|
|
56
|
+
dataDir = defaultDataRoot(),
|
|
56
57
|
sources = [],
|
|
57
58
|
allowRemote = true
|
|
58
59
|
} = {}) {
|
|
@@ -126,7 +127,7 @@ async function getExtractor({ allowRemote, dataDir }) {
|
|
|
126
127
|
return extractorPromises.get(key);
|
|
127
128
|
}
|
|
128
129
|
|
|
129
|
-
export function modelCacheDir(dataDir =
|
|
130
|
+
export function modelCacheDir(dataDir = defaultDataRoot()) {
|
|
130
131
|
return path.join(dataDir, "models");
|
|
131
132
|
}
|
|
132
133
|
|
|
@@ -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,7 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
1
|
+
import { isSystemUserRule } from "./analyzer.js";
|
|
2
|
+
|
|
3
|
+
export function buildReport({ cwd, prompt, relevantFiles, scheduled, gitSnapshot, compliance, runtimeEvidence }) {
|
|
4
|
+
const actionableCompliance = compliance.filter((item) => !isSystemUserRule(item.rule));
|
|
5
|
+
const followed = actionableCompliance.filter((item) => item.status === "followed");
|
|
6
|
+
const ignored = actionableCompliance.filter((item) => item.status === "ignored");
|
|
7
|
+
const unknown = actionableCompliance.filter((item) => item.status === "unknown");
|
|
5
8
|
const measured = followed.length + ignored.length;
|
|
6
9
|
const efficiencyScore = measured ? Math.round((followed.length / measured) * 100) : null;
|
|
7
10
|
|
|
@@ -13,6 +16,7 @@ export function buildReport({ cwd, prompt, relevantFiles, scheduled, gitSnapshot
|
|
|
13
16
|
relevantFiles,
|
|
14
17
|
changedFiles: gitSnapshot.changedFiles,
|
|
15
18
|
warnings: gitSnapshot.warnings || [],
|
|
19
|
+
runtimeEvidence: summarizeRuntimeEvidence(runtimeEvidence),
|
|
16
20
|
followed,
|
|
17
21
|
ignored,
|
|
18
22
|
unknown,
|
|
@@ -23,6 +27,7 @@ export function buildReport({ cwd, prompt, relevantFiles, scheduled, gitSnapshot
|
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export function formatReport(report) {
|
|
30
|
+
report = sanitizeReport(report);
|
|
26
31
|
const lines = [];
|
|
27
32
|
lines.push("ContextOS report");
|
|
28
33
|
lines.push(`Efficiency: ${report.efficiencyScore == null ? "unknown" : `${report.efficiencyScore}%`}`);
|
|
@@ -34,6 +39,9 @@ export function formatReport(report) {
|
|
|
34
39
|
if (report.relevantFiles?.length) {
|
|
35
40
|
lines.push(`Suggested files: ${report.relevantFiles.map((file) => file.path).join(", ")}`);
|
|
36
41
|
}
|
|
42
|
+
if (report.runtimeEvidence?.signals?.length) {
|
|
43
|
+
lines.push(`Runtime telemetry: ${report.runtimeEvidence.signals.join(", ")}`);
|
|
44
|
+
}
|
|
37
45
|
|
|
38
46
|
for (const warning of report.warnings || []) lines.push(`Warning: ${warning}`);
|
|
39
47
|
|
|
@@ -51,6 +59,7 @@ export function formatReport(report) {
|
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
export function formatEvidence(report) {
|
|
62
|
+
report = sanitizeReport(report);
|
|
54
63
|
const lines = [];
|
|
55
64
|
lines.push("ContextOS evidence");
|
|
56
65
|
lines.push(`Prompt: ${report.prompt || "(empty)"}`);
|
|
@@ -103,3 +112,32 @@ function truncate(value, max) {
|
|
|
103
112
|
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
|
104
113
|
return normalized.length > max ? `${normalized.slice(0, max - 3)}...` : normalized;
|
|
105
114
|
}
|
|
115
|
+
|
|
116
|
+
function summarizeRuntimeEvidence(runtimeEvidence = {}) {
|
|
117
|
+
const signals = [
|
|
118
|
+
...(runtimeEvidence.toolSignals || []),
|
|
119
|
+
...(runtimeEvidence.commandSignals || []),
|
|
120
|
+
...(runtimeEvidence.signals || [])
|
|
121
|
+
];
|
|
122
|
+
return {
|
|
123
|
+
signals: [...new Set(signals)].slice(0, 20),
|
|
124
|
+
sources: (runtimeEvidence.sources || []).slice(0, 10)
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sanitizeReport(report = {}) {
|
|
129
|
+
const followed = (report.followed || []).filter((item) => !isSystemUserRule(item.rule));
|
|
130
|
+
const ignored = (report.ignored || []).filter((item) => !isSystemUserRule(item.rule));
|
|
131
|
+
const unknown = (report.unknown || []).filter((item) => !isSystemUserRule(item.rule));
|
|
132
|
+
const measured = followed.length + ignored.length;
|
|
133
|
+
return {
|
|
134
|
+
...report,
|
|
135
|
+
injectedRuleCount: followed.length + ignored.length + unknown.length,
|
|
136
|
+
followed,
|
|
137
|
+
ignored,
|
|
138
|
+
unknown,
|
|
139
|
+
measuredRuleCount: measured,
|
|
140
|
+
unknownRuleCount: unknown.length,
|
|
141
|
+
efficiencyScore: measured ? Math.round((followed.length / measured) * 100) : null
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
3
|
import { readAgentsChain } from "./reader.js";
|
|
4
|
-
import { parseRules, scoreRules, findRelevantFiles } from "./analyzer.js";
|
|
4
|
+
import { filterActionableRules, parseRules, scoreRules, findRelevantFiles } from "./analyzer.js";
|
|
5
5
|
import { enhanceRuleScoresWithEmbeddings } from "./embedding-scorer.js";
|
|
6
6
|
|
|
7
7
|
export async function scoreContext({
|
|
@@ -15,7 +15,8 @@ export async function scoreContext({
|
|
|
15
15
|
} = {}) {
|
|
16
16
|
const started = Date.now();
|
|
17
17
|
const merged = readAgentsChain({ cwd });
|
|
18
|
-
const
|
|
18
|
+
const rawRules = parseRules(merged.content);
|
|
19
|
+
const parsedRules = filterActionableRules(rawRules);
|
|
19
20
|
const baseScoredRules = scoreRules(parsedRules, prompt, openFiles);
|
|
20
21
|
const embedding = await enhanceRuleScoresWithEmbeddings(baseScoredRules, prompt, {
|
|
21
22
|
dataDir,
|
|
@@ -47,6 +48,7 @@ export async function scoreContext({
|
|
|
47
48
|
model: embedding.model,
|
|
48
49
|
cachePath: embedding.cachePath,
|
|
49
50
|
rulesParsed: parsedRules.length,
|
|
51
|
+
rulesFiltered: rawRules.length - parsedRules.length,
|
|
50
52
|
rulesInjected: scoredRules.filter((rule) => Number(rule.score || 0) >= 0.1).length,
|
|
51
53
|
filesSuggested: suggestedFiles.length,
|
|
52
54
|
sources: merged.sources.map((source) => path.relative(cwd, source))
|
|
@@ -3,23 +3,48 @@ 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";
|
|
7
|
+
import { filterActionableRules } from "./analyzer.js";
|
|
6
8
|
|
|
7
|
-
export function handleStopPayload(payload, { contextPath, reportPath, historyPath } = {}) {
|
|
9
|
+
export function handleStopPayload(payload, { contextPath, reportPath, historyPath, telemetryPath } = {}) {
|
|
8
10
|
const cwd = payload.cwd || payload.working_directory || process.cwd();
|
|
9
11
|
const promptContext = contextPath && fs.existsSync(contextPath) ? readJsonFile(contextPath) : null;
|
|
10
|
-
const
|
|
12
|
+
const rawScheduledRules = [
|
|
11
13
|
...(promptContext?.scheduled?.highRules || []),
|
|
12
14
|
...(promptContext?.scheduled?.midRules || [])
|
|
13
15
|
];
|
|
16
|
+
const scheduledRules = filterActionableRules(rawScheduledRules);
|
|
17
|
+
const scheduled = promptContext?.scheduled
|
|
18
|
+
? {
|
|
19
|
+
...promptContext.scheduled,
|
|
20
|
+
highRules: filterActionableRules(promptContext.scheduled.highRules || []),
|
|
21
|
+
midRules: filterActionableRules(promptContext.scheduled.midRules || []),
|
|
22
|
+
droppedRules: [
|
|
23
|
+
...(promptContext.scheduled.droppedRules || []),
|
|
24
|
+
...rawScheduledRules.filter((rule) => !scheduledRules.some((item) => item.content === rule.content && item.sourcePath === rule.sourcePath))
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
: null;
|
|
14
28
|
const gitSnapshot = readGitSnapshot({ cwd });
|
|
15
|
-
const
|
|
29
|
+
const runtimeEvidence = loadRuntimeEvidence({
|
|
30
|
+
telemetryPath,
|
|
31
|
+
since: promptContext?.at,
|
|
32
|
+
cwd,
|
|
33
|
+
payload
|
|
34
|
+
});
|
|
35
|
+
const compliance = checkCompliance({
|
|
36
|
+
rules: scheduledRules,
|
|
37
|
+
addedLines: gitSnapshot.addedLines,
|
|
38
|
+
runtimeEvidence
|
|
39
|
+
});
|
|
16
40
|
const report = buildReport({
|
|
17
41
|
cwd,
|
|
18
42
|
prompt: promptContext?.prompt || "",
|
|
19
43
|
relevantFiles: promptContext?.relevantFiles || [],
|
|
20
|
-
scheduled
|
|
44
|
+
scheduled,
|
|
21
45
|
gitSnapshot,
|
|
22
|
-
compliance
|
|
46
|
+
compliance,
|
|
47
|
+
runtimeEvidence
|
|
23
48
|
});
|
|
24
49
|
|
|
25
50
|
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 });
|