@compr/opscontext-mcp 2.0.2 → 2.1.1

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.
@@ -0,0 +1,336 @@
1
+ // 🔒 LOCKED [DRIFT-HEURISTICS] — 2026-06-23
2
+ // ⛔ NEVER make a heuristic fire on a single event in isolation. Every
3
+ // heuristic looks at a WINDOW of events. Single-event triggers will
4
+ // fire on the user's normal workflow and burn trust in the alerts.
5
+ // ⛔ NEVER raise a critical severity from a heuristic without a corresponding
6
+ // audit event (drift.detected with full payload). Critical = OS
7
+ // notification + interrupt — the audit trail is what the user reviews
8
+ // after-the-fact to understand WHY the alert fired.
9
+ // ⛔ NEVER trust the assistant's claim that something exists in the file
10
+ // system. The fabrication_suspect check is precisely about catching
11
+ // those claims. If you add helpers, default to "verify against fs".
12
+ // WHY: Drift alerts have to be precise. False positives train users to
13
+ // ignore the status bar, defeating the entire purpose. Conservative
14
+ // thresholds + window-based detection + auditable trail are the
15
+ // discipline that earns user trust.
16
+ // FIX: To add a new heuristic, copy the shape of detectLoop or detectStuck,
17
+ // keep the predicate pure (no I/O except fs.existsSync), append it to
18
+ // HEURISTICS at the bottom, and add a fixture to tests/__fixtures__/
19
+ // audit-logs/.
20
+ import { readAuditLog, safeAppend } from "./audit.js";
21
+ import { watch, existsSync } from "fs";
22
+ import { join, isAbsolute } from "path";
23
+ import { homedir } from "os";
24
+ // ─── Window scan ───────────────────────────────────────────────────────────
25
+ export function scanRecentEvents(windowSeconds = 300, now = Date.now()) {
26
+ let all;
27
+ try {
28
+ all = readAuditLog();
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ const cutoff = now - windowSeconds * 1000;
34
+ return all.filter((r) => {
35
+ const ts = Date.parse(r.ts);
36
+ return Number.isFinite(ts) && ts >= cutoff;
37
+ });
38
+ }
39
+ // ─── Helpers ───────────────────────────────────────────────────────────────
40
+ /** Tokenize text for cheap similarity comparisons (Jaccard / BM25-lite). */
41
+ export function tokens(s) {
42
+ return new Set(String(s || "")
43
+ .toLowerCase()
44
+ .replace(/[^\p{L}\p{N}\s]+/gu, " ")
45
+ .split(/\s+/)
46
+ .filter((t) => t.length > 2));
47
+ }
48
+ export function jaccard(a, b) {
49
+ if (a.size === 0 && b.size === 0)
50
+ return 1;
51
+ if (a.size === 0 || b.size === 0)
52
+ return 0;
53
+ let intersect = 0;
54
+ for (const t of a)
55
+ if (b.has(t))
56
+ intersect++;
57
+ return intersect / (a.size + b.size - intersect);
58
+ }
59
+ function groupBy(arr, keyFn) {
60
+ const m = new Map();
61
+ for (const x of arr) {
62
+ const k = keyFn(x);
63
+ const list = m.get(k) || [];
64
+ list.push(x);
65
+ m.set(k, list);
66
+ }
67
+ return m;
68
+ }
69
+ function stableStringify(v) {
70
+ if (v === null || typeof v !== "object")
71
+ return JSON.stringify(v);
72
+ if (Array.isArray(v))
73
+ return "[" + v.map(stableStringify).join(",") + "]";
74
+ const keys = Object.keys(v).sort();
75
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(v[k])).join(",") + "}";
76
+ }
77
+ function getText(r) {
78
+ const p = r.payload || {};
79
+ return String(p.text ?? p.preview ?? p.args_preview ?? "");
80
+ }
81
+ function sessionKey(r) {
82
+ const p = r.payload || {};
83
+ return String(p.conversation_id ?? p.session_name ?? p.session ?? "default");
84
+ }
85
+ function mk(kind, severity, reason, evidence, payload) {
86
+ return {
87
+ kind,
88
+ severity,
89
+ reason,
90
+ evidence: evidence.slice(0, 5),
91
+ payload,
92
+ detectedAt: Date.now(),
93
+ };
94
+ }
95
+ // ─── Heuristics ────────────────────────────────────────────────────────────
96
+ function detectLoop(events) {
97
+ const prompts = events
98
+ .filter((e) => e.event === "browser.prompt" || e.event === "vscode.prompt_submit")
99
+ .slice(-10);
100
+ for (let i = 0; i < prompts.length; i++) {
101
+ let dupes = 0;
102
+ const matches = [prompts[i]];
103
+ const ti = tokens(getText(prompts[i]));
104
+ if (ti.size === 0)
105
+ continue;
106
+ for (let j = i + 1; j < prompts.length; j++) {
107
+ const tj = tokens(getText(prompts[j]));
108
+ // 0.6 Jaccard ≈ 60% token overlap. Lower than initially gut-felt
109
+ // because real prompt loops include "let me rephrase" wording shifts
110
+ // that drop overlap quickly. 0.85 missed the "fix login bug" /
111
+ // "login bug fix please" pattern; 0.6 catches it without falsing on
112
+ // genuinely different prompts that happen to share verbs.
113
+ if (jaccard(ti, tj) > 0.6 &&
114
+ Date.parse(prompts[j].ts) - Date.parse(prompts[i].ts) < 300_000) {
115
+ dupes++;
116
+ matches.push(prompts[j]);
117
+ }
118
+ }
119
+ if (dupes >= 2) {
120
+ const snippet = getText(prompts[i]).slice(0, 80);
121
+ return mk("loop", "warn", `Same prompt sent ${dupes + 1}× in 5 min`, matches, { repetitions: dupes + 1, snippet });
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+ function detectStuck(events, now) {
127
+ const calls = events.filter((e) => e.event === "browser.tool_call" || e.event === "vscode.tool_call");
128
+ // 5-minute window. The "3 identical calls in a row" pattern usually plays
129
+ // out over a few minutes — the user retries, waits, retries, gives up,
130
+ // retries again. 2 min was too tight for normal LLM-agent rhythms.
131
+ const recent = calls.filter((c) => now - Date.parse(c.ts) < 300_000);
132
+ const byKey = groupBy(recent, (c) => {
133
+ const p = c.payload || {};
134
+ return `${p.tool}:${stableStringify(p.args ?? p.args_preview ?? "")}`;
135
+ });
136
+ for (const group of byKey.values()) {
137
+ if (group.length >= 3) {
138
+ const tool = String(group[0].payload?.tool ?? "unknown");
139
+ return mk("stuck", "warn", `Tool ${tool} called ${group.length}× with identical args in 2 min`, group, { tool, count: group.length, args_preview: String(group[0].payload?.args_preview ?? "") });
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+ function detectContextBloat(events) {
145
+ const bySession = groupBy(events, sessionKey);
146
+ for (const [sid, group] of bySession) {
147
+ let tokensSum = 0;
148
+ let hasSave = false;
149
+ for (const e of group) {
150
+ const p = e.payload || {};
151
+ if (typeof p.tokens === "number")
152
+ tokensSum += p.tokens;
153
+ else if (typeof p.char_count === "number")
154
+ tokensSum += Math.ceil(p.char_count / 4);
155
+ if (e.event === "session.save")
156
+ hasSave = true;
157
+ }
158
+ if (tokensSum > 80_000 && !hasSave) {
159
+ return mk("context_bloat", "warn", `Session "${sid}" at ~${Math.round(tokensSum / 1000)}K tokens, no save_session yet`, group.slice(-3), { sessionId: sid, approxTokens: tokensSum });
160
+ }
161
+ }
162
+ return null;
163
+ }
164
+ function detectFabrication(events, cwd) {
165
+ const responses = events.filter((e) => e.event === "browser.response").slice(-5);
166
+ // Match file paths with a line number: "src/foo.ts:42", "lib/x.py:3-7", etc.
167
+ // Conservative: only flag paths with a recognizable code extension AND a line ref.
168
+ const re = /([A-Za-z0-9_\-./]+\.(?:ts|tsx|js|jsx|py|md|json|yml|yaml|go|rs|rb|java|cs|cpp|c|h)):\d+/g;
169
+ for (const r of responses) {
170
+ const text = getText(r);
171
+ const found = new Set();
172
+ for (const m of text.matchAll(re)) {
173
+ const p = m[1];
174
+ if (found.has(p))
175
+ continue;
176
+ found.add(p);
177
+ const abs = isAbsolute(p) ? p : join(cwd, p);
178
+ if (!existsSync(abs)) {
179
+ return mk("fabrication_suspect", "critical", `Assistant referenced non-existent file: ${p}`, [r], { citedPath: p, responseHash: r.hash });
180
+ }
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+ function detectDrift(events) {
186
+ // Per-session: if the last 3 prompts are jointly far (low overlap) from
187
+ // the session's FIRST prompt, the conversation has drifted off-topic.
188
+ const bySession = groupBy(events.filter((e) => e.event === "browser.prompt" || e.event === "vscode.prompt_submit"), sessionKey);
189
+ for (const [sid, prompts] of bySession) {
190
+ if (prompts.length < 4)
191
+ continue;
192
+ const first = tokens(getText(prompts[0]));
193
+ if (first.size === 0)
194
+ continue;
195
+ const last3 = prompts.slice(-3).map((p) => tokens(getText(p)));
196
+ const avgSim = last3.reduce((sum, t) => sum + jaccard(first, t), 0) / 3;
197
+ if (avgSim < 0.10) {
198
+ return mk("drift", "info", `Session "${sid}" has drifted from its opening prompt (similarity ${avgSim.toFixed(2)})`, prompts.slice(-3), { sessionId: sid, similarity: avgSim, firstPrompt: getText(prompts[0]).slice(0, 80) });
199
+ }
200
+ }
201
+ return null;
202
+ }
203
+ function detectNoInsight(events) {
204
+ const lastLearn = [...events].reverse().find((e) => e.event === "learning.save");
205
+ const since = lastLearn ? Date.parse(lastLearn.ts) : 0;
206
+ const toolCalls = events.filter((e) => (e.event === "browser.tool_call" ||
207
+ e.event === "vscode.tool_call") &&
208
+ Date.parse(e.ts) > since);
209
+ if (toolCalls.length >= 30) {
210
+ return mk("no_insight", "info", `${toolCalls.length} tool calls since the last save_learning`, toolCalls.slice(-3), { toolCallCount: toolCalls.length, lastLearningAt: since || null });
211
+ }
212
+ return null;
213
+ }
214
+ function detectSilentFailure(events, now) {
215
+ const errs = events.filter((e) => {
216
+ if (e.event !== "browser.tool_call" && e.event !== "vscode.tool_call")
217
+ return false;
218
+ const p = e.payload || {};
219
+ return Boolean(p.error || p.failed || p.status === "error");
220
+ });
221
+ const recent = errs.filter((e) => now - Date.parse(e.ts) < 300_000);
222
+ const byTool = groupBy(recent, (e) => String(e.payload?.tool ?? "unknown"));
223
+ for (const [tool, group] of byTool) {
224
+ if (group.length >= 3) {
225
+ const snippet = String((group[0].payload?.error || group[0].payload?.message || "")).slice(0, 200);
226
+ return mk("silent_failure", "critical", `Tool ${tool} failed ${group.length}× in 5 min`, group, { tool, errorSnippet: snippet, count: group.length });
227
+ }
228
+ }
229
+ return null;
230
+ }
231
+ function detectStaleDocSignal(_events) {
232
+ // Stub: full implementation requires reading .contextengine/policy.json
233
+ // and matching staged edits to doc_coverage rules. Defer to Phase 3.1
234
+ // when the policy module exposes a helper. For now, return null so the
235
+ // heuristic is wired but inert (keeps the union complete + lets tests
236
+ // assert "no signal" against fixtures that don't trigger it).
237
+ return null;
238
+ }
239
+ // ─── Runner ────────────────────────────────────────────────────────────────
240
+ export function runHeuristics(events, opts = {}) {
241
+ const now = opts.now ?? Date.now();
242
+ const cwd = opts.cwd ?? process.cwd();
243
+ const signals = [];
244
+ const push = (s) => { if (s)
245
+ signals.push(s); };
246
+ push(detectLoop(events));
247
+ push(detectStuck(events, now));
248
+ push(detectContextBloat(events));
249
+ push(detectFabrication(events, cwd));
250
+ push(detectDrift(events));
251
+ push(detectNoInsight(events));
252
+ push(detectSilentFailure(events, now));
253
+ push(detectStaleDocSignal(events));
254
+ return signals;
255
+ }
256
+ /** Convenience for callers: scan recent events and run heuristics in one call. */
257
+ export function detect(opts = {}) {
258
+ const now = opts.now ?? Date.now();
259
+ const events = opts.events ?? scanRecentEvents(opts.windowSeconds ?? 300, now);
260
+ return runHeuristics(events, { now, cwd: opts.cwd });
261
+ }
262
+ // ─── Live watcher (CLI + MCP) ──────────────────────────────────────────────
263
+ /**
264
+ * Watch the audit log and fire `onAlert` for each new signal. Dedupe key is
265
+ * `kind:reason` kept in an in-memory LRU bounded at 100 entries — prevents
266
+ * the same drift from firing every poll cycle.
267
+ *
268
+ * Returns a dispose function. Caller is responsible for handling SIGINT
269
+ * cleanly.
270
+ */
271
+ export function watchAuditLog(onAlert, opts = {}) {
272
+ const auditPath = join(process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine"), "audit.log");
273
+ const seen = new Set();
274
+ const seenOrder = [];
275
+ const SEEN_CAP = 100;
276
+ function maybeFire(signal) {
277
+ const key = `${signal.kind}:${signal.reason}`;
278
+ if (seen.has(key))
279
+ return;
280
+ seen.add(key);
281
+ seenOrder.push(key);
282
+ if (seenOrder.length > SEEN_CAP) {
283
+ const evict = seenOrder.shift();
284
+ if (evict)
285
+ seen.delete(evict);
286
+ }
287
+ onAlert(signal);
288
+ if (opts.emitAuditEvent !== false) {
289
+ safeAppend("drift.detected", {
290
+ kind: signal.kind,
291
+ severity: signal.severity,
292
+ reason: signal.reason,
293
+ evidence_count: signal.evidence.length,
294
+ ...signal.payload,
295
+ });
296
+ }
297
+ }
298
+ function tick() {
299
+ const events = scanRecentEvents(opts.windowSeconds ?? 300);
300
+ const signals = runHeuristics(events);
301
+ for (const s of signals)
302
+ maybeFire(s);
303
+ }
304
+ // Initial scan.
305
+ tick();
306
+ let debounceTimer = null;
307
+ const debounce = opts.debounceMs ?? 250;
308
+ let watcher = null;
309
+ try {
310
+ if (existsSync(auditPath)) {
311
+ watcher = watch(auditPath, { persistent: false }, () => {
312
+ if (debounceTimer)
313
+ clearTimeout(debounceTimer);
314
+ debounceTimer = setTimeout(tick, debounce);
315
+ });
316
+ }
317
+ }
318
+ catch {
319
+ /* fs.watch unsupported on some platforms — fall back to polling below */
320
+ }
321
+ // Poll-based fallback (in case watch doesn't fire, e.g., remote FS).
322
+ const pollTimer = setInterval(tick, 5_000);
323
+ return () => {
324
+ if (watcher)
325
+ watcher.close();
326
+ clearInterval(pollTimer);
327
+ if (debounceTimer)
328
+ clearTimeout(debounceTimer);
329
+ };
330
+ }
331
+ // Test-only — clear the dedupe LRU between fixture runs.
332
+ export const _internal = {
333
+ detectLoop, detectStuck, detectContextBloat, detectFabrication,
334
+ detectDrift, detectNoInsight, detectSilentFailure, detectStaleDocSignal,
335
+ };
336
+ //# sourceMappingURL=detector.js.map
@@ -0,0 +1,30 @@
1
+ interface IncomingEvent {
2
+ v?: number;
3
+ ts?: string;
4
+ event?: string;
5
+ actor?: string;
6
+ payload?: Record<string, unknown>;
7
+ }
8
+ /** Hot-reload the secret from disk so the CLI can rotate without restarting MCP. */
9
+ declare function loadSecret(): string | null;
10
+ declare function constantTimeEqual(a: string, b: string): boolean;
11
+ /** Validate an event has the minimum shape we'll write to audit. */
12
+ declare function validateEvent(e: IncomingEvent, idx: number): string | null;
13
+ /**
14
+ * Boot the local event-ingest HTTP server. Returns the listening port or null
15
+ * if the port was already in use (caller may decide whether to retry on
16
+ * another port or surface the error).
17
+ *
18
+ * Safe to call multiple times — second call returns the existing server.
19
+ */
20
+ export declare function startEventIngestServer(): Promise<number | null>;
21
+ export declare function stopEventIngestServer(): Promise<void>;
22
+ export declare const _internal: {
23
+ loadSecret: typeof loadSecret;
24
+ constantTimeEqual: typeof constantTimeEqual;
25
+ validateEvent: typeof validateEvent;
26
+ SECRET_FILE: string;
27
+ PORT: number;
28
+ };
29
+ export {};
30
+ //# sourceMappingURL=http-server.d.ts.map
@@ -0,0 +1,242 @@
1
+ // 🔒 LOCKED [HTTP-EVENT-INGEST] — 2026-06-23
2
+ // ⛔ NEVER bind to 0.0.0.0 — only 127.0.0.1. The threat model is "browser
3
+ // extension running on the same machine"; a network-reachable port would
4
+ // let any device on the LAN inject audit events.
5
+ // ⛔ NEVER compare the secret with `===` — use timingSafeEqual. String compare
6
+ // leaks timing info that lets a remote attacker brute-force the secret
7
+ // one byte at a time.
8
+ // ⛔ NEVER auto-generate the secret on first request. The CLI must create it
9
+ // explicitly (so a stray client can't bootstrap itself into the audit log).
10
+ // Refuse with 401 if ~/.contextengine/extension-secret is missing.
11
+ // ⛔ NEVER write events before validating shape — a malformed event in the
12
+ // audit log corrupts the chain verifier and ruins compliance evidence.
13
+ // WHY: This is the only network surface OpsContext exposes locally. Every
14
+ // decision here is about keeping it auth-required, scope-bound, and shape-
15
+ // validated, because the audit log is the foundation everything else
16
+ // builds on.
17
+ // FIX: To add more endpoints, follow the same auth + validation pattern. Do
18
+ // not add a /raw-write or /admin route without a separate secret + a
19
+ // separate LOCK comment explaining why.
20
+ import * as http from "http";
21
+ import { existsSync, readFileSync } from "fs";
22
+ import { join } from "path";
23
+ import { homedir } from "os";
24
+ import { timingSafeEqual } from "crypto";
25
+ import { safeAppend } from "./audit.js";
26
+ const PORT = parseInt(process.env.OPSCONTEXT_EVENT_PORT || "7842", 10);
27
+ const HOST = "127.0.0.1";
28
+ const SECRET_FILE = join(homedir(), ".contextengine", "extension-secret");
29
+ const MAX_BODY = 64 * 1024; // 64 KB per batch
30
+ const MAX_BATCH = 50;
31
+ let serverInstance = null;
32
+ /** Hot-reload the secret from disk so the CLI can rotate without restarting MCP. */
33
+ function loadSecret() {
34
+ try {
35
+ if (!existsSync(SECRET_FILE))
36
+ return null;
37
+ return readFileSync(SECRET_FILE, "utf-8").trim();
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ function constantTimeEqual(a, b) {
44
+ // timingSafeEqual requires equal-length buffers — short-circuit on mismatch
45
+ // length but only via Buffer.byteLength so we don't leak via string-length
46
+ // comparison early-exit. Acceptable because length isn't a secret.
47
+ if (a.length !== b.length)
48
+ return false;
49
+ try {
50
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ /** Validate an event has the minimum shape we'll write to audit. */
57
+ function validateEvent(e, idx) {
58
+ if (typeof e !== "object" || e === null)
59
+ return `events[${idx}]: not an object`;
60
+ if (e.v !== 1)
61
+ return `events[${idx}]: missing or unsupported version field (v=${e.v})`;
62
+ if (typeof e.event !== "string" || !e.event)
63
+ return `events[${idx}]: missing event kind`;
64
+ if (typeof e.ts !== "string" || !e.ts)
65
+ return `events[${idx}]: missing ts`;
66
+ if (typeof e.payload !== "object" || e.payload === null)
67
+ return `events[${idx}]: missing payload object`;
68
+ // Restrict event kinds to the browser.* + vscode.* + cli.* namespaces.
69
+ // The audit module's own writers use other kinds (learning.save etc.);
70
+ // those events come from the LOCAL server, not the network surface.
71
+ if (!/^(browser|vscode|cli)\./.test(e.event)) {
72
+ return `events[${idx}]: event kind '${e.event}' not allowed via HTTP (only browser.*/vscode.*/cli.*)`;
73
+ }
74
+ return null;
75
+ }
76
+ function sendJson(res, status, body) {
77
+ const json = JSON.stringify(body);
78
+ res.writeHead(status, {
79
+ "Content-Type": "application/json",
80
+ "Content-Length": Buffer.byteLength(json),
81
+ // Belt-and-braces: even though the manifest's host_permissions already
82
+ // lets the SW POST without preflight, we set the response header so
83
+ // future popup-side probe pings work too.
84
+ "Access-Control-Allow-Origin": "*",
85
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
86
+ "Access-Control-Allow-Headers": "Content-Type, X-OpsContext-Secret",
87
+ });
88
+ res.end(json);
89
+ }
90
+ function handleEvents(req, res) {
91
+ const secret = loadSecret();
92
+ if (!secret) {
93
+ sendJson(res, 401, {
94
+ ok: false,
95
+ error: "no_secret_configured",
96
+ hint: "Run: contextengine init-extension-secret",
97
+ });
98
+ return;
99
+ }
100
+ const provided = req.headers["x-opscontext-secret"];
101
+ if (typeof provided !== "string" || !constantTimeEqual(provided, secret)) {
102
+ sendJson(res, 401, { ok: false, error: "bad_secret" });
103
+ return;
104
+ }
105
+ let bytes = 0;
106
+ const chunks = [];
107
+ req.on("data", (chunk) => {
108
+ bytes += chunk.length;
109
+ if (bytes > MAX_BODY) {
110
+ req.destroy();
111
+ sendJson(res, 413, { ok: false, error: "payload_too_large", limit: MAX_BODY });
112
+ return;
113
+ }
114
+ chunks.push(chunk);
115
+ });
116
+ req.on("end", () => {
117
+ let batch;
118
+ try {
119
+ batch = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
120
+ }
121
+ catch {
122
+ sendJson(res, 400, { ok: false, error: "bad_json" });
123
+ return;
124
+ }
125
+ if (!batch || !Array.isArray(batch.events)) {
126
+ sendJson(res, 400, { ok: false, error: "missing_events_array" });
127
+ return;
128
+ }
129
+ if (batch.events.length > MAX_BATCH) {
130
+ sendJson(res, 400, { ok: false, error: "batch_too_large", limit: MAX_BATCH });
131
+ return;
132
+ }
133
+ // Validate every event BEFORE writing any of them.
134
+ for (let i = 0; i < batch.events.length; i++) {
135
+ const err = validateEvent(batch.events[i], i);
136
+ if (err) {
137
+ sendJson(res, 400, { ok: false, error: "invalid_event", detail: err });
138
+ return;
139
+ }
140
+ }
141
+ // All valid — write them to audit log via safeAppend.
142
+ let written = 0;
143
+ for (const ev of batch.events) {
144
+ const actor = typeof ev.actor === "string" ? ev.actor : "browser-ext";
145
+ // event/payload were validated above — cast is safe.
146
+ safeAppend(ev.event, ev.payload, actor);
147
+ written++;
148
+ }
149
+ sendJson(res, 200, { ok: true, written });
150
+ });
151
+ }
152
+ function handleHealth(_req, res) {
153
+ sendJson(res, 200, {
154
+ ok: true,
155
+ service: "opscontext-event-ingest",
156
+ port: PORT,
157
+ secretConfigured: loadSecret() !== null,
158
+ });
159
+ }
160
+ function handleOptions(_req, res) {
161
+ // CORS preflight — the SW shouldn't need this thanks to host_permissions,
162
+ // but answering it cleanly costs nothing and helps popup probes.
163
+ res.writeHead(204, {
164
+ "Access-Control-Allow-Origin": "*",
165
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
166
+ "Access-Control-Allow-Headers": "Content-Type, X-OpsContext-Secret",
167
+ "Access-Control-Max-Age": "600",
168
+ });
169
+ res.end();
170
+ }
171
+ /**
172
+ * Boot the local event-ingest HTTP server. Returns the listening port or null
173
+ * if the port was already in use (caller may decide whether to retry on
174
+ * another port or surface the error).
175
+ *
176
+ * Safe to call multiple times — second call returns the existing server.
177
+ */
178
+ export function startEventIngestServer() {
179
+ if (serverInstance) {
180
+ const addr = serverInstance.address();
181
+ return Promise.resolve(typeof addr === "object" && addr ? addr.port : PORT);
182
+ }
183
+ return new Promise((resolve) => {
184
+ const srv = http.createServer((req, res) => {
185
+ try {
186
+ if (req.method === "OPTIONS")
187
+ return handleOptions(req, res);
188
+ const url = req.url || "/";
189
+ if (req.method === "POST" && url.startsWith("/events"))
190
+ return handleEvents(req, res);
191
+ if (req.method === "GET" && url.startsWith("/health"))
192
+ return handleHealth(req, res);
193
+ sendJson(res, 404, { ok: false, error: "not_found" });
194
+ }
195
+ catch (err) {
196
+ console.error("[ContextEngine] event-ingest error:", err);
197
+ try {
198
+ sendJson(res, 500, { ok: false, error: "internal" });
199
+ }
200
+ catch {
201
+ /* ignore — response may already be closed */
202
+ }
203
+ }
204
+ });
205
+ srv.on("error", (err) => {
206
+ if (err.code === "EADDRINUSE") {
207
+ console.error(`[ContextEngine] ⚠ port ${PORT} already in use — browser-event ingest disabled.\n` +
208
+ ` Set OPSCONTEXT_EVENT_PORT=<n> to use a different port (must also update extension options).`);
209
+ resolve(null);
210
+ return;
211
+ }
212
+ console.error("[ContextEngine] event-ingest server error:", err);
213
+ resolve(null);
214
+ });
215
+ srv.listen(PORT, HOST, () => {
216
+ serverInstance = srv;
217
+ console.error(`[ContextEngine] 🌐 event-ingest on http://${HOST}:${PORT} ` +
218
+ (loadSecret() ? "(secret loaded)" : "(NO SECRET — run `contextengine init-extension-secret`)"));
219
+ resolve(PORT);
220
+ });
221
+ });
222
+ }
223
+ export function stopEventIngestServer() {
224
+ return new Promise((resolve) => {
225
+ if (!serverInstance)
226
+ return resolve();
227
+ serverInstance.close(() => {
228
+ serverInstance = null;
229
+ resolve();
230
+ });
231
+ });
232
+ }
233
+ // Test helpers (not exported in dist surface in production use — but the
234
+ // module is small enough that tests can import them directly).
235
+ export const _internal = {
236
+ loadSecret,
237
+ constantTimeEqual,
238
+ validateEvent,
239
+ SECRET_FILE,
240
+ PORT,
241
+ };
242
+ //# sourceMappingURL=http-server.js.map
package/dist/index.js CHANGED
@@ -11,6 +11,8 @@ import { loadCache, saveCache } from "./cache.js";
11
11
  import { listProjects, checkPorts, runComplianceAudit, formatProjectList, formatPortMap, formatPlan, scoreProject, formatScoreReport, } from "./agents.js";
12
12
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
13
13
  import { verifyChain, readAuditLog, filterByRange } from "./audit.js";
14
+ import { startEventIngestServer } from "./http-server.js";
15
+ import { detect } from "./detector.js";
14
16
  import { saveLearning, searchLearnings, listLearnings, deleteLearning, learningsToChunks, learningsStats, formatLearnings, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
15
17
  import { readFileSync, existsSync, watch, statSync } from "fs";
16
18
  import { basename, join, dirname } from "path";
@@ -580,6 +582,39 @@ server.tool("audit_verify", "Verify the integrity of the local audit log chain.
580
582
  return respond("audit_verify", summary.join("\n"));
581
583
  });
582
584
  // ---------------------------------------------------------------------------
585
+ // Tool: drift_status (Detector — read current drift signals)
586
+ // ---------------------------------------------------------------------------
587
+ // Agents should call this between major task phases. If any 'critical' signal
588
+ // is active, they should pause and surface to the human. The signals are also
589
+ // appended to the audit log as drift.detected events so post-hoc review can
590
+ // reconstruct what fired and when.
591
+ server.tool("drift_status", "Returns active drift / loop / stuck-tool / fabrication / silent-failure signals detected over the recent audit-log window. Use to self-check before starting a major task phase. If any 'critical' signal is active (fabrication_suspect or silent_failure), pause and surface to the human.", {
592
+ windowSeconds: z.number().optional().describe("Look-back window in seconds. Default 300 (5 min)."),
593
+ minSeverity: z.enum(["info", "warn", "critical"]).optional().describe("Floor filter for severity. Default 'info' (everything)."),
594
+ }, async ({ windowSeconds, minSeverity }) => {
595
+ const signals = detect({ windowSeconds: windowSeconds ?? 300 });
596
+ const order = { info: 0, warn: 1, critical: 2 };
597
+ const floor = order[minSeverity ?? "info"];
598
+ const filtered = signals.filter((s) => order[s.severity] >= floor);
599
+ const lines = [];
600
+ lines.push(`Drift signals: ${filtered.length} active (window=${windowSeconds ?? 300}s, minSeverity=${minSeverity ?? "info"}).`);
601
+ if (filtered.length === 0) {
602
+ lines.push("All clear.");
603
+ }
604
+ else {
605
+ for (const s of filtered) {
606
+ const sev = s.severity.toUpperCase();
607
+ lines.push(` [${sev}] ${s.kind}: ${s.reason}`);
608
+ }
609
+ const critical = filtered.filter((s) => s.severity === "critical");
610
+ if (critical.length > 0) {
611
+ lines.push("");
612
+ lines.push(`⛔ ${critical.length} CRITICAL signal(s) — pause the task and surface to the human.`);
613
+ }
614
+ }
615
+ return respond("drift_status", lines.join("\n"));
616
+ });
617
+ // ---------------------------------------------------------------------------
583
618
  // Tool: end_session (End-of-Session Protocol Enforcer)
584
619
  // ---------------------------------------------------------------------------
585
620
  server.tool("end_session", "MUST be called before ending any coding session. Checks all project repos for uncommitted changes, verifies documentation freshness (copilot-instructions.md, SKILLS.md, session docs), and returns a checklist of required actions. Will report PASS/FAIL for each check. The AI agent should resolve all FAIL items before ending.", {}, async () => {
@@ -1073,6 +1108,15 @@ async function main() {
1073
1108
  }
1074
1109
  // 5. Start file watchers
1075
1110
  startWatching();
1111
+ // 6. Boot the local HTTP event-ingest endpoint for the browser extension.
1112
+ // Local 127.0.0.1:7842 only; auth via shared secret at
1113
+ // ~/.contextengine/extension-secret (see init-extension-secret CLI).
1114
+ // No-op if secret is missing; the endpoint will refuse with 401 until
1115
+ // a secret is configured. Failure to bind (port collision) logs and
1116
+ // continues — the MCP server stays usable without browser capture.
1117
+ startEventIngestServer().catch((err) => {
1118
+ console.error("[ContextEngine] event-ingest start failed:", err);
1119
+ });
1076
1120
  }
1077
1121
  main().catch((err) => {
1078
1122
  console.error("[ContextEngine] Fatal:", err);
@@ -0,0 +1,4 @@
1
+ export declare function cliInstallAutostart(args: string[]): Promise<void>;
2
+ export declare function cliUninstallAutostart(args: string[]): Promise<void>;
3
+ export declare function cliAutostartStatus(args: string[]): Promise<void>;
4
+ //# sourceMappingURL=install-autostart.d.ts.map