context-mode 1.0.157 → 1.0.158

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,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "1.0.157"
9
+ "version": "1.0.158"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "context-mode",
14
14
  "source": "./",
15
15
  "description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
16
- "version": "1.0.157",
16
+ "version": "1.0.158",
17
17
  "author": {
18
18
  "name": "Mert Koseoğlu"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.157",
3
+ "version": "1.0.158",
4
4
  "description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.157",
3
+ "version": "1.0.158",
4
4
  "description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -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.157",
6
+ "version": "1.0.158",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.157",
3
+ "version": "1.0.158",
4
4
  "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.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -164,6 +164,28 @@ export interface SessionMeta {
164
164
  event_count: number;
165
165
  compact_count: number;
166
166
  }
167
+ /**
168
+ * Session rollup snapshot (seed-parity aggregate).
169
+ *
170
+ * 12 fields that mirror the platform's `session_summary` + `session_metadata`
171
+ * stamps from src/routes/seed.ts. Each outgoing canonical event carries
172
+ * this snapshot computed at the moment of forward so the analytics engine
173
+ * can run its SUM/AVG/MAX rollups across per-event rows.
174
+ */
175
+ export interface SessionRollup {
176
+ tool_calls: number;
177
+ errors: number;
178
+ unique_tools: number;
179
+ unique_files: number;
180
+ max_file_edits: number;
181
+ has_commit: 0 | 1;
182
+ edit_test_cycles: number;
183
+ duration_min: number;
184
+ compact_count: number;
185
+ sources_indexed: number;
186
+ total_chunks: number;
187
+ search_queries: number;
188
+ }
167
189
  /** Resume snapshot row from the session_resume table. */
