context-mode 1.0.158 → 1.0.160
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/build/adapters/codex/index.d.ts +4 -1
- package/build/adapters/codex/index.js +237 -45
- package/cli.bundle.mjs +181 -178
- package/hooks/posttooluse.mjs +44 -17
- package/hooks/precompact.mjs +30 -23
- package/hooks/session-loaders.mjs +232 -9
- package/hooks/sessionstart.mjs +90 -21
- package/hooks/userpromptsubmit.mjs +14 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +132 -129
package/hooks/posttooluse.mjs
CHANGED
|
@@ -71,12 +71,23 @@ await runHook(async () => {
|
|
|
71
71
|
const colonIdx = rejectedData.indexOf(":");
|
|
72
72
|
const rejTool = colonIdx > 0 ? rejectedData.slice(0, colonIdx) : rejectedData;
|
|
73
73
|
const rejReason = colonIdx > 0 ? rejectedData.slice(colonIdx + 1) : "denied";
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
// v1.0.160: route through attributeAndInsertEvents so the bridge wire
|
|
75
|
+
// receives this event too. db.insertEvent only writes locally — the
|
|
76
|
+
// dashboard's rejection-rate widget needs the platform row.
|
|
77
|
+
attributeAndInsertEvents(
|
|
78
|
+
db,
|
|
79
|
+
sessionId,
|
|
80
|
+
[{
|
|
81
|
+
type: "rejected",
|
|
82
|
+
category: "rejected-approach",
|
|
83
|
+
data: `${rejTool}: ${rejReason}`,
|
|
84
|
+
priority: 2,
|
|
85
|
+
}],
|
|
86
|
+
input,
|
|
87
|
+
projectDir,
|
|
88
|
+
"PreToolUse",
|
|
89
|
+
resolveProjectAttributions,
|
|
90
|
+
);
|
|
80
91
|
}
|
|
81
92
|
} catch { /* best-effort */ }
|
|
82
93
|
|
|
@@ -108,17 +119,24 @@ await runHook(async () => {
|
|
|
108
119
|
const summary = redirectData.slice(i3 + 1);
|
|
109
120
|
const bytesAvoided = Number.parseInt(bytesRaw, 10);
|
|
110
121
|
if (Number.isFinite(bytesAvoided) && bytesAvoided > 0) {
|
|
111
|
-
|
|
122
|
+
// v1.0.160: route through wire — context-saving (byte-accounting)
|
|
123
|
+
// widget on the platform reads category='redirect' rows. event
|
|
124
|
+
// carries bytes_avoided so the bytesList branch in
|
|
125
|
+
// attributeAndInsertEvents stamps the column.
|
|
126
|
+
attributeAndInsertEvents(
|
|
127
|
+
db,
|
|
112
128
|
sessionId,
|
|
113
|
-
{
|
|
129
|
+
[{
|
|
114
130
|
type,
|
|
115
131
|
category: "redirect",
|
|
116
132
|
data: `${tool}: ${summary}`,
|
|
117
133
|
priority: 2,
|
|
118
|
-
|
|
134
|
+
bytes_avoided: bytesAvoided,
|
|
135
|
+
}],
|
|
136
|
+
input,
|
|
137
|
+
projectDir,
|
|
119
138
|
"PreToolUse",
|
|
120
|
-
|
|
121
|
-
{ bytesAvoided, bytesReturned: 0 },
|
|
139
|
+
resolveProjectAttributions,
|
|
122
140
|
);
|
|
123
141
|
}
|
|
124
142
|
}
|
|
@@ -140,12 +158,21 @@ await runHook(async () => {
|
|
|
140
158
|
if (startTime && !isNaN(startTime)) {
|
|
141
159
|
const duration = Date.now() - startTime;
|
|
142
160
|
if (duration > 5000) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
161
|
+
// v1.0.160: route through wire — slow-tool insights need this row.
|
|
162
|
+
attributeAndInsertEvents(
|
|
163
|
+
db,
|
|
164
|
+
sessionId,
|
|
165
|
+
[{
|
|
166
|
+
type: "tool_latency",
|
|
167
|
+
category: "latency",
|
|
168
|
+
data: `${toolName}: ${duration}ms`,
|
|
169
|
+
priority: 3,
|
|
170
|
+
}],
|
|
171
|
+
input,
|
|
172
|
+
projectDir,
|
|
173
|
+
"PostToolUse",
|
|
174
|
+
resolveProjectAttributions,
|
|
175
|
+
);
|
|
149
176
|
}
|
|
150
177
|
}
|
|
151
178
|
}
|
package/hooks/precompact.mjs
CHANGED
|
@@ -17,16 +17,17 @@ await runHook(async () => {
|
|
|
17
17
|
parseStdin,
|
|
18
18
|
getSessionId,
|
|
19
19
|
getSessionDBPath,
|
|
20
|
+
getInputProjectDir,
|
|
20
21
|
resolveConfigDir,
|
|
21
22
|
} = await import("./session-helpers.mjs");
|
|
22
|
-
const { createSessionLoaders } = await import("./session-loaders.mjs");
|
|
23
|
+
const { createSessionLoaders, attributeAndInsertEvents } = await import("./session-loaders.mjs");
|
|
23
24
|
const { appendFileSync } = await import("node:fs");
|
|
24
25
|
const { join, dirname } = await import("node:path");
|
|
25
26
|
const { fileURLToPath } = await import("node:url");
|
|
26
27
|
|
|
27
28
|
// Resolve absolute path for imports
|
|
28
29
|
const HOOK_DIR = dirname(fileURLToPath(import.meta.url));
|
|
29
|
-
const { loadSessionDB, loadSnapshot } = createSessionLoaders(HOOK_DIR);
|
|
30
|
+
const { loadSessionDB, loadSnapshot, loadProjectAttribution } = createSessionLoaders(HOOK_DIR);
|
|
30
31
|
const DEBUG_LOG = join(resolveConfigDir(), "context-mode", "precompact-debug.log");
|
|
31
32
|
|
|
32
33
|
try {
|
|
@@ -52,31 +53,37 @@ await runHook(async () => {
|
|
|
52
53
|
db.upsertResume(sessionId, snapshot, events.length);
|
|
53
54
|
db.incrementCompactCount(sessionId);
|
|
54
55
|
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
type: "compaction_summary",
|
|
59
|
-
category: "compaction",
|
|
60
|
-
data: `Session compacted. ${events.length} events, ${fileEvents.length} files touched.`,
|
|
61
|
-
priority: 1,
|
|
62
|
-
}, "PreCompact");
|
|
63
|
-
|
|
64
|
-
// D2 PRD Phase 6.1: emit snapshot-built event with bytes_avoided=snapshot.length
|
|
65
|
-
// Snapshot bytes are bytes the model would have re-read on resume but didn't.
|
|
56
|
+
// v1.0.160: route compaction lifecycle events through wire so
|
|
57
|
+
// dashboard's compact widget gets per-compaction rows (the engine
|
|
58
|
+
// joins on category='compaction' to compute snapshot insights).
|
|
66
59
|
try {
|
|
67
|
-
|
|
60
|
+
const fileEvents = events.filter(e => e.category === "file");
|
|
61
|
+
const projectDirCompact = getInputProjectDir(input);
|
|
62
|
+
const { resolveProjectAttributions } = await loadProjectAttribution();
|
|
63
|
+
attributeAndInsertEvents(
|
|
64
|
+
db,
|
|
68
65
|
sessionId,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
[
|
|
67
|
+
{
|
|
68
|
+
type: "compaction_summary",
|
|
69
|
+
category: "compaction",
|
|
70
|
+
data: `Session compacted. ${events.length} events, ${fileEvents.length} files touched.`,
|
|
71
|
+
priority: 1,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
type: "snapshot-built",
|
|
75
|
+
category: "compaction",
|
|
76
|
+
data: `Snapshot built. ${snapshot.length} bytes for ${events.length} events.`,
|
|
77
|
+
priority: 1,
|
|
78
|
+
bytes_avoided: snapshot.length,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
input,
|
|
82
|
+
projectDirCompact,
|
|
75
83
|
"PreCompact",
|
|
76
|
-
|
|
77
|
-
{ bytesAvoided: snapshot.length, bytesReturned: 0 },
|
|
84
|
+
resolveProjectAttributions,
|
|
78
85
|
);
|
|
79
|
-
} catch { /* best-effort */ }
|
|
86
|
+
} catch { /* best-effort — never block PreCompact */ }
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
db.close();
|
|
@@ -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";
|
|
@@ -82,13 +83,21 @@ export function attributeAndInsertEvents(db, sessionId, events, input, projectDi
|
|
|
82
83
|
// no event carries a positive value we leave bytesList undefined so
|
|
83
84
|
// SessionDB falls back to its 0-default for bytes_avoided/bytes_returned
|
|
84
85
|
// — preserves backward compat with older callers / tests.
|
|
86
|
+
// v1.0.160: handle both bytes_avoided (saved) and bytes_returned (resume
|
|
87
|
+
// snapshot replay) so the snapshot-consumed event from sessionstart.mjs
|
|
88
|
+
// routes through here without losing the bytes_returned column.
|
|
85
89
|
let bytesList;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
)
|
|
90
|
+
const hasBytes = events.some((e) =>
|
|
91
|
+
(typeof e?.bytes_avoided === "number" && e.bytes_avoided > 0) ||
|
|
92
|
+
(typeof e?.bytes_returned === "number" && e.bytes_returned > 0),
|
|
93
|
+
);
|
|
94
|
+
if (hasBytes) {
|
|
95
|
+
bytesList = events.map((e) => {
|
|
96
|
+
const avoided = typeof e?.bytes_avoided === "number" && e.bytes_avoided > 0 ? e.bytes_avoided : 0;
|
|
97
|
+
const returned = typeof e?.bytes_returned === "number" && e.bytes_returned > 0 ? e.bytes_returned : 0;
|
|
98
|
+
if (avoided === 0 && returned === 0) return undefined;
|
|
99
|
+
return { bytesAvoided: avoided, bytesReturned: returned };
|
|
100
|
+
});
|
|
92
101
|
}
|
|
93
102
|
// Prefer bulk path (single transaction = single WAL commit). Falls back
|
|
94
103
|
// to per-event insert for older SessionDB instances that lack bulkInsertEvents.
|
|
@@ -115,9 +124,25 @@ export function attributeAndInsertEvents(db, sessionId, events, input, projectDi
|
|
|
115
124
|
? db.getSessionRollup(sessionId)
|
|
116
125
|
: null;
|
|
117
126
|
|
|
127
|
+
// v1.0.159: Bash metadata shared across all events from this hook fire.
|
|
128
|
+
// A single Bash tool call may emit multiple canonical events (a `git
|
|
129
|
+
// pull` produces type=git AND type=cwd) — they all share the same
|
|
130
|
+
// command_type / command_tool / exit_code / duration_bucket. Hook
|
|
131
|
+
// metadata (latency, exit_code) is also per-call, not per-event.
|
|
132
|
+
const bashMeta = deriveBashMetadata(input);
|
|
133
|
+
// v1.0.159: latency_ms read from the PreToolUse timestamp stamp.
|
|
134
|
+
// PreToolUse writes ms-precision Date.now() to a tmp file, PostToolUse
|
|
135
|
+
// reads + computes delta + cleans up. Failure → undefined (no field
|
|
136
|
+
// surfaces on the wire; Zod is optional).
|
|
137
|
+
const latencyMs = readLatencyMs(sessionId, input?.tool_name);
|
|
138
|
+
|
|
118
139
|
for (let i = 0; i < events.length; i++) {
|
|
119
140
|
const enriched = enrichEventForPlatform(events[i], attributions[i]);
|
|
120
|
-
const
|
|
141
|
+
const withBash = bashMeta ? { ...enriched, ...bashMeta } : enriched;
|
|
142
|
+
const withLatency = latencyMs !== undefined
|
|
143
|
+
? { ...withBash, latency_ms: latencyMs, duration_bucket: bucketizeDuration(latencyMs) }
|
|
144
|
+
: withBash;
|
|
145
|
+
const payload = rollup ? { ...withLatency, ...rollup } : withLatency;
|
|
121
146
|
maybeForward({ ...payload, session_id: sessionId }, platform);
|
|
122
147
|
}
|
|
123
148
|
}
|
|
@@ -161,6 +186,15 @@ function enrichEventForPlatform(event, attribution) {
|
|
|
161
186
|
enriched.error_tool = cls.error_tool;
|
|
162
187
|
}
|
|
163
188
|
|
|
189
|
+
// blocker_status: derive from the canonical event TYPE, not lexical
|
|
190
|
+
// pattern-matching on prose. session-extract already identifies blocker
|
|
191
|
+
// states semantically (type='blocker' when the agent signals stuck;
|
|
192
|
+
// type='blocker_resolved' on recovery). Regex on error_message would
|
|
193
|
+
// false-positive on the millions of error texts in the wild — we let
|
|
194
|
+
// the extractor's structural judgment be the source of truth.
|
|
195
|
+
if (event?.type === "blocker") enriched.blocker_status = "open";
|
|
196
|
+
else if (event?.type === "blocker_resolved") enriched.blocker_status = "resolved";
|
|
197
|
+
|
|
164
198
|
// Git events: surface commit message + mark has_commit at the event level
|
|
165
199
|
// (rollup-level has_commit comes from the session-wide stamp; both win
|
|
166
200
|
// when set — `{...enriched, ...rollup}` order keeps rollup authoritative
|
|
@@ -205,3 +239,192 @@ function classifyError(message) {
|
|
|
205
239
|
if (/test failed|fail |tests failed|assertion/.test(m)) return { error_category: "test_failed", error_tool: "Bash" };
|
|
206
240
|
return { error_category: "unknown", error_tool: "Bash" };
|
|
207
241
|
}
|
|
242
|
+
|
|
243
|
+
// ── Bash metadata derivation — algorithmic, not enumerative ──────────────
|
|
244
|
+
//
|
|
245
|
+
// A single Bash tool call may emit MULTIPLE canonical events (a `git pull`
|
|
246
|
+
// produces type='git' AND type='cwd'). The platform's command_metadata
|
|
247
|
+
// describes the BASH CALL, not the per-event derivative — so all events
|
|
248
|
+
// from one PostToolUse fire carry the same shape. Non-Bash tool calls
|
|
249
|
+
// return null and the per-event fields stay undefined (Zod optional drops
|
|
250
|
+
// them silently — no NULL noise on the wire).
|
|
251
|
+
//
|
|
252
|
+
// DESIGN: tool ecosystems contain millions of CLI binaries but converge on
|
|
253
|
+
// a tiny canonical verb set (test/build/install/lint/format/run/start/
|
|
254
|
+
// deploy/...). The classifier scans for these verbs at canonical token
|
|
255
|
+
// positions — agnostic of which package manager / language / framework.
|
|
256
|
+
// New tools without a registry change automatically classify correctly as
|
|
257
|
+
// long as they use the verbs (which is the dominant ecosystem convention).
|
|
258
|
+
// This was originally regex-table enumeration; the table never converges.
|
|
259
|
+
const CANONICAL_VERBS = new Set([
|
|
260
|
+
"test", "build", "install", "lint", "format", "run", "start",
|
|
261
|
+
"deploy", "compile", "bundle", "watch", "serve", "publish",
|
|
262
|
+
]);
|
|
263
|
+
// Runners that wrap the actual executable — strip them so command_tool
|
|
264
|
+
// reflects the real binary the user invoked (`bunx pytest` → "pytest",
|
|
265
|
+
// not "bunx"). NODE_ENV=production npm run build → "npm".
|
|
266
|
+
const COMMAND_RUNNERS = new Set([
|
|
267
|
+
"sudo", "doas", "env", "exec", "time",
|
|
268
|
+
"npx", "pnpx", "bunx", "pnpm", "yarn", "bun",
|
|
269
|
+
]);
|
|
270
|
+
const ENV_ASSIGN_RE = /^[A-Z_][A-Z0-9_]*=/;
|
|
271
|
+
|
|
272
|
+
// Tools whose NAME directly implies their type (no subcommand needed).
|
|
273
|
+
// Curated minimum — covers the dominant test/lint/format/build/db/http/
|
|
274
|
+
// deploy invocations across ecosystems. New ecosystem tools land in
|
|
275
|
+
// "other" until added — preferred to a noisy heuristic that misclassifies.
|
|
276
|
+
// Lookup is O(1); contrast with the original regex-table approach which
|
|
277
|
+
// scaled to no boundary and still missed unknowns.
|
|
278
|
+
const CANONICAL_TOOLS = new Map([
|
|
279
|
+
// test runners
|
|
280
|
+
["pytest", "test"], ["jest", "test"], ["vitest", "test"], ["mocha", "test"],
|
|
281
|
+
["ava", "test"], ["jasmine", "test"], ["rspec", "test"], ["junit", "test"],
|
|
282
|
+
["tap", "test"], ["karma", "test"],
|
|
283
|
+
// linters
|
|
284
|
+
["eslint", "lint"], ["tslint", "lint"], ["ruff", "lint"], ["rubocop", "lint"],
|
|
285
|
+
["pylint", "lint"], ["flake8", "lint"], ["clippy", "lint"], ["staticcheck", "lint"],
|
|
286
|
+
["mypy", "lint"], ["shellcheck", "lint"],
|
|
287
|
+
// formatters
|
|
288
|
+
["prettier", "format"], ["black", "format"], ["gofmt", "format"], ["rustfmt", "format"],
|
|
289
|
+
["autopep8", "format"], ["yapf", "format"],
|
|
290
|
+
// bundlers / builders
|
|
291
|
+
["webpack", "build"], ["vite", "build"], ["rollup", "build"], ["esbuild", "build"],
|
|
292
|
+
["parcel", "build"], ["tsc", "build"], ["swc", "build"], ["turbo", "build"],
|
|
293
|
+
// deploy / infra
|
|
294
|
+
["docker", "deploy"], ["kubectl", "deploy"], ["terraform", "deploy"], ["pulumi", "deploy"],
|
|
295
|
+
["ansible", "deploy"], ["helm", "deploy"], ["aws", "deploy"], ["gcloud", "deploy"], ["az", "deploy"],
|
|
296
|
+
// databases
|
|
297
|
+
["psql", "database"], ["mysql", "database"], ["sqlite3", "database"],
|
|
298
|
+
["redis-cli", "database"], ["mongosh", "database"], ["mongo", "database"],
|
|
299
|
+
// http
|
|
300
|
+
["curl", "http"], ["wget", "http"], ["httpie", "http"], ["http", "http"],
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
function deriveBashMetadata(input) {
|
|
304
|
+
if (input?.tool_name !== "Bash") return null;
|
|
305
|
+
const cmd = String(input?.tool_input?.command ?? "").trim();
|
|
306
|
+
if (!cmd) return { command_type: "other", command_tool: "Bash" };
|
|
307
|
+
|
|
308
|
+
const tokens = cmd.split(/\s+/);
|
|
309
|
+
const command_tool = extractCommandTool(tokens);
|
|
310
|
+
const command_type = classifyCommandType(tokens, command_tool);
|
|
311
|
+
const exit_code = inferExitCode(input?.tool_response);
|
|
312
|
+
return { command_type, command_tool, exit_code };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Strip env-assign prefixes (`FOO=bar`), then strip runner shells,
|
|
316
|
+
// then return the basename of the executable token.
|
|
317
|
+
function extractCommandTool(tokens) {
|
|
318
|
+
let i = 0;
|
|
319
|
+
// Skip env assignments
|
|
320
|
+
while (i < tokens.length && ENV_ASSIGN_RE.test(tokens[i])) i++;
|
|
321
|
+
// Skip runner shells
|
|
322
|
+
while (i < tokens.length && COMMAND_RUNNERS.has(tokens[i].toLowerCase())) {
|
|
323
|
+
i++;
|
|
324
|
+
// Skip subcommands like `pnpm dlx`, `pnpm exec`, `bun run`
|
|
325
|
+
if (i < tokens.length && /^(dlx|exec|run|x)$/i.test(tokens[i])) i++;
|
|
326
|
+
}
|
|
327
|
+
if (i >= tokens.length) return tokens[0] || "Bash";
|
|
328
|
+
const exe = tokens[i];
|
|
329
|
+
// basename of path-like executables (`/usr/local/bin/foo` → "foo")
|
|
330
|
+
const base = exe.split(/[/\\]/).pop() || "Bash";
|
|
331
|
+
// Strip shell quoting if present
|
|
332
|
+
return base.replace(/^['"]|['"]$/g, "");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Type classification — priority order:
|
|
336
|
+
// 1. Tool name implies type (curated CANONICAL_TOOLS map)
|
|
337
|
+
// 2. Canonical verb at subcommand position (`npm test`, `cargo build`)
|
|
338
|
+
// 3. Argument-shape heuristics (test/ dir, .test.ts suffix, --prod flag)
|
|
339
|
+
// 4. Tool-level fallback (git → git, make → build)
|
|
340
|
+
// 5. "other"
|
|
341
|
+
function classifyCommandType(tokens, command_tool) {
|
|
342
|
+
const toolLc = (command_tool || "").toLowerCase();
|
|
343
|
+
|
|
344
|
+
// 1. Tool name itself names the type
|
|
345
|
+
const fromTool = CANONICAL_TOOLS.get(toolLc);
|
|
346
|
+
if (fromTool) return fromTool;
|
|
347
|
+
|
|
348
|
+
// Skip env + runners to find subcommand position
|
|
349
|
+
const lower = tokens.map((t) => t.toLowerCase());
|
|
350
|
+
let start = 0;
|
|
351
|
+
while (start < lower.length && ENV_ASSIGN_RE.test(tokens[start])) start++;
|
|
352
|
+
while (start < lower.length && COMMAND_RUNNERS.has(lower[start])) {
|
|
353
|
+
start++;
|
|
354
|
+
if (start < lower.length && /^(dlx|exec|run|x)$/.test(lower[start])) start++;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 2. Canonical verb scan within next 4 tokens
|
|
358
|
+
const horizon = Math.min(lower.length, start + 4);
|
|
359
|
+
for (let i = start; i < horizon; i++) {
|
|
360
|
+
if (CANONICAL_VERBS.has(lower[i])) return lower[i];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 3. Argument-shape heuristics
|
|
364
|
+
const tail = tokens.slice(start).join(" ");
|
|
365
|
+
if (/\btests?[/\\]|\bspec[/\\]|__tests__|\.(test|spec)\.[mc]?[jt]sx?\b|test_[\w-]+\.py\b|_test\.go\b/.test(tail)) return "test";
|
|
366
|
+
if (/--(prod|production|release|optimize)\b/.test(tail)) return "build";
|
|
367
|
+
if (/\bDockerfile\b|docker-compose/.test(tail)) return "deploy";
|
|
368
|
+
|
|
369
|
+
// 4. Tool-level fallback for tools whose mere presence implies the type
|
|
370
|
+
if (toolLc === "git") return "git";
|
|
371
|
+
if (toolLc === "make" || toolLc === "ninja" || toolLc === "cmake") return "build";
|
|
372
|
+
|
|
373
|
+
return "other";
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Exit code best-effort inference from tool_response. Hook stdin does
|
|
377
|
+
// not carry the actual exit code on CC; we read the shape of the output
|
|
378
|
+
// for signals. Engine treats exit_code as soft signal (Anomaly #3 — no
|
|
379
|
+
// pattern in patterns.ts reads it today), so probabilistic stamps are
|
|
380
|
+
// adequate. Captures named exit code when explicit.
|
|
381
|
+
function inferExitCode(response) {
|
|
382
|
+
const r = String(response ?? "");
|
|
383
|
+
if (!r) return 0;
|
|
384
|
+
// Explicit exit-code marker (some wrappers emit "exit status 137" etc.)
|
|
385
|
+
const explicit = r.match(/\bexit (?:status|code)\s+(\d+)\b/i);
|
|
386
|
+
if (explicit) return Number(explicit[1]);
|
|
387
|
+
// "command not found" → POSIX standard 127
|
|
388
|
+
if (/^bash:.*: (?:command not found|No such file)/m.test(r)) return 127;
|
|
389
|
+
// Heuristic non-zero indicators (line-anchored to avoid false positives
|
|
390
|
+
// inside narrative text from successful commands).
|
|
391
|
+
if (/^(?:Error: |Traceback|FAIL\b|✗|✘)/m.test(r)) return 1;
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Latency timing — reads PreToolUse marker ────────────────────────────
|
|
396
|
+
//
|
|
397
|
+
// PreToolUse already writes `${tmpdir}/context-mode-latency-${sessionId}-
|
|
398
|
+
// ${toolName}.txt` with the start timestamp (pretooluse.mjs:177). We
|
|
399
|
+
// piggyback on that marker — read + compute delta, do NOT unlink (the
|
|
400
|
+
// downstream slow-tool event emission in posttooluse.mjs:128-152 manages
|
|
401
|
+
// the unlink lifecycle). Failure modes (missing marker, parse error,
|
|
402
|
+
// negative delta, sanity-out-of-range) all return undefined — Zod's
|
|
403
|
+
// optional handling drops the field silently. No NULL noise on the wire.
|
|
404
|
+
function readLatencyMs(sessionId, toolName) {
|
|
405
|
+
if (!sessionId || !toolName) return undefined;
|
|
406
|
+
const markerPath = resolvePath(
|
|
407
|
+
tmpdir(),
|
|
408
|
+
`context-mode-latency-${sessionId}-${toolName}.txt`,
|
|
409
|
+
);
|
|
410
|
+
try {
|
|
411
|
+
const start = parseInt(readFileSync(markerPath, "utf8").trim(), 10);
|
|
412
|
+
if (!Number.isFinite(start) || start <= 0) return undefined;
|
|
413
|
+
const delta = Date.now() - start;
|
|
414
|
+
if (delta < 0 || delta > 24 * 3600 * 1000) return undefined;
|
|
415
|
+
return delta;
|
|
416
|
+
} catch {
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── Duration bucket ──────────────────────────────────────────────────────
|
|
422
|
+
//
|
|
423
|
+
// Open-string label the platform Zod schema accepts (max 20 chars). Three
|
|
424
|
+
// buckets cover the seed.ts shape: <5s | 5-30s | 30s+.
|
|
425
|
+
function bucketizeDuration(ms) {
|
|
426
|
+
if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return undefined;
|
|
427
|
+
if (ms < 5_000) return "<5s";
|
|
428
|
+
if (ms < 30_000) return "5-30s";
|
|
429
|
+
return "30s+";
|
|
430
|
+
}
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -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
|
|
@@ -166,42 +200,48 @@ await runHook(async () => {
|
|
|
166
200
|
// D2 PRD Phase 6.2: emit snapshot-consumed with bytes_returned=snapshot.length.
|
|
167
201
|
// The resumed snapshot bytes ARE returned to the model — that's the whole
|
|
168
202
|
// point of resume — so account them on bytes_returned, not bytes_avoided.
|
|
203
|
+
// v1.0.160: route through wire — resume metric on the platform reads
|
|
204
|
+
// category='session-resume' rows. Both snapshot-consumed (bytes
|
|
205
|
+
// returned) and resume_completed land here so the dashboard sees
|
|
206
|
+
// every resume boundary.
|
|
169
207
|
try {
|
|
170
208
|
const resumeRow = (resume && resume.snapshot)
|
|
171
209
|
? resume
|
|
172
210
|
: (db.getResume?.(sessionId) ?? null);
|
|
173
211
|
const snapshotBytes = resumeRow?.snapshot?.length ?? 0;
|
|
212
|
+
const { resolveProjectAttributions } = await loadProjectAttribution();
|
|
213
|
+
const projectDirResumeMeta = getInputProjectDir(input);
|
|
174
214
|
|
|
175
|
-
|
|
215
|
+
await attributeAndInsertEvents(
|
|
216
|
+
db,
|
|
176
217
|
sessionId,
|
|
177
|
-
{
|
|
218
|
+
[{
|
|
178
219
|
type: "snapshot-consumed",
|
|
179
220
|
category: "session-resume",
|
|
180
221
|
data: `Session resumed from ${source}. Snapshot ${snapshotBytes} bytes injected.`,
|
|
181
222
|
priority: 1,
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
undefined,
|
|
185
|
-
{ bytesAvoided: 0, bytesReturned: snapshotBytes },
|
|
186
|
-
);
|
|
187
|
-
} catch { /* best-effort */ }
|
|
188
|
-
|
|
189
|
-
// Legacy resume_completed event retained for back-compat with existing
|
|
190
|
-
// analytics consumers that filter on `type === 'resume_completed'`.
|
|
191
|
-
try {
|
|
192
|
-
db.insertEvent(
|
|
193
|
-
sessionId,
|
|
194
|
-
{
|
|
223
|
+
bytes_returned: snapshotBytes,
|
|
224
|
+
}, {
|
|
195
225
|
type: "resume_completed",
|
|
196
226
|
category: "session-resume",
|
|
197
227
|
data: `Session resumed from ${source}. Prior events loaded.`,
|
|
198
228
|
priority: 1,
|
|
199
|
-
},
|
|
229
|
+
}],
|
|
230
|
+
input,
|
|
231
|
+
projectDirResumeMeta,
|
|
200
232
|
"SessionStart",
|
|
233
|
+
resolveProjectAttributions,
|
|
201
234
|
);
|
|
202
235
|
} catch { /* best-effort */ }
|
|
203
236
|
}
|
|
204
237
|
|
|
238
|
+
// Emit lifecycle anchor BEFORE close — engine joins on
|
|
239
|
+
// category='session_start' to compute per-session aggregates.
|
|
240
|
+
// Cross-platform projectDir via getInputProjectDir (covers cursor's
|
|
241
|
+
// workspace_roots[], codex/gemini/qwen's *_PROJECT_DIR env vars,
|
|
242
|
+
// CC's CLAUDE_PROJECT_DIR, falls back to input.cwd and process.cwd).
|
|
243
|
+
const projectDirCompact = getInputProjectDir(input);
|
|
244
|
+
await emitSessionStartLifecycle(db, sessionId, "compact", projectDirCompact, input);
|
|
205
245
|
db.close();
|
|
206
246
|
} else if (source === "resume") {
|
|
207
247
|
// User invoked --continue, --resume, or /resume — clear cleanup flag so
|
|
@@ -234,6 +274,10 @@ await runHook(async () => {
|
|
|
234
274
|
}
|
|
235
275
|
}
|
|
236
276
|
|
|
277
|
+
const projectDirResume = getInputProjectDir(input);
|
|
278
|
+
if (sessionId) {
|
|
279
|
+
await emitSessionStartLifecycle(db, sessionId, "resume", projectDirResume, input);
|
|
280
|
+
}
|
|
237
281
|
db.close();
|
|
238
282
|
} else if (source === "startup") {
|
|
239
283
|
// Fresh session (no --continue) — clean slate, capture CLAUDE.md rules.
|
|
@@ -277,22 +321,47 @@ await runHook(async () => {
|
|
|
277
321
|
// context at startup, invisible to PostToolUse hooks. We read them from
|
|
278
322
|
// disk so they survive compact/resume via the session events pipeline.
|
|
279
323
|
const sessionId = getSessionId(input);
|
|
280
|
-
|
|
324
|
+
// v1.0.160: cross-adapter projectDir resolution (was hardcoded CC env).
|
|
325
|
+
const projectDir = getInputProjectDir(input);
|
|
281
326
|
db.ensureSession(sessionId, projectDir);
|
|
282
327
|
const claudeMdPaths = [
|
|
283
328
|
join(resolveConfigDir(), "CLAUDE.md"),
|
|
284
329
|
join(projectDir, "CLAUDE.md"),
|
|
285
330
|
join(projectDir, ".claude", "CLAUDE.md"),
|
|
286
331
|
];
|
|
332
|
+
// v1.0.160: collect rule events into a batch and forward through wire.
|
|
333
|
+
// Dashboard's "CLAUDE.md adoption" widget COUNTs category='rule' rows on
|
|
334
|
+
// the platform — without this routing the widget reads 0 no matter how
|
|
335
|
+
// many CLAUDE.md files actually loaded.
|
|
336
|
+
const ruleEvents = [];
|
|
287
337
|
for (const p of claudeMdPaths) {
|
|
288
338
|
try {
|
|
289
339
|
const content = readFileSync(p, "utf-8");
|
|
290
340
|
if (content.trim()) {
|
|
291
|
-
|
|
292
|
-
|
|
341
|
+
ruleEvents.push({ type: "rule", category: "rule", data: p, priority: 1 });
|
|
342
|
+
ruleEvents.push({ type: "rule_content", category: "rule", data: content, priority: 1 });
|
|
293
343
|
}
|
|
294
344
|
} catch { /* file doesn't exist — skip */ }
|
|
295
345
|
}
|
|
346
|
+
if (ruleEvents.length > 0) {
|
|
347
|
+
try {
|
|
348
|
+
const { resolveProjectAttributions } = await loadProjectAttribution();
|
|
349
|
+
attributeAndInsertEvents(
|
|
350
|
+
db,
|
|
351
|
+
sessionId,
|
|
352
|
+
ruleEvents,
|
|
353
|
+
input,
|
|
354
|
+
projectDir,
|
|
355
|
+
"SessionStart",
|
|
356
|
+
resolveProjectAttributions,
|
|
357
|
+
);
|
|
358
|
+
} catch { /* best-effort — rule capture must never block start */ }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Lifecycle anchor for a fresh session — emits BEFORE the CLAUDE.md
|
|
362
|
+
// rule events have been forwarded so the `session_start` row lands
|
|
363
|
+
// as the very first row the platform sees for this session.
|
|
364
|
+
await emitSessionStartLifecycle(db, sessionId, "startup", projectDir, input);
|
|
296
365
|
|
|
297
366
|
db.close();
|
|
298
367
|
|
|
@@ -77,8 +77,20 @@ await runHook(async () => {
|
|
|
77
77
|
workspaceRoots: Array.isArray(input.workspace_roots) ? input.workspace_roots : [],
|
|
78
78
|
lastKnownProjectDir: savedLastKnown || lastKnownProjectDir,
|
|
79
79
|
});
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// v1.0.160: route through wire so prompt-derived events (decision /
|
|
81
|
+
// role / intent / data extractions) reach the platform. Previously
|
|
82
|
+
// they only landed in local SessionDB → dashboard's prompt-flow
|
|
83
|
+
// insights stayed at 0.
|
|
84
|
+
if (userEvents.length > 0) {
|
|
85
|
+
attributeAndInsertEvents(
|
|
86
|
+
db,
|
|
87
|
+
sessionId,
|
|
88
|
+
userEvents,
|
|
89
|
+
input,
|
|
90
|
+
projectDir,
|
|
91
|
+
"UserPromptSubmit",
|
|
92
|
+
resolveProjectAttributions,
|
|
93
|
+
);
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
db.close();
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
6
|
+
"version": "1.0.160",
|
|
7
7
|
"sandbox": {
|
|
8
8
|
"mode": "permissive",
|
|
9
9
|
"filesystem_access": "full",
|