context-mode 1.0.158 → 1.0.159

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.
@@ -6,9 +6,10 @@
6
6
  * Fallback: if bundles are missing (marketplace installs), try build/session/*.js.
7
7
  */
8
8
 
9
- import { join } from "node:path";
9
+ import { join, resolve as resolvePath } from "node:path";
10
10
  import { pathToFileURL } from "node:url";
11
- import { existsSync } from "node:fs";
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
12
13
 
13
14
  import { hasPlatformConfig, maybeForward } from "./platform-bridge.mjs";
14
15
  import { detectPlatformFromEnv } from "./core/platform-detect.mjs";
@@ -115,9 +116,25 @@ export function attributeAndInsertEvents(db, sessionId, events, input, projectDi
115
116
  ? db.getSessionRollup(sessionId)
116
117
  : null;
117
118
 
119
+ // v1.0.159: Bash metadata shared across all events from this hook fire.
120
+ // A single Bash tool call may emit multiple canonical events (a `git
121
+ // pull` produces type=git AND type=cwd) — they all share the same
122
+ // command_type / command_tool / exit_code / duration_bucket. Hook
123
+ // metadata (latency, exit_code) is also per-call, not per-event.
124
+ const bashMeta = deriveBashMetadata(input);
125
+ // v1.0.159: latency_ms read from the PreToolUse timestamp stamp.
126
+ // PreToolUse writes ms-precision Date.now() to a tmp file, PostToolUse
127
+ // reads + computes delta + cleans up. Failure → undefined (no field
128
+ // surfaces on the wire; Zod is optional).
129
+ const latencyMs = readLatencyMs(sessionId, input?.tool_name);
130
+
118
131
  for (let i = 0; i < events.length; i++) {
119
132
  const enriched = enrichEventForPlatform(events[i], attributions[i]);
120
- const payload = rollup ? { ...enriched, ...rollup } : enriched;
133
+ const withBash = bashMeta ? { ...enriched, ...bashMeta } : enriched;
134
+ const withLatency = latencyMs !== undefined
135
+ ? { ...withBash, latency_ms: latencyMs, duration_bucket: bucketizeDuration(latencyMs) }
136
+ : withBash;
137
+ const payload = rollup ? { ...withLatency, ...rollup } : withLatency;
121
138
  maybeForward({ ...payload, session_id: sessionId }, platform);
122
139
  }
123
140
  }
@@ -161,6 +178,15 @@ function enrichEventForPlatform(event, attribution) {
161
178
  enriched.error_tool = cls.error_tool;
162
179
  }
163
180
 
181
+ // blocker_status: derive from the canonical event TYPE, not lexical
182
+ // pattern-matching on prose. session-extract already identifies blocker
183
+ // states semantically (type='blocker' when the agent signals stuck;
184
+ // type='blocker_resolved' on recovery). Regex on error_message would
185
+ // false-positive on the millions of error texts in the wild — we let
186
+ // the extractor's structural judgment be the source of truth.
187
+ if (event?.type === "blocker") enriched.blocker_status = "open";
188
+ else if (event?.type === "blocker_resolved") enriched.blocker_status = "resolved";
189
+
164
190
  // Git events: surface commit message + mark has_commit at the event level
165
191
  // (rollup-level has_commit comes from the session-wide stamp; both win
166
192
  // when set — `{...enriched, ...rollup}` order keeps rollup authoritative
@@ -205,3 +231,192 @@ function classifyError(message) {
205
231
  if (/test failed|fail |tests failed|assertion/.test(m)) return { error_category: "test_failed", error_tool: "Bash" };
206
232
  return { error_category: "unknown", error_tool: "Bash" };
207
233
  }