168
190
  export interface ResumeRow {
169
191
  snapshot: string;
@@ -340,6 +362,19 @@ export declare class SessionDB extends SQLiteBase {
340
362
  * Get session statistics/metadata.
341
363
  */
342
364
  getSessionStats(sessionId: string): SessionMeta | null;
365
+ /**
366
+ * Session rollup snapshot — 12 aggregate fields the analytics platform
367
+ * stamps onto every outgoing event row (seed.ts shape parity).
368
+ *
369
+ * Called from session-loaders BEFORE `maybeForward`; the snapshot is
370
+ * computed against the LOCAL SessionDB and threaded into the canonical
371
+ * event so the platform-side Zod schema receives the rich shape without
372
+ * the bridge ever hand-mapping fields (PRD §5.4 ABI passthrough).
373
+ *
374
+ * Returns zeroed defaults for unknown sessions — callers MUST tolerate
375
+ * a snapshot from an empty session (first event into a fresh DB).
376
+ */
377
+ getSessionRollup(sessionId: string): SessionRollup;
343
378
  /**
344
379
  * Increment the compact_count for a session (tracks snapshot rebuilds).
345
380
  */
@@ -477,6 +477,8 @@ const S = {
477
477
  updateMetaLastEvent: "updateMetaLastEvent",
478
478
  ensureSession: "ensureSession",
479
479
  getSessionStats: "getSessionStats",
480
+ getSessionRollup: "getSessionRollup",
481
+ getMaxFileEdits: "getMaxFileEdits",
480
482
  incrementCompactCount: "incrementCompactCount",
481
483
  upsertResume: "upsertResume",
482
484
  getResume: "getResume",
@@ -716,6 +718,35 @@ export class SessionDB extends SQLiteBase {
716
718
  p(S.ensureSession, `INSERT OR IGNORE INTO session_meta (session_id, project_dir) VALUES (?, ?)`);
717
719
  p(S.getSessionStats, `SELECT session_id, project_dir, started_at, last_event_at, event_count, compact_count
718
720
  FROM session_meta WHERE session_id = ?`);
721
+ // ── Session rollup (seed-parity aggregator) ────────────────────────
722
+ // Single query producing 9 of the 12 platform-side session_summary +
723
+ // session_metadata fields. Computed against the local SessionDB
724
+ // session_events table at forward time so every outgoing canonical
725
+ // event carries a session-wide snapshot at that moment — matches the
726
+ // seed.ts shape where each event row has tool_calls/errors/etc. stamped.
727
+ // max_file_edits and edit_test_cycles need separate GROUP BY queries
728
+ // (below). compact_count is read from session_meta (already in getSessionStats).
729
+ p(S.getSessionRollup, `SELECT
730
+ COUNT(*) AS tool_calls,
731
+ COALESCE(SUM(CASE WHEN category = 'error' THEN 1 ELSE 0 END), 0) AS errors,
732
+ COUNT(DISTINCT type) AS unique_tools,
733
+ COUNT(DISTINCT CASE WHEN category = 'file' THEN data END) AS unique_files,
734
+ CASE WHEN SUM(CASE WHEN category = 'git' THEN 1 ELSE 0 END) > 0 THEN 1 ELSE 0 END AS has_commit,
735
+ CAST(COALESCE((MAX(strftime('%s', created_at)) - MIN(strftime('%s', created_at))) / 60.0, 0) AS INTEGER) AS duration_min,
736
+ COALESCE(SUM(CASE WHEN type = 'external_ref' THEN 1 ELSE 0 END), 0) AS sources_indexed,
737
+ CAST(COALESCE(SUM(bytes_avoided) / 1024.0, 0) AS INTEGER) AS total_chunks,
738
+ COALESCE(SUM(CASE WHEN type IN ('file_search', 'file_glob') THEN 1 ELSE 0 END), 0) AS search_queries
739
+ FROM session_events
740
+ WHERE session_id = ?`);
741
+ // max_file_edits: max edits on any single file path in the session.
742
+ // Two-level aggregation — GROUP BY data first, then MAX of those counts.
743
+ p(S.getMaxFileEdits, `SELECT COALESCE(MAX(c), 0) AS max_file_edits
744
+ FROM (
745
+ SELECT COUNT(*) AS c
746
+ FROM session_events
747
+ WHERE session_id = ? AND category = 'file' AND type IN ('file_edit', 'file_write')
748
+ GROUP BY data
749
+ )`);
719
750
  p(S.incrementCompactCount, `UPDATE session_meta SET compact_count = compact_count + 1 WHERE session_id = ?`);
720
751
  // ── Resume ──
721
752
  p(S.upsertResume, `INSERT INTO session_resume (session_id, snapshot, event_count)
@@ -1017,6 +1048,46 @@ export class SessionDB extends SQLiteBase {
1017
1048
  const row = this.stmt(S.getSessionStats).get(sessionId);
1018
1049
  return row ?? null;
1019
1050
  }
1051
+ /**
1052
+ * Session rollup snapshot — 12 aggregate fields the analytics platform
1053
+ * stamps onto every outgoing event row (seed.ts shape parity).
1054
+ *
1055
+ * Called from session-loaders BEFORE `maybeForward`; the snapshot is
1056
+ * computed against the LOCAL SessionDB and threaded into the canonical
1057
+ * event so the platform-side Zod schema receives the rich shape without
1058
+ * the bridge ever hand-mapping fields (PRD §5.4 ABI passthrough).
1059
+ *
1060
+ * Returns zeroed defaults for unknown sessions — callers MUST tolerate
1061
+ * a snapshot from an empty session (first event into a fresh DB).
1062
+ */
1063
+ getSessionRollup(sessionId) {
1064
+ const main = this.stmt(S.getSessionRollup).get(sessionId);
1065
+ const maxRow = this.stmt(S.getMaxFileEdits).get(sessionId);
1066
+ const meta = this.getSessionStats(sessionId);
1067
+ // edit_test_cycles: heuristic — min(file edits, errors) approximates
1068
+ // the number of edit-then-test attempts in a session. Exact pattern
1069
+ // detection (consecutive file_edit followed by error_tool) would need
1070
+ // a windowed query; this scalar pair under-counts but never overshoots.
1071
+ const fileEdits = (main?.tool_calls ?? 0) > 0
1072
+ ? (main?.unique_files ?? 0)
1073
+ : 0;
1074
+ const errors = main?.errors ?? 0;
1075
+ const editTestCycles = Math.min(fileEdits, errors);
1076
+ return {
1077
+ tool_calls: main?.tool_calls ?? 0,
1078
+ errors: main?.errors ?? 0,
1079
+ unique_tools: main?.unique_tools ?? 0,
1080
+ unique_files: main?.unique_files ?? 0,
1081
+ max_file_edits: maxRow?.max_file_edits ?? 0,
1082
+ has_commit: main?.has_commit ?? 0,
1083
+ edit_test_cycles: editTestCycles,
1084
+ duration_min: main?.duration_min ?? 0,
1085
+ compact_count: meta?.compact_count ?? 0,
1086
+ sources_indexed: main?.sources_indexed ?? 0,
1087
+ total_chunks: main?.total_chunks ?? 0,
1088
+ search_queries: main?.search_queries ?? 0,
1089
+ };
1090
+ }
1020
1091
  /**
1021
1092
  * Increment the compact_count for a session (tracks snapshot rebuilds).
1022
1093
  */
@@ -0,0 +1,87 @@
1
+ /**
2
+ * error-classifier — PRD-context-as-a-service §5 ABI parity
3
+ *
4
+ * Pure classifiers that derive per-event metadata from PostToolUse hook
5
+ * stdin, mirroring the seed shape produced by context-mode-platform's
6
+ * `seed.ts` so the bridge can ship full seed-shape parity.
7
+ *
8
+ * The dashboard reads three derived columns that are populated by the
9
+ * platform seeder but, per the OSS handoff (ANOMALY #3), are NEVER READ
10
+ * by the engine when ingesting live events:
11
+ *
12
+ * • error_category — one of 10 fixed buckets
13
+ * • error_tool — tool name that produced the error
14
+ * • command_type — git / test / build / lint / install / format / run / other
15
+ * • command_tool — first token of the command (npm, git, pytest, …)
16
+ * • duration_bucket — fast / medium / slow / timeout
17
+ *
18
+ * We populate them anyway, with sane defaults derived from message text
19
+ * and tool name, so the dashboard renders symmetrically with seed.
20
+ *
21
+ * Hard constraints:
22
+ * 1. Pure functions — no I/O, no globals, no module state.
23
+ * 2. `error_category` MUST match seed's `pickErrorClassification`
24
+ * output exactly. The literal union below is the ABI.
25
+ * 3. No external dependencies; TypeScript stdlib only.
26
+ * 4. Robust to null / undefined / empty / malformed input.
27
+ */
28
+ export type ErrorCategory = "file_not_found" | "command_not_found" | "edit_match_failed" | "test_failed" | "syntax_error" | "runtime_error" | "permission_denied" | "git_conflict" | "timeout" | "unknown";
29
+ export type CommandType = "test" | "build" | "lint" | "git" | "install" | "format" | "run" | "other";
30
+ export type DurationBucket = "fast" | "medium" | "slow" | "timeout";
31
+ export interface ErrorClassification {
32
+ error_category: ErrorCategory;
33
+ error_tool: string;
34
+ }
35
+ export interface CommandClassification {
36
+ command_type: CommandType;
37
+ command_tool: string;
38
+ }
39
+ /**
40
+ * Classify an error message + tool name into one of seed's 10 categories.
41
+ *
42
+ * Patterns (ordered most-specific → least-specific so the first match
43
+ * wins). Rationale for each pattern is inline.
44
+ *
45
+ * • file_not_found — Node's ENOENT, Python's FileNotFoundError, and
46
+ * Claude's "Cannot find module" all signal a
47
+ * missing file/path.
48
+ * • command_not_found — POSIX shells emit "command not found" (exit 127)
49
+ * and "bash: <bin>: not found"; npx prints similar.
50
+ * • edit_match_failed — Claude's Edit tool prints "old_string not found"
51
+ * or "matches multiple locations" when the patch
52
+ * context is stale.
53
+ * • test_failed — vitest/jest/pytest format `FAIL ` prefix; npm
54
+ * prints "Test failed".
55
+ * • syntax_error — SyntaxError (JS/TS), tsc TS-codes, or Python's
56
+ * SyntaxError header.
57
+ * • runtime_error — TypeError/ReferenceError/RangeError (uncaught).
58
+ * • permission_denied — EACCES / "permission denied" from fs or sudo.
59
+ * • git_conflict — git's "CONFLICT" header on rebase/merge, plus
60
+ * "Merge conflict in".
61
+ * • timeout — explicit "timeout"/"timed out"/"ETIMEDOUT".
62
+ * • unknown — fallthrough; never throws.
63
+ */
64
+ export declare function classifyError(message: unknown, toolName: unknown): ErrorClassification;
65
+ /**
66
+ * Classify a Bash command into a workflow bucket. The bucket is derived
67
+ * from the (tool, sub-verb) pair: `npm test` → test, `npm run build` →
68
+ * build, `git commit` → git, `pip install` → install, etc.
69
+ *
70
+ * Heuristic ordering — most-specific verb checks first; falls through to
71
+ * `run` for generic execution and `other` for anything we can't classify.
72
+ */
73
+ export declare function classifyCommand(command: unknown): CommandClassification;
74
+ /**
75
+ * Bucketise a latency_ms reading into the four fixed buckets the
76
+ * dashboard renders. Bucket edges (in ms):
77
+ *
78
+ * fast : [0, 1_000)
79
+ * medium : [1_000, 10_000)
80
+ * slow : [10_000, 60_000)
81
+ * timeout : [60_000, ∞)
82
+ *
83
+ * Defensive: negative and non-numeric inputs collapse to `fast` (the
84
+ * neutral bucket) rather than throwing, because hooks have been seen
85
+ * to emit `null` for very short calls.
86
+ */
87
+ export declare function bucketizeDuration(latencyMs: unknown): DurationBucket;
@@ -0,0 +1,303 @@
1
+ /**
2
+ * error-classifier — PRD-context-as-a-service §5 ABI parity
3
+ *
4
+ * Pure classifiers that derive per-event metadata from PostToolUse hook
5
+ * stdin, mirroring the seed shape produced by context-mode-platform's
6
+ * `seed.ts` so the bridge can ship full seed-shape parity.
7
+ *
8
+ * The dashboard reads three derived columns that are populated by the
9
+ * platform seeder but, per the OSS handoff (ANOMALY #3), are NEVER READ
10
+ * by the engine when ingesting live events:
11
+ *
12
+ * • error_category — one of 10 fixed buckets
13
+ * • error_tool — tool name that produced the error
14
+ * • command_type — git / test / build / lint / install / format / run / other
15
+ * • command_tool — first token of the command (npm, git, pytest, …)
16
+ * • duration_bucket — fast / medium / slow / timeout
17
+ *
18
+ * We populate them anyway, with sane defaults derived from message text
19
+ * and tool name, so the dashboard renders symmetrically with seed.
20
+ *
21
+ * Hard constraints:
22
+ * 1. Pure functions — no I/O, no globals, no module state.
23
+ * 2. `error_category` MUST match seed's `pickErrorClassification`
24
+ * output exactly. The literal union below is the ABI.
25
+ * 3. No external dependencies; TypeScript stdlib only.
26
+ * 4. Robust to null / undefined / empty / malformed input.
27
+ */
28
+ // ── Internal helpers ────────────────────────────────────────────────────
29
+ /**
30
+ * Normalise message for case-insensitive substring matching. Coerces
31
+ * non-string input (null, undefined, numbers from errno objects) to
32
+ * empty string so downstream `.includes()` is always safe.
33
+ */
34
+ function normalise(s) {
35
+ if (typeof s !== "string")
36
+ return "";
37
+ return s.toLowerCase();
38
+ }
39
+ /**
40
+ * Best-effort `error_tool` derivation. Prefers the PostToolUse-supplied
41
+ * `toolName`; falls back to scanning the message for a canonical tool
42
+ * name (Read/Edit/Write/Bash/Grep/Glob) so isolated error strings still
43
+ * resolve to a useful value rather than the literal "unknown".
44
+ */
45
+ function deriveErrorTool(toolName, message) {
46
+ if (typeof toolName === "string" && toolName.trim().length > 0) {
47
+ return toolName.trim();
48
+ }
49
+ // Canonical Claude Code tool names that may appear in error strings.
50
+ const known = ["Edit", "Read", "Write", "Bash", "Grep", "Glob", "MultiEdit"];
51
+ for (const t of known) {
52
+ if (message.toLowerCase().includes(t.toLowerCase()))
53
+ return t;
54
+ }
55
+ return "unknown";
56
+ }
57
+ // ── Error classifier ────────────────────────────────────────────────────
58
+ /**
59
+ * Classify an error message + tool name into one of seed's 10 categories.
60
+ *
61
+ * Patterns (ordered most-specific → least-specific so the first match
62
+ * wins). Rationale for each pattern is inline.
63
+ *
64
+ * • file_not_found — Node's ENOENT, Python's FileNotFoundError, and
65
+ * Claude's "Cannot find module" all signal a
66
+ * missing file/path.
67
+ * • command_not_found — POSIX shells emit "command not found" (exit 127)
68
+ * and "bash: <bin>: not found"; npx prints similar.
69
+ * • edit_match_failed — Claude's Edit tool prints "old_string not found"
70
+ * or "matches multiple locations" when the patch
71
+ * context is stale.
72
+ * • test_failed — vitest/jest/pytest format `FAIL ` prefix; npm
73
+ * prints "Test failed".
74
+ * • syntax_error — SyntaxError (JS/TS), tsc TS-codes, or Python's
75
+ * SyntaxError header.
76
+ * • runtime_error — TypeError/ReferenceError/RangeError (uncaught).
77
+ * • permission_denied — EACCES / "permission denied" from fs or sudo.
78
+ * • git_conflict — git's "CONFLICT" header on rebase/merge, plus
79
+ * "Merge conflict in".
80
+ * • timeout — explicit "timeout"/"timed out"/"ETIMEDOUT".
81
+ * • unknown — fallthrough; never throws.
82
+ */
83
+ export function classifyError(message, toolName) {
84
+ const msg = normalise(message);
85
+ const tool = typeof toolName === "string" ? toolName : "";
86
+ const error_tool = deriveErrorTool(toolName, typeof message === "string" ? message : "");
87
+ // Empty / malformed input → unknown bucket but still return a valid tool.
88
+ if (msg.length === 0) {
89
+ return { error_category: "unknown", error_tool };
90
+ }
91
+ // ── file_not_found ────────────────────────────────────────────────
92
+ // Node's ENOENT carries "no such file or directory"; Claude's Read
93
+ // tool surfaces the raw errno line. "cannot find module" is the
94
+ // require-resolution variant.
95
+ if (msg.includes("enoent") ||
96
+ msg.includes("no such file") ||
97
+ msg.includes("cannot find module") ||
98
+ msg.includes("filenotfounderror")) {
99
+ return { error_category: "file_not_found", error_tool };
100
+ }
101
+ // ── command_not_found ────────────────────────────────────────────
102
+ // POSIX shells: "<shell>: <bin>: command not found" → exit 127.
103
+ // Some hooks surface the bare exit code without the message.
104
+ if (msg.includes("command not found") ||
105
+ msg.includes(": not found") ||
106
+ /\bexit(?:\s+code)?\s*[:=]?\s*127\b/.test(msg)) {
107
+ return { error_category: "command_not_found", error_tool };
108
+ }
109
+ // ── edit_match_failed ────────────────────────────────────────────
110
+ // Claude Code's Edit tool prints these exact phrases when the
111
+ // `old_string` parameter no longer matches the file contents.
112
+ // Gate on the Edit tool to avoid false positives when a Bash script
113
+ // happens to contain the literal text "old_string".
114
+ if ((tool === "Edit" || tool === "MultiEdit") &&
115
+ (msg.includes("old_string") ||
116
+ msg.includes("could not find string") ||
117
+ msg.includes("string to replace not found") ||
118
+ msg.includes("matches multiple"))) {
119
+ return { error_category: "edit_match_failed", error_tool };
120
+ }
121
+ // Edit-style failure can also leak without tool name — match the
122
+ // distinctive Claude phrasing alone.
123
+ if (msg.includes("old_string not found") || msg.includes("string to replace not found")) {
124
+ return { error_category: "edit_match_failed", error_tool };
125
+ }
126
+ // ── git_conflict ─────────────────────────────────────────────────
127
+ // Check BEFORE test_failed because "CONFLICT" can co-occur with the
128
+ // word "fail" in some merge outputs.
129
+ if (msg.includes("conflict") &&
130
+ (msg.includes("merge") || msg.includes("rebase") || msg.includes("git"))) {
131
+ return { error_category: "git_conflict", error_tool };
132
+ }
133
+ if (msg.startsWith("conflict") || msg.includes("merge conflict")) {
134
+ return { error_category: "git_conflict", error_tool };
135
+ }
136
+ // ── timeout ──────────────────────────────────────────────────────
137
+ // Check BEFORE test_failed; "test timed out" should bucket as timeout.
138
+ if (msg.includes("etimedout") ||
139
+ msg.includes("timed out") ||
140
+ msg.includes("timeout") ||
141
+ msg.includes("deadline exceeded")) {
142
+ return { error_category: "timeout", error_tool };
143
+ }
144
+ // ── permission_denied ────────────────────────────────────────────
145
+ if (msg.includes("eacces") ||
146
+ msg.includes("permission denied") ||
147
+ msg.includes("operation not permitted") ||
148
+ msg.includes("eperm")) {
149
+ return { error_category: "permission_denied", error_tool };
150
+ }
151
+ // ── syntax_error ─────────────────────────────────────────────────
152
+ // tsc errors look like "error TS2322"; JS SyntaxError header is exact.
153
+ if (msg.includes("syntaxerror") ||
154
+ /\berror\s+ts\d{3,5}\b/.test(msg) ||
155
+ msg.includes("unexpected token") ||
156
+ msg.includes("unexpected end of") ||
157
+ msg.includes("parse error")) {
158
+ return { error_category: "syntax_error", error_tool };
159
+ }
160
+ // ── test_failed ──────────────────────────────────────────────────
161
+ // vitest/jest print `FAIL `; npm test prints "Test failed".
162
+ // pytest prints "FAILED" capital. Bias to Bash tool but accept
163
+ // unscoped messages because hooks sometimes drop tool name.
164
+ if (msg.includes("test failed") ||
165
+ msg.includes("tests failed") ||
166
+ /\bfail(?:ed)?\b.*\btest\b/.test(msg) ||
167
+ /\b\d+\s+tests?\s+failed\b/.test(msg) ||
168
+ msg.includes("assertion") ||
169
+ /^fail\s/.test(msg) ||
170
+ /\nfail\s/.test(msg)) {
171
+ return { error_category: "test_failed", error_tool };
172
+ }
173
+ // ── runtime_error ────────────────────────────────────────────────
174
+ // Catch-all for uncaught JS exceptions and the common Python ones.
175
+ // Placed near the end so more specific buckets (syntax_error) win
176
+ // when both could match.
177
+ if (msg.includes("typeerror") ||
178
+ msg.includes("referenceerror") ||
179
+ msg.includes("rangeerror") ||
180
+ msg.includes("uncaught exception") ||
181
+ msg.includes("traceback (most recent call last)") ||
182
+ msg.includes("nullpointerexception")) {
183
+ return { error_category: "runtime_error", error_tool };
184
+ }
185
+ // Fallthrough — categorisation is best-effort; the dashboard renders
186
+ // "unknown" as a neutral bucket rather than dropping the event.
187
+ return { error_category: "unknown", error_tool };
188
+ }
189
+ // ── Command classifier ──────────────────────────────────────────────────
190
+ /**
191
+ * Tokenise a shell command, stripping leading env-var prefixes
192
+ * (`FOO=bar npm run build`) and `sudo`/`time` wrappers so the first
193
+ * meaningful token is returned.
194
+ */
195
+ function firstToken(command) {
196
+ const parts = command.trim().split(/\s+/);
197
+ let i = 0;
198
+ // Skip env-var assignments: KEY=value
199
+ while (i < parts.length && /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(parts[i]))
200
+ i++;
201
+ // Skip common wrappers that aren't the "real" tool.
202
+ while (i < parts.length && (parts[i] === "sudo" || parts[i] === "time" || parts[i] === "nice"))
203
+ i++;
204
+ return parts[i] ?? "";
205
+ }
206
+ /**
207
+ * Classify a Bash command into a workflow bucket. The bucket is derived
208
+ * from the (tool, sub-verb) pair: `npm test` → test, `npm run build` →
209
+ * build, `git commit` → git, `pip install` → install, etc.
210
+ *
211
+ * Heuristic ordering — most-specific verb checks first; falls through to
212
+ * `run` for generic execution and `other` for anything we can't classify.
213
+ */
214
+ export function classifyCommand(command) {
215
+ if (typeof command !== "string" || command.trim().length === 0) {
216
+ return { command_type: "other", command_tool: "" };
217
+ }
218
+ const raw = command.trim();
219
+ const tool = firstToken(raw).toLowerCase();
220
+ const lower = raw.toLowerCase();
221
+ // ── git ──────────────────────────────────────────────────────────
222
+ if (tool === "git") {
223
+ return { command_type: "git", command_tool: "git" };
224
+ }
225
+ // ── install ──────────────────────────────────────────────────────
226
+ // npm/pnpm/yarn/pip install variants come before generic test/build
227
+ // because `npm install --save-dev jest` would otherwise match test.
228
+ if (/\b(install|add|ci)\b/.test(lower) &&
229
+ (tool === "npm" || tool === "pnpm" || tool === "yarn" || tool === "bun" || tool === "pip" || tool === "pip3")) {
230
+ return { command_type: "install", command_tool: tool };
231
+ }
232
+ if (tool === "brew" && /\binstall\b/.test(lower)) {
233
+ return { command_type: "install", command_tool: "brew" };
234
+ }
235
+ // ── test ─────────────────────────────────────────────────────────
236
+ // Direct test runners, plus `npm test` / `pnpm test` / `yarn test`.
237
+ if (tool === "vitest" || tool === "jest" || tool === "pytest" ||
238
+ tool === "mocha" || tool === "playwright" || tool === "cypress") {
239
+ return { command_type: "test", command_tool: tool };
240
+ }
241
+ if ((tool === "npm" || tool === "pnpm" || tool === "yarn" || tool === "bun") &&
242
+ /\btest\b/.test(lower)) {
243
+ return { command_type: "test", command_tool: tool };
244
+ }
245
+ // ── lint ─────────────────────────────────────────────────────────
246
+ if (tool === "eslint" || tool === "tslint" || tool === "ruff" || tool === "flake8" || tool === "pylint") {
247
+ return { command_type: "lint", command_tool: tool };
248
+ }
249
+ if (/\blint\b/.test(lower) && (tool === "npm" || tool === "pnpm" || tool === "yarn" || tool === "bun")) {
250
+ return { command_type: "lint", command_tool: tool };
251
+ }
252
+ // ── format ───────────────────────────────────────────────────────
253
+ if (tool === "prettier" || tool === "black" || tool === "gofmt" || tool === "rustfmt") {
254
+ return { command_type: "format", command_tool: tool };
255
+ }
256
+ if (/\b(format|fmt)\b/.test(lower) && (tool === "npm" || tool === "pnpm" || tool === "yarn" || tool === "bun")) {
257
+ return { command_type: "format", command_tool: tool };
258
+ }
259
+ // ── build ────────────────────────────────────────────────────────
260
+ if (tool === "tsc" || tool === "webpack" || tool === "rollup" || tool === "vite" || tool === "esbuild" || tool === "make") {
261
+ return { command_type: "build", command_tool: tool };
262
+ }
263
+ if (/\bbuild\b/.test(lower) && (tool === "npm" || tool === "pnpm" || tool === "yarn" || tool === "bun" || tool === "cargo" || tool === "go")) {
264
+ return { command_type: "build", command_tool: tool };
265
+ }
266
+ // ── run ──────────────────────────────────────────────────────────
267
+ // Generic execution — `npm run …` (with no recognised sub-verb),
268
+ // `node script.js`, `python -m foo`, etc.
269
+ if ((tool === "npm" || tool === "pnpm" || tool === "yarn" || tool === "bun") &&
270
+ /\brun\b/.test(lower)) {
271
+ return { command_type: "run", command_tool: tool };
272
+ }
273
+ if (tool === "node" || tool === "python" || tool === "python3" || tool === "deno" || tool === "bun") {
274
+ return { command_type: "run", command_tool: tool };
275
+ }
276
+ // Fallthrough — preserves the tool name so the dashboard can still
277
+ // chart "other" commands by their underlying binary.
278
+ return { command_type: "other", command_tool: tool || "" };
279
+ }
280
+ // ── Duration bucketiser ────────────────────────────────────────────────
281
+ /**
282
+ * Bucketise a latency_ms reading into the four fixed buckets the
283
+ * dashboard renders. Bucket edges (in ms):
284
+ *
285
+ * fast : [0, 1_000)
286
+ * medium : [1_000, 10_000)
287
+ * slow : [10_000, 60_000)
288
+ * timeout : [60_000, ∞)
289
+ *
290
+ * Defensive: negative and non-numeric inputs collapse to `fast` (the
291
+ * neutral bucket) rather than throwing, because hooks have been seen
292
+ * to emit `null` for very short calls.
293
+ */
294
+ export function bucketizeDuration(latencyMs) {
295
+ const n = typeof latencyMs === "number" && Number.isFinite(latencyMs) ? latencyMs : 0;
296
+ if (n < 1_000)
297
+ return "fast";
298
+ if (n < 10_000)
299
+ return "medium";
300
+ if (n < 60_000)
301
+ return "slow";
302
+ return "timeout";
303
+ }