context-mode 1.0.154 → 1.0.156

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,209 @@
1
+ // hooks/platform-bridge.mjs — Fire-and-forget event forwarder.
2
+ // Reads ~/.context-mode/platform.json {api_key, platform_url}.
3
+ // POSTs every event to ${platform_url}/events.
4
+ // Privacy: redacts secrets + normalizes $HOME before send.
5
+ // Backward-compat (PRD §5.3 Upgrade Lag): v1 events_url → platform_url; token → api_key.
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import os from "node:os";
10
+
11
+ const CACHE_TTL_MS = 60_000;
12
+ const FETCH_TIMEOUT_MS = 2_000;
13
+ const MAX_FIELD_LEN = 200;
14
+ const MAX_DEPTH = 4;
15
+
16
+ // Negative-cache sentinel — distinguishes "uninitialized" (null) from
17
+ // "we checked recently and there's no config" (NO_CONFIG). Without this,
18
+ // the unconfigured-user path would hit fs.readFileSync on every event.
19
+ const NO_CONFIG = Symbol("no-config");
20
+
21
+ let _cache = null;
22
+ let _cacheLoadedAt = 0;
23
+ let _warned = false; // dedupe stderr — log first failure only, reset on success
24
+ let _fsLoads = 0; // test-only counter: how many times readConfig hit the FS
25
+
26
+ // === Cross-platform config path (bug-free across Win/Linux/Mac/WSL) ===
27
+ export function configPath() {
28
+ if (process.platform === "win32") {
29
+ const appdata = process.env.APPDATA;
30
+ if (appdata) return path.join(appdata, "context-mode", "platform.json");
31
+ // Fallback if APPDATA unset (rare — Git Bash without env)
32
+ return path.join(os.homedir(), "AppData", "Roaming", "context-mode", "platform.json");
33
+ }
34
+ if (process.env.XDG_CONFIG_HOME) {
35
+ return path.join(process.env.XDG_CONFIG_HOME, "context-mode", "platform.json");
36
+ }
37
+ return path.join(os.homedir(), ".context-mode", "platform.json");
38
+ }
39
+
40
+ function warn(msg) {
41
+ if (_warned) return;
42
+ _warned = true;
43
+ process.stderr.write(`[context-mode] ${msg}\n`);
44
+ }
45
+
46
+ // v1 events_url + token alias kept for Upgrade Lag (PRD §5.3).
47
+ // Stray fields (version, hostname, device_id, endpoints.sessions, routes) silently ignored.
48
+ function normalizeConfig(raw) {
49
+ if (!raw || typeof raw !== "object") return null;
50
+ const api_key = raw.api_key ?? raw.token;
51
+ let platform_url = raw.platform_url;
52
+ if (!platform_url && raw.endpoints?.events) platform_url = String(raw.endpoints.events).replace(/\/events$/, "");
53
+ if (!platform_url && raw.events_url) platform_url = String(raw.events_url).replace(/\/events$/, "");
54
+ if (typeof api_key !== "string" || !api_key.startsWith("ctxm_")) return null;
55
+ if (typeof platform_url !== "string" || !platform_url) return null;
56
+ return { api_key, platform_url: platform_url.replace(/\/$/, "") };
57
+ }
58
+
59
+ function readConfig() {
60
+ const now = Date.now();
61
+ // Cache hit — covers BOTH positive (config object) and negative (NO_CONFIG) results.
62
+ if (_cache !== null && now - _cacheLoadedAt < CACHE_TTL_MS) {
63
+ return _cache === NO_CONFIG ? null : _cache;
64
+ }
65
+ _cacheLoadedAt = now;
66
+ _fsLoads++;
67
+ const cfgPath = configPath();
68
+
69
+ let raw;
70
+ try {
71
+ raw = fs.readFileSync(cfgPath, "utf8");
72
+ } catch (e) {
73
+ if (e.code !== "ENOENT") warn(`cannot read ${cfgPath}: ${e.code || e.message}`);
74
+ _cache = NO_CONFIG;
75
+ return null;
76
+ }
77
+
78
+ let parsed;
79
+ try {
80
+ parsed = JSON.parse(raw);
81
+ } catch (e) {
82
+ warn(`${cfgPath} is not valid JSON: ${e.message}`);
83
+ _cache = NO_CONFIG;
84
+ return null;
85
+ }
86
+
87
+ const normalized = normalizeConfig(parsed);
88
+ if (!normalized) {
89
+ warn(`${cfgPath} schema invalid — expected {api_key (ctxm_*), platform_url}`);
90
+ _cache = NO_CONFIG;
91
+ return null;
92
+ }
93
+
94
+ _warned = false; // success — re-arm warning for future failures
95
+ _cache = normalized;
96
+ return _cache;
97
+ }
98
+
99
+ // === Gate — cheap boolean for callers that want to skip allocations entirely ===
100
+ // `session-loaders.mjs` uses this BEFORE the per-event forwarding loop so the
101
+ // loop never executes when ~/.context-mode/platform.json is missing. Honors
102
+ // the same 60s TTL as readConfig() — first call hits the FS, subsequent calls
103
+ // within the TTL return the cached decision (positive or negative).
104
+ export function hasPlatformConfig() {
105
+ return readConfig() !== null;
106
+ }
107
+
108
+ // === URL construction (single endpoint per ADR-0006) ===
109
+ export function buildUrl(cfg, _eventType) {
110
+ return `${cfg.platform_url}/events`;
111
+ }
112
+
113
+ // === Privacy: secret + PII redaction ===
114
+ const SECRETS = [
115
+ /\b(?:ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{20,}\b/g, // GitHub
116
+ /\bAKIA[0-9A-Z]{16}\b/g, // AWS
117
+ /\bsk-(?:ant|proj)?-?[A-Za-z0-9_-]{32,}\b/g, // OpenAI/Anthropic
118
+ /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, // JWT
119
+ /\bxox[bpoas]-[A-Za-z0-9-]{10,}\b/g, // Slack
120
+ /\bglpat-[A-Za-z0-9_-]{20,}\b/g, // GitLab
121
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, // emails
122
+ /\b\d{3}-\d{2}-\d{4}\b/g, // SSN-like
123
+ ];
124
+ const HOME = os.homedir();
125
+ const USER_DIR_RE = /(\/Users\/|\/home\/|\\+Users\\+)[^/\\]+/g;
126
+
127
+ function privacyTransform(s) {
128
+ if (typeof s !== "string") return s;
129
+ let out = s.split(HOME).join("<HOME>")
130
+ .replace(USER_DIR_RE, (m) => m.replace(/[^/\\]+$/, "<USER>"));
131
+ for (const re of SECRETS) out = out.replace(re, "[REDACTED]");
132
+ return out;
133
+ }
134
+
135
+ function walk(obj, depth) {
136
+ if (depth > MAX_DEPTH) return "[depth-limited]";
137
+ if (obj == null) return obj;
138
+ if (typeof obj === "string") {
139
+ const cleaned = privacyTransform(obj);
140
+ return cleaned.length > MAX_FIELD_LEN ? cleaned.slice(0, MAX_FIELD_LEN) + "…[truncated]" : cleaned;
141
+ }
142
+ if (Array.isArray(obj)) return obj.slice(0, 50).map((x) => walk(x, depth + 1));
143
+ if (typeof obj === "object") {
144
+ const out = {};
145
+ for (const [k, v] of Object.entries(obj)) out[k] = walk(v, depth + 1);
146
+ return out;
147
+ }
148
+ return obj;
149
+ }
150
+
151
+ export function sanitizeEvent(event) {
152
+ return event && typeof event === "object" ? walk(event, 0) : event;
153
+ }
154
+
155
+ // === Public API ===
156
+ export async function maybeForward(event, platform, opts = {}) {
157
+ const cfg = readConfig();
158
+ if (!cfg) return;
159
+
160
+ const ev = sanitizeEvent(event);
161
+ const ctrl = new AbortController();
162
+ const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
163
+
164
+ try {
165
+ const res = await fetch(buildUrl(cfg, ev.type), {
166
+ method: "POST",
167
+ headers: {
168
+ "Authorization": `Bearer ${cfg.api_key}`,
169
+ "Content-Type": "application/json",
170
+ "X-Source-Platform": platform,
171
+ "X-Schema-Version": "2",
172
+ },
173
+ // Canonical envelope (PRD §5.4 stability ABI):
174
+ // - All event fields passthrough — server-side Zod picks per event.type
175
+ // - `platform` envelope metadata (claude-code, cursor, ...)
176
+ // - `ts` defaulted from event or wall clock
177
+ // Hand-mapping individual fields here is the anti-pattern: every new
178
+ // event field forced a bridge release. With this envelope, new fields
179
+ // ride the existing pipe and the platform schema is the only thing
180
+ // that ever needs to learn them.
181
+ body: JSON.stringify({
182
+ ...ev,
183
+ platform,
184
+ ts: ev.ts ?? opts.ts ?? Math.floor(Date.now() / 1000),
185
+ }),
186
+ signal: ctrl.signal,
187
+ });
188
+ if (res.status === 401) { _cache = null; _cacheLoadedAt = 0; }
189
+ else if (res.status === 429) {
190
+ process.stderr.write(`[context-mode-platform] rate limited (retry after ${res.headers.get("Retry-After")}s)\n`);
191
+ }
192
+ return { ok: res.ok, status: res.status };
193
+ } catch (e) {
194
+ return { ok: false, status: 0, error: e.message };
195
+ } finally {
196
+ clearTimeout(t);
197
+ }
198
+ }
199
+
200
+ export const _internal = {
201
+ readConfig,
202
+ normalizeConfig,
203
+ buildUrl,
204
+ sanitizeEvent,
205
+ privacyTransform,
206
+ configPath,
207
+ resetState: () => { _cache = null; _cacheLoadedAt = 0; _warned = false; _fsLoads = 0; },
208
+ get fsLoads() { return _fsLoads; },
209
+ };
@@ -10,6 +10,9 @@ import { join } from "node:path";
10
10
  import { pathToFileURL } from "node:url";
11
11
  import { existsSync } from "node:fs";
12
12
 
13
+ import { hasPlatformConfig, maybeForward } from "./platform-bridge.mjs";
14
+ import { detectPlatformFromEnv } from "./core/platform-detect.mjs";
15
+
13
16
  export function createSessionLoaders(hookDir) {
14
17
  // Auto-detect bundle directory: bundles live in hooks/ root, not platform subdirs.
15
18
  // If hookDir itself has bundles, use it; otherwise go up one level.
@@ -96,5 +99,30 @@ export function attributeAndInsertEvents(db, sessionId, events, input, projectDi
96
99
  db.insertEvent(sessionId, events[i], hookName, attributions[i], bytesList?.[i]);
97
100
  }
98
101
  }
102
+
103
+ // PRD-context-as-a-service §5.2 — Forwarder injection.
104
+ // Gated: the per-event loop never runs when ~/.context-mode/platform.json
105
+ // is missing. hasPlatformConfig() is a single cached probe (60s TTL), so
106
+ // the unconfigured-user path costs at most one syscall per minute.
107
+ if (hasPlatformConfig()) {
108
+ const platform = detectPlatformFromEnv();
109
+ for (let i = 0; i < events.length; i++) {
110
+ const attr = attributions[i];
111
+ maybeForward(
112
+ {
113
+ ...events[i],
114
+ ...attr,
115
+ session_id: sessionId,
116
+ // Canonical alias — server reads `project` (snake-case shape on the wire);
117
+ // attribution-side stores `projectDir` (camelCase TS interface). Surfacing
118
+ // both keeps the wire shape stable without forcing the attribution module
119
+ // to change its public type.
120
+ project: attr?.projectDir,
121
+ },
122
+ platform,
123
+ );
124
+ }
125
+ }
126
+
99
127
  return attributions;
100
128
  }
@@ -3,7 +3,7 @@
3
3
  "name": "Context Mode",
4
4
  "kind": "tool",
5
5
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
6
- "version": "1.0.154",
6
+ "version": "1.0.156",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.154",
3
+ "version": "1.0.156",
4
4
  "type": "module",
5
5
  "description": "MCP plugin that saves 98% of your context window. Works with Claude Code, Gemini CLI, VS Code Copilot, OpenCode, and Codex CLI. Sandboxed code execution, FTS5 knowledge base, and intent-driven search.",
6
6
  "author": "Mert Koseoğlu",