234
+
235
+ // ── Bash metadata derivation — algorithmic, not enumerative ──────────────
236
+ //
237
+ // A single Bash tool call may emit MULTIPLE canonical events (a `git pull`
238
+ // produces type='git' AND type='cwd'). The platform's command_metadata
239
+ // describes the BASH CALL, not the per-event derivative — so all events
240
+ // from one PostToolUse fire carry the same shape. Non-Bash tool calls
241
+ // return null and the per-event fields stay undefined (Zod optional drops
242
+ // them silently — no NULL noise on the wire).
243
+ //
244
+ // DESIGN: tool ecosystems contain millions of CLI binaries but converge on
245
+ // a tiny canonical verb set (test/build/install/lint/format/run/start/
246
+ // deploy/...). The classifier scans for these verbs at canonical token
247
+ // positions — agnostic of which package manager / language / framework.
248
+ // New tools without a registry change automatically classify correctly as
249
+ // long as they use the verbs (which is the dominant ecosystem convention).
250
+ // This was originally regex-table enumeration; the table never converges.
251
+ const CANONICAL_VERBS = new Set([
252
+ "test", "build", "install", "lint", "format", "run", "start",
253
+ "deploy", "compile", "bundle", "watch", "serve", "publish",
254
+ ]);
255
+ // Runners that wrap the actual executable — strip them so command_tool
256
+ // reflects the real binary the user invoked (`bunx pytest` → "pytest",
257
+ // not "bunx"). NODE_ENV=production npm run build → "npm".
258
+ const COMMAND_RUNNERS = new Set([
259
+ "sudo", "doas", "env", "exec", "time",
260
+ "npx", "pnpx", "bunx", "pnpm", "yarn", "bun",
261
+ ]);
262
+ const ENV_ASSIGN_RE = /^[A-Z_][A-Z0-9_]*=/;
263
+
264
+ // Tools whose NAME directly implies their type (no subcommand needed).
265
+ // Curated minimum — covers the dominant test/lint/format/build/db/http/
266
+ // deploy invocations across ecosystems. New ecosystem tools land in
267
+ // "other" until added — preferred to a noisy heuristic that misclassifies.
268
+ // Lookup is O(1); contrast with the original regex-table approach which
269
+ // scaled to no boundary and still missed unknowns.
270
+ const CANONICAL_TOOLS = new Map([
271
+ // test runners
272
+ ["pytest", "test"], ["jest", "test"], ["vitest", "test"], ["mocha", "test"],
273
+ ["ava", "test"], ["jasmine", "test"], ["rspec", "test"], ["junit", "test"],
274
+ ["tap", "test"], ["karma", "test"],
275
+ // linters
276
+ ["eslint", "lint"], ["tslint", "lint"], ["ruff", "lint"], ["rubocop", "lint"],
277
+ ["pylint", "lint"], ["flake8", "lint"], ["clippy", "lint"], ["staticcheck", "lint"],
278
+ ["mypy", "lint"], ["shellcheck", "lint"],
279
+ // formatters
280
+ ["prettier", "format"], ["black", "format"], ["gofmt", "format"], ["rustfmt", "format"],
281
+ ["autopep8", "format"], ["yapf", "format"],
282
+ // bundlers / builders
283
+ ["webpack", "build"], ["vite", "build"], ["rollup", "build"], ["esbuild", "build"],
284
+ ["parcel", "build"], ["tsc", "build"], ["swc", "build"], ["turbo", "build"],
285
+ // deploy / infra
286
+ ["docker", "deploy"], ["kubectl", "deploy"], ["terraform", "deploy"], ["pulumi", "deploy"],
287
+ ["ansible", "deploy"], ["helm", "deploy"], ["aws", "deploy"], ["gcloud", "deploy"], ["az", "deploy"],
288
+ // databases
289
+ ["psql", "database"], ["mysql", "database"], ["sqlite3", "database"],
290
+ ["redis-cli", "database"], ["mongosh", "database"], ["mongo", "database"],
291
+ // http
292
+ ["curl", "http"], ["wget", "http"], ["httpie", "http"], ["http", "http"],
293
+ ]);
294
+
295
+ function deriveBashMetadata(input) {
296
+ if (input?.tool_name !== "Bash") return null;
297
+ const cmd = String(input?.tool_input?.command ?? "").trim();
298
+ if (!cmd) return { command_type: "other", command_tool: "Bash" };
299
+
300
+ const tokens = cmd.split(/\s+/);
301
+ const command_tool = extractCommandTool(tokens);
302
+ const command_type = classifyCommandType(tokens, command_tool);
303
+ const exit_code = inferExitCode(input?.tool_response);
304
+ return { command_type, command_tool, exit_code };
305
+ }
306
+
307
+ // Strip env-assign prefixes (`FOO=bar`), then strip runner shells,
308
+ // then return the basename of the executable token.
309
+ function extractCommandTool(tokens) {
310
+ let i = 0;
311
+ // Skip env assignments
312
+ while (i < tokens.length && ENV_ASSIGN_RE.test(tokens[i])) i++;
313
+ // Skip runner shells
314
+ while (i < tokens.length && COMMAND_RUNNERS.has(tokens[i].toLowerCase())) {
315
+ i++;
316
+ // Skip subcommands like `pnpm dlx`, `pnpm exec`, `bun run`
317
+ if (i < tokens.length && /^(dlx|exec|run|x)$/i.test(tokens[i])) i++;
318
+ }
319
+ if (i >= tokens.length) return tokens[0] || "Bash";
320
+ const exe = tokens[i];
321
+ // basename of path-like executables (`/usr/local/bin/foo` → "foo")
322
+ const base = exe.split(/[/\\]/).pop() || "Bash";
323
+ // Strip shell quoting if present
324
+ return base.replace(/^['"]|['"]$/g, "");
325
+ }
326
+
327
+ // Type classification — priority order:
328
+ // 1. Tool name implies type (curated CANONICAL_TOOLS map)
329
+ // 2. Canonical verb at subcommand position (`npm test`, `cargo build`)
330
+ // 3. Argument-shape heuristics (test/ dir, .test.ts suffix, --prod flag)
331
+ // 4. Tool-level fallback (git → git, make → build)
332
+ // 5. "other"
333
+ function classifyCommandType(tokens, command_tool) {
334
+ const toolLc = (command_tool || "").toLowerCase();
335
+
336
+ // 1. Tool name itself names the type
337
+ const fromTool = CANONICAL_TOOLS.get(toolLc);
338
+ if (fromTool) return fromTool;
339
+
340
+ // Skip env + runners to find subcommand position
341
+ const lower = tokens.map((t) => t.toLowerCase());
342
+ let start = 0;
343
+ while (start < lower.length && ENV_ASSIGN_RE.test(tokens[start])) start++;
344
+ while (start < lower.length && COMMAND_RUNNERS.has(lower[start])) {
345
+ start++;
346
+ if (start < lower.length && /^(dlx|exec|run|x)$/.test(lower[start])) start++;
347
+ }
348
+
349
+ // 2. Canonical verb scan within next 4 tokens
350
+ const horizon = Math.min(lower.length, start + 4);
351
+ for (let i = start; i < horizon; i++) {
352
+ if (CANONICAL_VERBS.has(lower[i])) return lower[i];
353
+ }
354
+
355
+ // 3. Argument-shape heuristics
356
+ const tail = tokens.slice(start).join(" ");
357
+ if (/\btests?[/\\]|\bspec[/\\]|__tests__|\.(test|spec)\.[mc]?[jt]sx?\b|test_[\w-]+\.py\b|_test\.go\b/.test(tail)) return "test";
358
+ if (/--(prod|production|release|optimize)\b/.test(tail)) return "build";
359
+ if (/\bDockerfile\b|docker-compose/.test(tail)) return "deploy";
360
+
361
+ // 4. Tool-level fallback for tools whose mere presence implies the type
362
+ if (toolLc === "git") return "git";
363
+ if (toolLc === "make" || toolLc === "ninja" || toolLc === "cmake") return "build";
364
+
365
+ return "other";
366
+ }
367
+
368
+ // Exit code best-effort inference from tool_response. Hook stdin does
369
+ // not carry the actual exit code on CC; we read the shape of the output
370
+ // for signals. Engine treats exit_code as soft signal (Anomaly #3 — no
371
+ // pattern in patterns.ts reads it today), so probabilistic stamps are
372
+ // adequate. Captures named exit code when explicit.
373
+ function inferExitCode(response) {
374
+ const r = String(response ?? "");
375
+ if (!r) return 0;
376
+ // Explicit exit-code marker (some wrappers emit "exit status 137" etc.)
377
+ const explicit = r.match(/\bexit (?:status|code)\s+(\d+)\b/i);
378
+ if (explicit) return Number(explicit[1]);
379
+ // "command not found" → POSIX standard 127
380
+ if (/^bash:.*: (?:command not found|No such file)/m.test(r)) return 127;
381
+ // Heuristic non-zero indicators (line-anchored to avoid false positives
382
+ // inside narrative text from successful commands).
383
+ if (/^(?:Error: |Traceback|FAIL\b|✗|✘)/m.test(r)) return 1;
384
+ return 0;
385
+ }
386
+
387
+ // ── Latency timing — reads PreToolUse marker ────────────────────────────
388
+ //
389
+ // PreToolUse already writes `${tmpdir}/context-mode-latency-${sessionId}-
390
+ // ${toolName}.txt` with the start timestamp (pretooluse.mjs:177). We
391
+ // piggyback on that marker — read + compute delta, do NOT unlink (the
392
+ // downstream slow-tool event emission in posttooluse.mjs:128-152 manages
393
+ // the unlink lifecycle). Failure modes (missing marker, parse error,
394
+ // negative delta, sanity-out-of-range) all return undefined — Zod's
395
+ // optional handling drops the field silently. No NULL noise on the wire.
396
+ function readLatencyMs(sessionId, toolName) {
397
+ if (!sessionId || !toolName) return undefined;
398
+ const markerPath = resolvePath(
399
+ tmpdir(),
400
+ `context-mode-latency-${sessionId}-${toolName}.txt`,
401
+ );
402
+ try {
403
+ const start = parseInt(readFileSync(markerPath, "utf8").trim(), 10);
404
+ if (!Number.isFinite(start) || start <= 0) return undefined;
405
+ const delta = Date.now() - start;
406
+ if (delta < 0 || delta > 24 * 3600 * 1000) return undefined;
407
+ return delta;
408
+ } catch {
409
+ return undefined;
410
+ }
411
+ }
412
+
413
+ // ── Duration bucket ──────────────────────────────────────────────────────
414
+ //
415
+ // Open-string label the platform Zod schema accepts (max 20 chars). Three
416
+ // buckets cover the seed.ts shape: <5s | 5-30s | 30s+.
417
+ function bucketizeDuration(ms) {
418
+ if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return undefined;
419
+ if (ms < 5_000) return "<5s";
420
+ if (ms < 30_000) return "5-30s";
421
+ return "30s+";
422
+ }
@@ -30,6 +30,7 @@ await runHook(async () => {
30
30
  readStdin,
31
31
  parseStdin,
32
32
  getSessionId,
33
+ getInputProjectDir,
33
34
  getSessionDBPath,
34
35
  getSessionEventsPath,
35
36
  getCleanupFlagPath,
@@ -38,7 +39,7 @@ await runHook(async () => {
38
39
  const { writeSessionEventsFile, buildSessionDirective, getSessionEvents } = await import(
39
40
  "./session-directive.mjs"
40
41
  );
41
- const { createSessionLoaders } = await import("./session-loaders.mjs");
42
+ const { createSessionLoaders, attributeAndInsertEvents } = await import("./session-loaders.mjs");
42
43
  const { join, dirname } = await import("node:path");
43
44
  const { fileURLToPath } = await import("node:url");
44
45
  const { readFileSync, unlinkSync, readdirSync, rmSync, lstatSync } = await import("node:fs");
@@ -49,7 +50,40 @@ await runHook(async () => {
49
50
 
50
51
  // Resolve absolute path for imports (fileURLToPath for Windows compat)
51
52
  const HOOK_DIR = dirname(fileURLToPath(import.meta.url));
52
- const { loadSessionDB } = createSessionLoaders(HOOK_DIR);
53
+ const { loadSessionDB, loadProjectAttribution } = createSessionLoaders(HOOK_DIR);
54
+
55
+ // Emit a `session_start` canonical event at the boundary of each session
56
+ // lifecycle transition (startup / resume / compact). The platform's insight
57
+ // engine joins on `category='session_start'` to compute per-session
58
+ // aggregates (~60 of 180 patterns depend on this anchor row). Bridge
59
+ // forwards via attributeAndInsertEvents which also stamps the rollup
60
+ // snapshot — safe for the FIRST event of a fresh session.
61
+ async function emitSessionStartLifecycle(db, sessionId, source, projectDir, input) {
62
+ try {
63
+ const { resolveProjectAttributions } = await loadProjectAttribution();
64
+ const lifecycleEvent = {
65
+ type: "session_start",
66
+ category: "session_start",
67
+ data: JSON.stringify({
68
+ source,
69
+ project_dir: projectDir,
70
+ started_at: Math.floor(Date.now() / 1000),
71
+ }),
72
+ priority: 1,
73
+ };
74
+ attributeAndInsertEvents(
75
+ db,
76
+ sessionId,
77
+ [lifecycleEvent],
78
+ input,
79
+ projectDir,
80
+ "SessionStart",
81
+ resolveProjectAttributions,
82
+ );
83
+ } catch {
84
+ // Best-effort — lifecycle emission failure MUST NOT block session start.
85
+ }
86
+ }
53
87
 
54
88
  // Self-heal a partial plugin cache install before anything else
55
89
  // touches the cache dir. The Algo-D4 boot gate and the #604
@@ -202,6 +236,13 @@ await runHook(async () => {
202
236
  } catch { /* best-effort */ }
203
237
  }
204
238
 
239
+ // Emit lifecycle anchor BEFORE close — engine joins on
240
+ // category='session_start' to compute per-session aggregates.
241
+ // Cross-platform projectDir via getInputProjectDir (covers cursor's
242
+ // workspace_roots[], codex/gemini/qwen's *_PROJECT_DIR env vars,
243
+ // CC's CLAUDE_PROJECT_DIR, falls back to input.cwd and process.cwd).
244
+ const projectDirCompact = getInputProjectDir(input);
245
+ await emitSessionStartLifecycle(db, sessionId, "compact", projectDirCompact, input);
205
246
  db.close();
206
247
  } else if (source === "resume") {
207
248
  // User invoked --continue, --resume, or /resume — clear cleanup flag so
@@ -234,6 +275,10 @@ await runHook(async () => {
234
275
  }
235
276
  }
236
277
 
278
+ const projectDirResume = getInputProjectDir(input);
279
+ if (sessionId) {
280
+ await emitSessionStartLifecycle(db, sessionId, "resume", projectDirResume, input);
281
+ }
237
282
  db.close();
238
283
  } else if (source === "startup") {
239
284
  // Fresh session (no --continue) — clean slate, capture CLAUDE.md rules.
@@ -294,6 +339,11 @@ await runHook(async () => {
294
339
  } catch { /* file doesn't exist — skip */ }
295
340
  }
296
341
 
342
+ // Lifecycle anchor for a fresh session — emits BEFORE the CLAUDE.md
343
+ // rule events have been forwarded so the `session_start` row lands
344
+ // as the very first row the platform sees for this session.
345
+ await emitSessionStartLifecycle(db, sessionId, "startup", projectDir, input);
346
+
297
347
  db.close();
298
348
 
299
349
  // Age-gated lazy cleanup of old plugin cache version dirs (#181).
@@ -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.158",
6
+ "version": "1.0.159",
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.158",
3
+ "version": "1.0.159",
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",