context-mode 1.0.21 → 1.0.23
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 +4 -2
- package/.openclaw-plugin/index.ts +11 -0
- package/.openclaw-plugin/openclaw.plugin.json +23 -0
- package/.openclaw-plugin/package.json +28 -0
- package/README.md +165 -26
- package/build/adapters/antigravity/index.d.ts +49 -0
- package/build/adapters/antigravity/index.js +217 -0
- package/build/adapters/client-map.d.ts +10 -0
- package/build/adapters/client-map.js +18 -0
- package/build/adapters/detect.d.ts +8 -1
- package/build/adapters/detect.js +58 -1
- package/build/adapters/kiro/hooks.d.ts +32 -0
- package/build/adapters/kiro/hooks.js +47 -0
- package/build/adapters/kiro/index.d.ts +50 -0
- package/build/adapters/kiro/index.js +325 -0
- package/build/adapters/openclaw/config.d.ts +8 -0
- package/build/adapters/openclaw/config.js +8 -0
- package/build/adapters/openclaw/hooks.d.ts +50 -0
- package/build/adapters/openclaw/hooks.js +61 -0
- package/build/adapters/openclaw/index.d.ts +51 -0
- package/build/adapters/openclaw/index.js +459 -0
- package/build/adapters/openclaw/session-db.d.ts +55 -0
- package/build/adapters/openclaw/session-db.js +88 -0
- package/build/adapters/types.d.ts +1 -1
- package/build/cli.js +5 -3
- package/build/executor.js +99 -112
- package/build/openclaw/workspace-router.d.ts +29 -0
- package/build/openclaw/workspace-router.js +64 -0
- package/build/openclaw-plugin.d.ts +121 -0
- package/build/openclaw-plugin.js +525 -0
- package/build/server.js +45 -10
- package/build/session/db.d.ts +9 -0
- package/build/session/db.js +38 -0
- package/cli.bundle.mjs +136 -124
- package/configs/antigravity/GEMINI.md +58 -0
- package/configs/antigravity/mcp_config.json +7 -0
- package/configs/kiro/mcp_config.json +7 -0
- package/configs/openclaw/AGENTS.md +58 -0
- package/configs/openclaw/openclaw.json +13 -0
- package/hooks/core/routing.mjs +16 -8
- package/hooks/kiro/posttooluse.mjs +58 -0
- package/hooks/kiro/pretooluse.mjs +63 -0
- package/hooks/posttooluse.mjs +6 -5
- package/hooks/precompact.mjs +5 -4
- package/hooks/session-db.bundle.mjs +57 -0
- package/hooks/session-extract.bundle.mjs +1 -0
- package/hooks/session-helpers.mjs +41 -3
- package/hooks/session-loaders.mjs +28 -0
- package/hooks/session-snapshot.bundle.mjs +14 -0
- package/hooks/sessionstart.mjs +6 -5
- package/hooks/userpromptsubmit.mjs +6 -5
- package/hooks/vscode-copilot/posttooluse.mjs +5 -4
- package/hooks/vscode-copilot/precompact.mjs +5 -4
- package/hooks/vscode-copilot/sessionstart.mjs +5 -4
- package/openclaw.plugin.json +23 -0
- package/package.json +13 -2
- package/server.bundle.mjs +94 -82
- package/start.mjs +1 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw TypeScript plugin entry point for context-mode.
|
|
3
|
+
*
|
|
4
|
+
* Exports an object with { id, name, configSchema, register(api) } for
|
|
5
|
+
* declarative metadata and config validation before code execution.
|
|
6
|
+
*
|
|
7
|
+
* register(api) registers:
|
|
8
|
+
* - before_tool_call hook — Routing enforcement (deny/modify/passthrough)
|
|
9
|
+
* - after_tool_call hook — Session event capture
|
|
10
|
+
* - command:new hook — Session initialization and cleanup
|
|
11
|
+
* - session_start hook — Re-key DB session to OpenClaw's session ID
|
|
12
|
+
* - before_compaction hook — Flush events to resume snapshot
|
|
13
|
+
* - after_compaction hook — Increment compact count
|
|
14
|
+
* - before_prompt_build (p=10) — Resume snapshot injection into system context
|
|
15
|
+
* - before_prompt_build (p=5) — Routing instruction injection into system context
|
|
16
|
+
* - context-mode engine — Context engine with compaction management
|
|
17
|
+
* - /ctx-stats command — Auto-reply command for session statistics
|
|
18
|
+
* - /ctx-doctor command — Auto-reply command for diagnostics
|
|
19
|
+
* - /ctx-upgrade command — Auto-reply command for upgrade
|
|
20
|
+
*
|
|
21
|
+
* Loaded by OpenClaw via: openclaw.extensions entry in package.json
|
|
22
|
+
*
|
|
23
|
+
* OpenClaw plugin paradigm:
|
|
24
|
+
* - Plugins export { id, name, configSchema, register(api) } for metadata
|
|
25
|
+
* - api.registerHook() for event-driven hooks
|
|
26
|
+
* - api.on() for typed lifecycle hooks
|
|
27
|
+
* - api.registerContextEngine() for compaction ownership
|
|
28
|
+
* - api.registerCommand() for auto-reply slash commands
|
|
29
|
+
* - Plugins run in-process with the Gateway (trusted code)
|
|
30
|
+
*/
|
|
31
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
32
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { dirname, join, resolve } from "node:path";
|
|
35
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
36
|
+
import { OpenClawSessionDB } from "./adapters/openclaw/session-db.js";
|
|
37
|
+
import { extractEvents, extractUserEvents } from "./session/extract.js";
|
|
38
|
+
import { buildResumeSnapshot } from "./session/snapshot.js";
|
|
39
|
+
import { OpenClawAdapter } from "./adapters/openclaw/index.js";
|
|
40
|
+
import { WorkspaceRouter } from "./openclaw/workspace-router.js";
|
|
41
|
+
/** Plugin config schema for OpenClaw validation. */
|
|
42
|
+
const configSchema = {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
enabled: {
|
|
46
|
+
type: "boolean",
|
|
47
|
+
default: true,
|
|
48
|
+
description: "Enable or disable the context-mode plugin.",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
additionalProperties: false,
|
|
52
|
+
};
|
|
53
|
+
// ── Helpers ───────────────────────────────────────────────
|
|
54
|
+
function getSessionDir() {
|
|
55
|
+
const dir = join(homedir(), ".openclaw", "context-mode", "sessions");
|
|
56
|
+
mkdirSync(dir, { recursive: true });
|
|
57
|
+
return dir;
|
|
58
|
+
}
|
|
59
|
+
function getDBPath(projectDir) {
|
|
60
|
+
const hash = createHash("sha256")
|
|
61
|
+
.update(projectDir)
|
|
62
|
+
.digest("hex")
|
|
63
|
+
.slice(0, 16);
|
|
64
|
+
return join(getSessionDir(), `${hash}.db`);
|
|
65
|
+
}
|
|
66
|
+
// ── Module-level DB singleton ─────────────────────────────
|
|
67
|
+
// Shared across all register() calls (one per agent session).
|
|
68
|
+
// Lazy-initialized on first register() using the first projectDir seen.
|
|
69
|
+
// Uses OpenClawSessionDB for session_key mapping and rename support.
|
|
70
|
+
let _dbSingleton = null;
|
|
71
|
+
function getOrCreateDB(projectDir) {
|
|
72
|
+
if (!_dbSingleton) {
|
|
73
|
+
const dbPath = getDBPath(projectDir);
|
|
74
|
+
_dbSingleton = new OpenClawSessionDB({ dbPath });
|
|
75
|
+
_dbSingleton.cleanupOldSessions(7);
|
|
76
|
+
}
|
|
77
|
+
return _dbSingleton;
|
|
78
|
+
}
|
|
79
|
+
// ── Module-level state for command handlers ───────────────
|
|
80
|
+
// Commands are re-registered on each register() call (OpenClaw's registerCommand
|
|
81
|
+
// is idempotent). These refs give handlers access to the current session's state.
|
|
82
|
+
let _latestDb = null;
|
|
83
|
+
let _latestSessionId = "";
|
|
84
|
+
let _latestPluginRoot = "";
|
|
85
|
+
// ── Plugin Definition (object export) ─────────────────────
|
|
86
|
+
/**
|
|
87
|
+
* OpenClaw plugin definition. The object form provides declarative metadata
|
|
88
|
+
* (id, name, configSchema) that OpenClaw can read without executing code.
|
|
89
|
+
* register() is called once per agent session with a fresh api object.
|
|
90
|
+
* Each call creates isolated closures (db, sessionId, hooks) — no shared state.
|
|
91
|
+
*/
|
|
92
|
+
export default {
|
|
93
|
+
id: "context-mode",
|
|
94
|
+
name: "Context Mode",
|
|
95
|
+
configSchema,
|
|
96
|
+
// OpenClaw calls register() synchronously — returning a Promise causes hooks
|
|
97
|
+
// to be silently ignored. Async init runs eagerly; hooks await it on first use.
|
|
98
|
+
register(api) {
|
|
99
|
+
// Resolve build dir from compiled JS location
|
|
100
|
+
const buildDir = dirname(fileURLToPath(import.meta.url));
|
|
101
|
+
const projectDir = process.env.OPENCLAW_PROJECT_DIR || process.cwd();
|
|
102
|
+
const pluginRoot = resolve(buildDir, "..");
|
|
103
|
+
// Structured logger — wraps api.logger, falls back to no-op.
|
|
104
|
+
// info/error always emit; debug only when api.logger.debug is present
|
|
105
|
+
// (i.e. OpenClaw running with --log-level debug or lower).
|
|
106
|
+
const log = {
|
|
107
|
+
info: (...args) => api.logger?.info("[context-mode]", ...args),
|
|
108
|
+
error: (...args) => api.logger?.error("[context-mode]", ...args),
|
|
109
|
+
debug: (...args) => api.logger?.debug?.("[context-mode]", ...args),
|
|
110
|
+
warn: (...args) => api.logger?.warn?.("[context-mode]", ...args),
|
|
111
|
+
};
|
|
112
|
+
// Get shared DB singleton (lazy-init on first register() call)
|
|
113
|
+
const db = getOrCreateDB(projectDir);
|
|
114
|
+
// Start with temp UUID — session_start will assign the real ID + sessionKey
|
|
115
|
+
let sessionId = randomUUID();
|
|
116
|
+
log.info("register() called, sessionId:", sessionId.slice(0, 8));
|
|
117
|
+
let resumeInjected = false;
|
|
118
|
+
let sessionKey;
|
|
119
|
+
// Create temp session so after_tool_call events before session_start have a valid row
|
|
120
|
+
db.ensureSession(sessionId, projectDir);
|
|
121
|
+
const workspaceRouter = new WorkspaceRouter();
|
|
122
|
+
// Load routing instructions synchronously for prompt injection
|
|
123
|
+
let routingInstructions = "";
|
|
124
|
+
try {
|
|
125
|
+
const instructionsPath = resolve(buildDir, "..", "configs", "openclaw", "AGENTS.md");
|
|
126
|
+
if (existsSync(instructionsPath)) {
|
|
127
|
+
routingInstructions = readFileSync(instructionsPath, "utf-8");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// best effort
|
|
132
|
+
}
|
|
133
|
+
// Async init: load routing module + write AGENTS.md. Hooks await this.
|
|
134
|
+
const initPromise = (async () => {
|
|
135
|
+
const routingPath = resolve(buildDir, "..", "hooks", "core", "routing.mjs");
|
|
136
|
+
const routing = await import(pathToFileURL(routingPath).href);
|
|
137
|
+
await routing.initSecurity(buildDir);
|
|
138
|
+
try {
|
|
139
|
+
new OpenClawAdapter().writeRoutingInstructions(projectDir, pluginRoot);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// best effort — never break plugin init
|
|
143
|
+
}
|
|
144
|
+
return { routing };
|
|
145
|
+
})();
|
|
146
|
+
// ── 1. tool_call:before — Routing enforcement ──────────
|
|
147
|
+
// NOTE: api.on() was broken in OpenClaw ≤2026.1.29 (fixed in PR #9761, issue #5513).
|
|
148
|
+
// api.on() is the correct API for typed lifecycle hooks (session_start, before_tool_call, etc.).
|
|
149
|
+
// api.registerHook() is for generic/command hooks (command:new, command:reset, command:stop).
|
|
150
|
+
api.on("before_tool_call", async (event) => {
|
|
151
|
+
const { routing } = await initPromise;
|
|
152
|
+
const e = event;
|
|
153
|
+
const toolName = e.toolName ?? "";
|
|
154
|
+
const toolInput = e.params ?? {};
|
|
155
|
+
let decision;
|
|
156
|
+
try {
|
|
157
|
+
decision = routing.routePreToolUse(toolName, toolInput, projectDir);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return; // Routing failure → allow passthrough
|
|
161
|
+
}
|
|
162
|
+
if (!decision)
|
|
163
|
+
return; // No routing match → passthrough
|
|
164
|
+
log.debug("before_tool_call", { tool: toolName, action: decision.action });
|
|
165
|
+
if (decision.action === "deny" || decision.action === "ask") {
|
|
166
|
+
return {
|
|
167
|
+
block: true,
|
|
168
|
+
blockReason: decision.reason ?? "Blocked by context-mode",
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (decision.action === "modify" && decision.updatedInput) {
|
|
172
|
+
// In-place mutation — OpenClaw reads the mutated params object.
|
|
173
|
+
Object.assign(toolInput, decision.updatedInput);
|
|
174
|
+
}
|
|
175
|
+
// "context" action → handled by before_prompt_build, not inline
|
|
176
|
+
});
|
|
177
|
+
// ── 2. after_tool_call — Session event capture ─────────
|
|
178
|
+
// Map OpenClaw tool names → Claude Code equivalents so extractEvents
|
|
179
|
+
// can recognize them. OpenClaw uses lowercase names; CC uses PascalCase.
|
|
180
|
+
const OPENCLAW_TOOL_MAP = {
|
|
181
|
+
exec: "Bash",
|
|
182
|
+
read: "Read",
|
|
183
|
+
write: "Write",
|
|
184
|
+
edit: "Edit",
|
|
185
|
+
apply_patch: "Edit",
|
|
186
|
+
glob: "Glob",
|
|
187
|
+
grep: "Grep",
|
|
188
|
+
search: "Grep",
|
|
189
|
+
};
|
|
190
|
+
api.on("after_tool_call", async (event) => {
|
|
191
|
+
try {
|
|
192
|
+
const e = event;
|
|
193
|
+
const rawToolName = e.toolName ?? "";
|
|
194
|
+
const mappedToolName = OPENCLAW_TOOL_MAP[rawToolName] ?? rawToolName;
|
|
195
|
+
// Accept both result (v2+) and output (older builds)
|
|
196
|
+
const rawResult = e.result ?? e.output;
|
|
197
|
+
const resultStr = typeof rawResult === "string"
|
|
198
|
+
? rawResult
|
|
199
|
+
: rawResult != null
|
|
200
|
+
? JSON.stringify(rawResult)
|
|
201
|
+
: undefined;
|
|
202
|
+
// Accept both error (string, v2+) and isError (boolean, older builds)
|
|
203
|
+
const hasError = Boolean(e.error || e.isError);
|
|
204
|
+
const hookInput = {
|
|
205
|
+
tool_name: mappedToolName,
|
|
206
|
+
tool_input: e.params ?? {},
|
|
207
|
+
tool_response: resultStr,
|
|
208
|
+
tool_output: hasError ? { isError: true } : undefined,
|
|
209
|
+
};
|
|
210
|
+
const events = extractEvents(hookInput);
|
|
211
|
+
// Resolve agent-specific sessionId from workspace paths in params
|
|
212
|
+
const routedSessionId = workspaceRouter.resolveSessionId(e.params ?? {}) ?? sessionId;
|
|
213
|
+
if (events.length > 0) {
|
|
214
|
+
for (const ev of events) {
|
|
215
|
+
db.insertEvent(routedSessionId, ev, "PostToolUse");
|
|
216
|
+
}
|
|
217
|
+
log.debug("after_tool_call", { tool: rawToolName, mapped: mappedToolName, sessionId: routedSessionId.slice(0, 8), events: events.length, durationMs: e.durationMs });
|
|
218
|
+
}
|
|
219
|
+
else if (rawToolName) {
|
|
220
|
+
// Fallback: record any unrecognized tool call as a generic event
|
|
221
|
+
const data = JSON.stringify({
|
|
222
|
+
tool: rawToolName,
|
|
223
|
+
params: e.params,
|
|
224
|
+
durationMs: e.durationMs,
|
|
225
|
+
});
|
|
226
|
+
db.insertEvent(routedSessionId, {
|
|
227
|
+
type: "tool_call",
|
|
228
|
+
category: "openclaw",
|
|
229
|
+
data,
|
|
230
|
+
priority: 1,
|
|
231
|
+
data_hash: createHash("sha256")
|
|
232
|
+
.update(data)
|
|
233
|
+
.digest("hex")
|
|
234
|
+
.slice(0, 16),
|
|
235
|
+
}, "PostToolUse");
|
|
236
|
+
log.debug("after_tool_call", { tool: rawToolName, mapped: rawToolName, sessionId: routedSessionId.slice(0, 8), events: 1, durationMs: e.durationMs });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// Silent — session capture must never break the tool call
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
// ── 3. command:new — Session initialization ────────────
|
|
244
|
+
api.registerHook("command:new", async () => {
|
|
245
|
+
try {
|
|
246
|
+
log.debug("command:new", { sessionId: sessionId.slice(0, 8) });
|
|
247
|
+
db.cleanupOldSessions(7);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// best effort
|
|
251
|
+
}
|
|
252
|
+
}, {
|
|
253
|
+
name: "context-mode.session-new",
|
|
254
|
+
description: "Session initialization — cleans up old sessions on /new command",
|
|
255
|
+
});
|
|
256
|
+
// ── 3b. command:reset / command:stop — Session cleanup ────
|
|
257
|
+
api.registerHook("command:reset", async () => {
|
|
258
|
+
try {
|
|
259
|
+
log.debug("command:reset", { sessionId: sessionId.slice(0, 8) });
|
|
260
|
+
db.cleanupOldSessions(7);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// best effort
|
|
264
|
+
}
|
|
265
|
+
}, {
|
|
266
|
+
name: "context-mode.session-reset",
|
|
267
|
+
description: "Session cleanup on /reset command",
|
|
268
|
+
});
|
|
269
|
+
api.registerHook("command:stop", async () => {
|
|
270
|
+
try {
|
|
271
|
+
log.debug("command:stop", { sessionId: sessionId.slice(0, 8), sessionKey });
|
|
272
|
+
if (sessionKey) {
|
|
273
|
+
workspaceRouter.removeSession(sessionKey);
|
|
274
|
+
}
|
|
275
|
+
db.cleanupOldSessions(7);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// best effort
|
|
279
|
+
}
|
|
280
|
+
}, {
|
|
281
|
+
name: "context-mode.session-stop",
|
|
282
|
+
description: "Session cleanup on /stop command",
|
|
283
|
+
});
|
|
284
|
+
// ── 4. session_start — Re-key DB session to OpenClaw's session ID ─
|
|
285
|
+
api.on("session_start", async (event) => {
|
|
286
|
+
try {
|
|
287
|
+
const e = event;
|
|
288
|
+
const sid = e?.sessionId;
|
|
289
|
+
if (!sid)
|
|
290
|
+
return;
|
|
291
|
+
const key = e?.sessionKey;
|
|
292
|
+
const resumedFrom = e?.resumedFrom;
|
|
293
|
+
log.debug("session_start", { sessionId: sid.slice(0, 8), sessionKey: key, resumedFrom });
|
|
294
|
+
if (key) {
|
|
295
|
+
// Per-agent session lookup via sessionKey
|
|
296
|
+
const prevId = db.getMostRecentSession(key);
|
|
297
|
+
if (prevId && prevId !== sid) {
|
|
298
|
+
db.renameSession(prevId, sid);
|
|
299
|
+
log.info(`session re-keyed ${prevId.slice(0, 8)}… → ${sid.slice(0, 8)}… (key=${key})`);
|
|
300
|
+
}
|
|
301
|
+
else if (!prevId) {
|
|
302
|
+
db.ensureSessionWithKey(sid, projectDir, key);
|
|
303
|
+
log.info(`new session ${sid.slice(0, 8)}… (key=${key})`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
// Fallback: no sessionKey → fresh session (Option A)
|
|
308
|
+
db.ensureSession(sid, projectDir);
|
|
309
|
+
log.info(`session ${sid.slice(0, 8)}… (no sessionKey — fallback)`);
|
|
310
|
+
}
|
|
311
|
+
sessionId = sid;
|
|
312
|
+
_latestSessionId = sessionId;
|
|
313
|
+
sessionKey = key;
|
|
314
|
+
if (key) {
|
|
315
|
+
workspaceRouter.registerSession(key, sessionId);
|
|
316
|
+
}
|
|
317
|
+
resumeInjected = false;
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// best effort — never break session start
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
// ── 5. before_compaction — Flush events to snapshot before compaction ─
|
|
324
|
+
// NOTE: OpenClaw compaction hooks were broken until #4967/#3728 fix.
|
|
325
|
+
// Adapter gracefully degrades — session recovery falls back to DB snapshot
|
|
326
|
+
// reconstruction when compaction events don't fire.
|
|
327
|
+
api.on("before_compaction", async () => {
|
|
328
|
+
try {
|
|
329
|
+
const sid = sessionId; // snapshot to avoid race with concurrent session_start
|
|
330
|
+
const allEvents = db.getEvents(sid);
|
|
331
|
+
log.debug("before_compaction", { sessionId: sid.slice(0, 8), events: allEvents.length });
|
|
332
|
+
if (allEvents.length === 0)
|
|
333
|
+
return;
|
|
334
|
+
const freshStats = db.getSessionStats(sid);
|
|
335
|
+
const snapshot = buildResumeSnapshot(allEvents, {
|
|
336
|
+
compactCount: (freshStats?.compact_count ?? 0) + 1,
|
|
337
|
+
});
|
|
338
|
+
db.upsertResume(sid, snapshot, allEvents.length);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// best effort — never break compaction
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
// ── 6. after_compaction — Increment compact count ─────
|
|
345
|
+
api.on("after_compaction", async () => {
|
|
346
|
+
try {
|
|
347
|
+
const sid = sessionId;
|
|
348
|
+
log.debug("after_compaction", { sessionId: sid.slice(0, 8) });
|
|
349
|
+
db.incrementCompactCount(sid); // sessionId consistent with before_compaction within same sync cycle
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// best effort
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
// ── 7. before_model_resolve — User message capture ────────
|
|
356
|
+
api.on("before_model_resolve", async (event) => {
|
|
357
|
+
try {
|
|
358
|
+
const sid = sessionId; // snapshot to avoid race with concurrent session_start
|
|
359
|
+
const e = event;
|
|
360
|
+
const messageText = e?.userMessage ?? e?.message ?? e?.content ?? "";
|
|
361
|
+
log.debug("before_model_resolve", { hasMessage: !!messageText });
|
|
362
|
+
if (!messageText)
|
|
363
|
+
return;
|
|
364
|
+
const events = extractUserEvents(messageText);
|
|
365
|
+
for (const ev of events) {
|
|
366
|
+
db.insertEvent(sid, ev, "PostToolUse");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
// best effort — never break model resolution
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
// ── 8. before_prompt_build — Resume snapshot injection ────
|
|
374
|
+
api.on("before_prompt_build", () => {
|
|
375
|
+
try {
|
|
376
|
+
const sid = sessionId; // snapshot to avoid race with concurrent session_start
|
|
377
|
+
const resume = db.getResume(sid);
|
|
378
|
+
log.debug("before_prompt_build[resume]", { sessionId: sid.slice(0, 8), hasResume: !!resume, injected: !resumeInjected });
|
|
379
|
+
if (resumeInjected)
|
|
380
|
+
return undefined;
|
|
381
|
+
if (!resume)
|
|
382
|
+
return undefined;
|
|
383
|
+
const freshStats = db.getSessionStats(sid);
|
|
384
|
+
if ((freshStats?.compact_count ?? 0) === 0)
|
|
385
|
+
return undefined;
|
|
386
|
+
resumeInjected = true;
|
|
387
|
+
return { prependSystemContext: resume.snapshot };
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
}, { priority: 10 });
|
|
393
|
+
// ── 8. before_prompt_build — Routing instruction injection ──
|
|
394
|
+
if (routingInstructions) {
|
|
395
|
+
api.on("before_prompt_build", () => {
|
|
396
|
+
log.debug("before_prompt_build[routing]", { hasInstructions: !!routingInstructions });
|
|
397
|
+
return { appendSystemContext: routingInstructions };
|
|
398
|
+
}, { priority: 5 });
|
|
399
|
+
}
|
|
400
|
+
// ── 9. Context engine — Compaction management ──────────
|
|
401
|
+
api.registerContextEngine("context-mode", () => ({
|
|
402
|
+
info: {
|
|
403
|
+
id: "context-mode",
|
|
404
|
+
name: "Context Mode",
|
|
405
|
+
ownsCompaction: true,
|
|
406
|
+
},
|
|
407
|
+
async ingest() {
|
|
408
|
+
return { ingested: true };
|
|
409
|
+
},
|
|
410
|
+
async assemble({ messages }) {
|
|
411
|
+
return { messages, estimatedTokens: 0 };
|
|
412
|
+
},
|
|
413
|
+
async compact({ currentTokenCount } = {}) {
|
|
414
|
+
try {
|
|
415
|
+
const sid = sessionId;
|
|
416
|
+
const events = db.getEvents(sid);
|
|
417
|
+
if (events.length === 0)
|
|
418
|
+
return { ok: true, compacted: false };
|
|
419
|
+
const stats = db.getSessionStats(sid);
|
|
420
|
+
const compactCount = (stats?.compact_count ?? 0) + 1;
|
|
421
|
+
const snapshot = buildResumeSnapshot(events, { compactCount });
|
|
422
|
+
db.upsertResume(sid, snapshot, events.length);
|
|
423
|
+
db.incrementCompactCount(sid);
|
|
424
|
+
return {
|
|
425
|
+
ok: true,
|
|
426
|
+
compacted: true,
|
|
427
|
+
result: {
|
|
428
|
+
summary: snapshot,
|
|
429
|
+
firstKeptEntryId: "", // clear all history before this compaction
|
|
430
|
+
tokensBefore: currentTokenCount ?? 0,
|
|
431
|
+
tokensAfter: 0,
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return { ok: false, compacted: false };
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
}));
|
|
440
|
+
// ── 10. Auto-reply commands — ctx slash commands ──────
|
|
441
|
+
// Update module-level refs so command handlers (registered once) always
|
|
442
|
+
// read the latest session's db/sessionId/pluginRoot.
|
|
443
|
+
_latestDb = db;
|
|
444
|
+
_latestSessionId = sessionId;
|
|
445
|
+
_latestPluginRoot = pluginRoot;
|
|
446
|
+
if (api.registerCommand) {
|
|
447
|
+
api.registerCommand({
|
|
448
|
+
name: "ctx-stats",
|
|
449
|
+
description: "Show context-mode session statistics",
|
|
450
|
+
handler: () => {
|
|
451
|
+
const text = buildStatsText(_latestDb, _latestSessionId);
|
|
452
|
+
return { text };
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
api.registerCommand({
|
|
456
|
+
name: "ctx-doctor",
|
|
457
|
+
description: "Run context-mode diagnostics",
|
|
458
|
+
handler: () => {
|
|
459
|
+
const cmd = `node "${_latestPluginRoot}/build/cli.js" doctor`;
|
|
460
|
+
return {
|
|
461
|
+
text: [
|
|
462
|
+
"## ctx-doctor",
|
|
463
|
+
"",
|
|
464
|
+
"Run this command to diagnose context-mode:",
|
|
465
|
+
"",
|
|
466
|
+
"```",
|
|
467
|
+
cmd,
|
|
468
|
+
"```",
|
|
469
|
+
].join("\n"),
|
|
470
|
+
};
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
api.registerCommand({
|
|
474
|
+
name: "ctx-upgrade",
|
|
475
|
+
description: "Upgrade context-mode to the latest version",
|
|
476
|
+
handler: () => {
|
|
477
|
+
const cmd = `node "${_latestPluginRoot}/build/cli.js" upgrade`;
|
|
478
|
+
return {
|
|
479
|
+
text: [
|
|
480
|
+
"## ctx-upgrade",
|
|
481
|
+
"",
|
|
482
|
+
"Run this command to upgrade context-mode:",
|
|
483
|
+
"",
|
|
484
|
+
"```",
|
|
485
|
+
cmd,
|
|
486
|
+
"```",
|
|
487
|
+
"",
|
|
488
|
+
"Restart your session after upgrade.",
|
|
489
|
+
].join("\n"),
|
|
490
|
+
};
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
// ── Stats helper ──────────────────────────────────────────
|
|
497
|
+
function buildStatsText(db, sessionId) {
|
|
498
|
+
try {
|
|
499
|
+
const events = db.getEvents(sessionId);
|
|
500
|
+
const stats = db.getSessionStats(sessionId);
|
|
501
|
+
const lines = [
|
|
502
|
+
"## context-mode stats",
|
|
503
|
+
"",
|
|
504
|
+
`- Session: \`${sessionId.slice(0, 8)}…\``,
|
|
505
|
+
`- Events captured: ${events.length}`,
|
|
506
|
+
`- Compactions: ${stats?.compact_count ?? 0}`,
|
|
507
|
+
];
|
|
508
|
+
// Summarize events by type
|
|
509
|
+
const byType = {};
|
|
510
|
+
for (const ev of events) {
|
|
511
|
+
const key = ev.type ?? "unknown";
|
|
512
|
+
byType[key] = (byType[key] ?? 0) + 1;
|
|
513
|
+
}
|
|
514
|
+
if (Object.keys(byType).length > 0) {
|
|
515
|
+
lines.push("- Event breakdown:");
|
|
516
|
+
for (const [type, count] of Object.entries(byType)) {
|
|
517
|
+
lines.push(` - ${type}: ${count}`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return lines.join("\n");
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return "context-mode stats unavailable (session DB error)";
|
|
524
|
+
}
|
|
525
|
+
}
|
package/build/server.js
CHANGED
|
@@ -14,7 +14,9 @@ import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readTo
|
|
|
14
14
|
import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js";
|
|
15
15
|
import { classifyNonZeroExit } from "./exit-classify.js";
|
|
16
16
|
import { startLifecycleGuard } from "./lifecycle.js";
|
|
17
|
-
|
|
17
|
+
import { getWorktreeSuffix } from "./session/db.js";
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const VERSION = require("../package.json").version;
|
|
18
20
|
// Prevent silent server death from unhandled async errors
|
|
19
21
|
process.on("unhandledRejection", (err) => {
|
|
20
22
|
process.stderr.write(`[context-mode] unhandledRejection: ${err}\n`);
|
|
@@ -730,15 +732,43 @@ let searchWindowStart = Date.now();
|
|
|
730
732
|
const SEARCH_WINDOW_MS = 60_000;
|
|
731
733
|
const SEARCH_MAX_RESULTS_AFTER = 3; // after 3 calls: 1 result per query
|
|
732
734
|
const SEARCH_BLOCK_AFTER = 8; // after 8 calls: refuse, demand batching
|
|
735
|
+
/**
|
|
736
|
+
* Defensive coercion: parse stringified JSON arrays.
|
|
737
|
+
* Works around Claude Code double-serialization bug where array params
|
|
738
|
+
* are sent as JSON strings (e.g. "[\"a\",\"b\"]" instead of ["a","b"]).
|
|
739
|
+
* See: https://github.com/anthropics/claude-code/issues/34520
|
|
740
|
+
*/
|
|
741
|
+
function coerceJsonArray(val) {
|
|
742
|
+
if (typeof val === "string") {
|
|
743
|
+
try {
|
|
744
|
+
const parsed = JSON.parse(val);
|
|
745
|
+
if (Array.isArray(parsed))
|
|
746
|
+
return parsed;
|
|
747
|
+
}
|
|
748
|
+
catch { /* not valid JSON, let zod handle the error */ }
|
|
749
|
+
}
|
|
750
|
+
return val;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Coerce commands array: handles double-serialization AND the case where
|
|
754
|
+
* the model passes plain command strings instead of {label, command} objects.
|
|
755
|
+
*/
|
|
756
|
+
function coerceCommandsArray(val) {
|
|
757
|
+
const arr = coerceJsonArray(val);
|
|
758
|
+
if (Array.isArray(arr)) {
|
|
759
|
+
return arr.map((item, i) => typeof item === "string" ? { label: `cmd_${i + 1}`, command: item } : item);
|
|
760
|
+
}
|
|
761
|
+
return arr;
|
|
762
|
+
}
|
|
733
763
|
server.registerTool("ctx_search", {
|
|
734
764
|
title: "Search Indexed Content",
|
|
735
765
|
description: "Search indexed content. Pass ALL search questions as queries array in ONE call.\n\n" +
|
|
736
766
|
"TIPS: 2-4 specific terms per query. Use 'source' to scope results.",
|
|
737
767
|
inputSchema: z.object({
|
|
738
|
-
queries: z
|
|
768
|
+
queries: z.preprocess(coerceJsonArray, z
|
|
739
769
|
.array(z.string())
|
|
740
770
|
.optional()
|
|
741
|
-
.describe("Array of search queries. Batch ALL questions in one call."),
|
|
771
|
+
.describe("Array of search queries. Batch ALL questions in one call.")),
|
|
742
772
|
limit: z
|
|
743
773
|
.number()
|
|
744
774
|
.optional()
|
|
@@ -1044,7 +1074,7 @@ server.registerTool("ctx_batch_execute", {
|
|
|
1044
1074
|
"One batch_execute call replaces 30+ execute calls + 10+ search calls.\n" +
|
|
1045
1075
|
"Provide all commands to run and all queries to search — everything happens in one round trip.",
|
|
1046
1076
|
inputSchema: z.object({
|
|
1047
|
-
commands: z
|
|
1077
|
+
commands: z.preprocess(coerceCommandsArray, z
|
|
1048
1078
|
.array(z.object({
|
|
1049
1079
|
label: z
|
|
1050
1080
|
.string()
|
|
@@ -1054,13 +1084,13 @@ server.registerTool("ctx_batch_execute", {
|
|
|
1054
1084
|
.describe("Shell command to execute"),
|
|
1055
1085
|
}))
|
|
1056
1086
|
.min(1)
|
|
1057
|
-
.describe("Commands to execute as a batch. Each runs sequentially, output is labeled with the section header."),
|
|
1058
|
-
queries: z
|
|
1087
|
+
.describe("Commands to execute as a batch. Each runs sequentially, output is labeled with the section header.")),
|
|
1088
|
+
queries: z.preprocess(coerceJsonArray, z
|
|
1059
1089
|
.array(z.string())
|
|
1060
1090
|
.min(1)
|
|
1061
1091
|
.describe("Search queries to extract information from indexed output. Use 5-8 comprehensive queries. " +
|
|
1062
1092
|
"Each returns top 5 matching sections with full content. " +
|
|
1063
|
-
"This is your ONLY chance — put ALL your questions here. No follow-up calls needed."),
|
|
1093
|
+
"This is your ONLY chance — put ALL your questions here. No follow-up calls needed.")),
|
|
1064
1094
|
timeout: z
|
|
1065
1095
|
.number()
|
|
1066
1096
|
.optional()
|
|
@@ -1270,7 +1300,8 @@ server.registerTool("ctx_stats", {
|
|
|
1270
1300
|
try {
|
|
1271
1301
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
1272
1302
|
const dbHash = createHash("sha256").update(projectDir).digest("hex").slice(0, 16);
|
|
1273
|
-
const
|
|
1303
|
+
const worktreeSuffix = getWorktreeSuffix();
|
|
1304
|
+
const sessionDbPath = join(homedir(), ".claude", "context-mode", "sessions", `${dbHash}${worktreeSuffix}.db`);
|
|
1274
1305
|
if (existsSync(sessionDbPath)) {
|
|
1275
1306
|
const require = createRequire(import.meta.url);
|
|
1276
1307
|
const Database = require("better-sqlite3");
|
|
@@ -1467,11 +1498,15 @@ async function main() {
|
|
|
1467
1498
|
startLifecycleGuard({ onShutdown: () => gracefulShutdown() });
|
|
1468
1499
|
const transport = new StdioServerTransport();
|
|
1469
1500
|
await server.connect(transport);
|
|
1470
|
-
// Write routing instructions for hookless platforms (e.g. Codex CLI)
|
|
1501
|
+
// Write routing instructions for hookless platforms (e.g. Codex CLI, Antigravity)
|
|
1471
1502
|
try {
|
|
1472
1503
|
const { detectPlatform, getAdapter } = await import("./adapters/detect.js");
|
|
1473
|
-
const
|
|
1504
|
+
const clientInfo = server.server.getClientVersion();
|
|
1505
|
+
const signal = detectPlatform(clientInfo ?? undefined);
|
|
1474
1506
|
const adapter = await getAdapter(signal.platform);
|
|
1507
|
+
if (clientInfo) {
|
|
1508
|
+
console.error(`MCP client: ${clientInfo.name} v${clientInfo.version} → ${signal.platform}`);
|
|
1509
|
+
}
|
|
1475
1510
|
if (!adapter.capabilities.sessionStart) {
|
|
1476
1511
|
const pluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
1477
1512
|
const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.env.CODEX_HOME ?? process.cwd();
|
package/build/session/db.d.ts
CHANGED
|
@@ -7,6 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { SQLiteBase } from "../db-base.js";
|
|
9
9
|
import type { SessionEvent } from "../types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Returns the worktree suffix to append to session identifiers.
|
|
12
|
+
* Returns empty string when running in the main working tree.
|
|
13
|
+
*
|
|
14
|
+
* Set CONTEXT_MODE_SESSION_SUFFIX to an explicit value to override
|
|
15
|
+
* (useful in CI environments or when git is unavailable).
|
|
16
|
+
* Set to empty string to disable isolation entirely.
|
|
17
|
+
*/
|
|
18
|
+
export declare function getWorktreeSuffix(): string;
|
|
10
19
|
/** A stored event row from the session_events table. */
|
|
11
20
|
export interface StoredEvent {
|
|
12
21
|
id: number;
|