@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 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
- - All shell commands must run as minh_dev.
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 `$CODEX_HOME/contextos`.
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
- npx @minhpnq1807/contextos install
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
- rtk node bin/ctx.js install
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 `$CODEX_HOME/contextos/models`.
95
- 4. Warms `$CODEX_HOME/contextos/embeddings.db` for AGENTS rules and project file paths.
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 git diff/status contains concrete evidence. Runtime-only rules, such as tool usage order, are shown as `unknown` unless they leave evidence in changed files.
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
- ### `ctx report`
176
+ ### `npm warn deprecated prebuild-install@7.1.3`
182
177
 
183
- Shows the last Stop-hook report.
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
- ```bash
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
- ```bash
217
- ctx stats
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
- ### `ctx embeddings`
195
+ ## Runtime Files
221
196
 
222
- Builds local embeddings for semantic rule scoring.
197
+ ContextOS writes shared caches to:
223
198
 
224
- ```bash
225
- ctx embeddings warm -- "fix upload moderation flow"
199
+ ```text
200
+ ~/.ctx/contextos/
226
201
  ```
227
202
 
228
- `warm` downloads/loads the local `Xenova/all-MiniLM-L6-v2` model if needed and stores rule/prompt vectors plus project file path vectors in `$CODEX_HOME/contextos/embeddings.db`.
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
- $CODEX_HOME/contextos/
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 a heuristic diff-based measurement.
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
- Example `unknown`: a rule says shell commands must run as `minh_dev`, but git diff does not record shell user identity. ContextOS cannot prove the rule was followed from code changes alone.
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
- rtk npm install
299
+ npm install
318
300
  ```
319
301
 
320
302
  Run tests:
321
303
 
322
304
  ```bash
323
- rtk npm test
305
+ npm test
324
306
  ```
325
307
 
326
308
  Run MCP protocol and warm performance smoke:
327
309
 
328
310
  ```bash
329
- rtk npm run test:mcp
311
+ npm run test:mcp
330
312
  ```
331
313
 
332
314
  Validate plugin schema:
333
315
 
334
316
  ```bash
335
- rtk npm run validate:plugin
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
- process.env.PLUGIN_DATA && path.join(process.env.PLUGIN_DATA, "last-report.json"),
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 process.env.PLUGIN_DATA || path.join(codexHome(), "contextos");
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(contextOSDataDir())));
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhpnq1807/contextos",
3
- "version": "0.1.1",
3
+ "version": "0.1.5",
4
4
  "description": "Task-aware AGENTS.md context injection and compliance reporting for Codex.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,16 +1,20 @@
1
1
  #!/usr/bin/env node
2
- import { readStdinJson, writeJson, failOpen, logDebug, pluginDataDir } from "../lib/hook-io.js";
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: pluginDataDir("last-prompt-context.json"),
13
- historyPath: pluginDataDir("prompt-history.jsonl"),
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, pluginDataDir } from "../lib/hook-io.js";
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: pluginDataDir("last-prompt-context.json"),
10
- reportPath: pluginDataDir("last-report.json"),
11
- historyPath: pluginDataDir("report-history.jsonl")
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 process.env.PLUGIN_DATA || path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "contextos");
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 = path.join(os.homedir(), ".codex", "contextos"),
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 = path.join(os.homedir(), ".codex", "contextos"),
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 = path.join(os.homedir(), ".codex", "contextos")) {
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
- const root = process.env.PLUGIN_DATA || path.join(codexHome(), "contextos");
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(pluginDataDir("debug.log"), `${line}\n`, "utf8");
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: !addedLines.length ? "no added lines in git diff" : "no concrete compliance keywords found"
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 evidence, not git diff evidence"
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
- export function buildReport({ cwd, prompt, relevantFiles, scheduled, gitSnapshot, compliance }) {
2
- const followed = compliance.filter((item) => item.status === "followed");
3
- const ignored = compliance.filter((item) => item.status === "ignored");
4
- const unknown = compliance.filter((item) => item.status === "unknown");
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 parsedRules = parseRules(merged.content);
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 scheduledRules = [
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 compliance = checkCompliance({ rules: scheduledRules, addedLines: gitSnapshot.addedLines });
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: promptContext?.scheduled || null,
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 = process.env.PLUGIN_DATA || path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "contextos");
13
+ const dataDir = defaultDataRoot();
15
14
  const socketPath = ctxMcpSocketPath(dataDir);
16
15
 
17
16
  fs.mkdirSync(dataDir, { recursive: true });