context-mode 1.0.106 → 1.0.108

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.
Files changed (72) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  4. package/.openclaw-plugin/package.json +1 -1
  5. package/README.md +22 -18
  6. package/build/adapters/claude-code/index.js +26 -9
  7. package/build/adapters/copilot-base.d.ts +3 -3
  8. package/build/adapters/cursor/hooks.js +8 -0
  9. package/build/adapters/cursor/index.js +4 -1
  10. package/build/adapters/gemini-cli/hooks.d.ts +6 -1
  11. package/build/adapters/gemini-cli/hooks.js +7 -1
  12. package/build/adapters/gemini-cli/index.js +12 -0
  13. package/build/adapters/kiro/hooks.js +4 -0
  14. package/build/adapters/kiro/index.d.ts +9 -2
  15. package/build/adapters/kiro/index.js +49 -27
  16. package/build/adapters/opencode/index.js +11 -5
  17. package/build/adapters/qwen-code/index.js +18 -0
  18. package/build/adapters/vscode-copilot/hooks.d.ts +0 -4
  19. package/build/adapters/vscode-copilot/hooks.js +6 -6
  20. package/build/cli.js +93 -12
  21. package/build/openclaw/mcp-tools.d.ts +54 -0
  22. package/build/openclaw/mcp-tools.js +198 -0
  23. package/build/openclaw-plugin.d.ts +9 -0
  24. package/build/openclaw-plugin.js +132 -16
  25. package/build/opencode-plugin.d.ts +29 -4
  26. package/build/opencode-plugin.js +154 -7
  27. package/build/pi-extension.js +123 -29
  28. package/build/server.d.ts +1 -0
  29. package/build/server.js +26 -1
  30. package/build/session/analytics.js +36 -13
  31. package/build/session/extract.d.ts +1 -1
  32. package/build/session/extract.js +46 -1
  33. package/cli.bundle.mjs +133 -132
  34. package/hooks/core/platform-detect.mjs +49 -0
  35. package/hooks/core/routing.mjs +13 -1
  36. package/hooks/cursor/afteragentresponse.mjs +74 -0
  37. package/hooks/ensure-deps.mjs +28 -12
  38. package/hooks/gemini-cli/beforeagent.mjs +99 -0
  39. package/hooks/kiro/agentspawn.mjs +97 -0
  40. package/hooks/kiro/userpromptsubmit.mjs +88 -0
  41. package/hooks/posttooluse.mjs +90 -80
  42. package/hooks/precompact.mjs +56 -46
  43. package/hooks/pretooluse.mjs +161 -167
  44. package/hooks/routing-block.mjs +2 -2
  45. package/hooks/run-hook.mjs +82 -0
  46. package/hooks/session-extract.bundle.mjs +2 -2
  47. package/hooks/sessionstart.mjs +187 -153
  48. package/hooks/userpromptsubmit.mjs +69 -58
  49. package/hooks/vscode-copilot/sessionstart.mjs +13 -14
  50. package/openclaw.plugin.json +1 -1
  51. package/package.json +2 -1
  52. package/scripts/heal-better-sqlite3.mjs +108 -0
  53. package/scripts/postinstall.mjs +27 -0
  54. package/server.bundle.mjs +79 -79
  55. package/skills/UPSTREAM-CREDITS.md +51 -0
  56. package/skills/context-mode-ops/SKILL.md +147 -0
  57. package/skills/diagnose/SKILL.md +122 -0
  58. package/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
  59. package/skills/grill-me/SKILL.md +15 -0
  60. package/skills/grill-with-docs/ADR-FORMAT.md +47 -0
  61. package/skills/grill-with-docs/CONTEXT-FORMAT.md +77 -0
  62. package/skills/grill-with-docs/SKILL.md +93 -0
  63. package/skills/improve-codebase-architecture/DEEPENING.md +37 -0
  64. package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
  65. package/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
  66. package/skills/improve-codebase-architecture/SKILL.md +76 -0
  67. package/skills/tdd/SKILL.md +114 -0
  68. package/skills/tdd/deep-modules.md +33 -0
  69. package/skills/tdd/interface-design.md +31 -0
  70. package/skills/tdd/mocking.md +59 -0
  71. package/skills/tdd/refactoring.md +10 -0
  72. package/skills/tdd/tests.md +61 -0
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * OpenCode / KiloCode TypeScript plugin entry point for context-mode.
3
3
  *
4
- * Provides three hooks:
4
+ * Provides five hooks (v1.0.107 — Mickey OC-1..OC-4 follow-up):
5
5
  * - tool.execute.before — Routing enforcement (deny/modify/passthrough)
6
- * - tool.execute.after — Session event capture
7
- * - experimental.session.compacting — Compaction snapshot generation
6
+ * - tool.execute.after — Session event capture + first-fire AGENTS.md scan (OC-4)
7
+ * - experimental.session.compacting — Compaction snapshot + budget-capped auto-injection (OC-3)
8
+ * - experimental.chat.system.transform — ROUTING_BLOCK + resume snapshot injection (OC-1)
9
+ * - chat.message — User-prompt capture w/ CCv2 inline filter (OC-2)
8
10
  *
9
11
  * KiloCode loads this via: import("context-mode") → expects default export
10
12
  * with shape { server: (input) => Promise<Hooks> } (PluginModule).
@@ -14,7 +16,7 @@
14
16
  *
15
17
  * Constraints:
16
18
  * - No SessionStart hook (OpenCode doesn't support it — #14808, #5409)
17
- * - No context injection (canInjectSessionContext: false)
19
+ * - context injection now via chat.system.transform surrogate (OC-1)
18
20
  * - No routing file auto-write (avoid dirtying project trees)
19
21
  * - Session cleanup happens at plugin init (no SessionStart)
20
22
  */
@@ -77,6 +79,28 @@ interface SystemTransformHookInput {
77
79
  interface SystemTransformHookOutput {
78
80
  system: string[];
79
81
  }
82
+ /**
83
+ * OpenCode chat.message hook — verified against
84
+ * refs/platforms/opencode/packages/plugin/src/index.ts:233.
85
+ * input: { sessionID; agent?; model?; messageID?; variant? }
86
+ * output: { message: UserMessage; parts: Part[] }
87
+ * We read text from `parts[*].text` (the orchestrator reference at
88
+ * refs/plugin-examples/opencode/opencode-orchestrator/src/plugin-handlers/
89
+ * chat-message-handler.ts:41-65 uses the same pattern).
90
+ */
91
+ interface ChatMessageHookInput {
92
+ sessionID: string;
93
+ agent?: string;
94
+ messageID?: string;
95
+ }
96
+ interface ChatMessagePart {
97
+ type: string;
98
+ text?: string;
99
+ }
100
+ interface ChatMessageHookOutput {
101
+ message: unknown;
102
+ parts: ChatMessagePart[];
103
+ }
80
104
  /**
81
105
  * Plugin factory. Called once when KiloCode/OpenCode loads the plugin.
82
106
  * Returns an object mapping hook event names to async handler functions.
@@ -87,6 +111,7 @@ interface SystemTransformHookOutput {
87
111
  declare function createContextModePlugin(ctx: PluginContext): Promise<{
88
112
  "tool.execute.before": (input: BeforeHookInput, output: BeforeHookOutput) => Promise<void>;
89
113
  "tool.execute.after": (input: AfterHookInput, output: AfterHookOutput) => Promise<void>;
114
+ "chat.message": (input: ChatMessageHookInput, output: ChatMessageHookOutput) => Promise<void>;
90
115
  "experimental.session.compacting": (input: CompactingHookInput, output: CompactingHookOutput) => Promise<string>;
91
116
  "experimental.chat.system.transform": (input: SystemTransformHookInput, output: SystemTransformHookOutput) => Promise<void>;
92
117
  }>;
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * OpenCode / KiloCode TypeScript plugin entry point for context-mode.
3
3
  *
4
- * Provides three hooks:
4
+ * Provides five hooks (v1.0.107 — Mickey OC-1..OC-4 follow-up):
5
5
  * - tool.execute.before — Routing enforcement (deny/modify/passthrough)
6
- * - tool.execute.after — Session event capture
7
- * - experimental.session.compacting — Compaction snapshot generation
6
+ * - tool.execute.after — Session event capture + first-fire AGENTS.md scan (OC-4)
7
+ * - experimental.session.compacting — Compaction snapshot + budget-capped auto-injection (OC-3)
8
+ * - experimental.chat.system.transform — ROUTING_BLOCK + resume snapshot injection (OC-1)
9
+ * - chat.message — User-prompt capture w/ CCv2 inline filter (OC-2)
8
10
  *
9
11
  * KiloCode loads this via: import("context-mode") → expects default export
10
12
  * with shape { server: (input) => Promise<Hooks> } (PluginModule).
@@ -14,15 +16,15 @@
14
16
  *
15
17
  * Constraints:
16
18
  * - No SessionStart hook (OpenCode doesn't support it — #14808, #5409)
17
- * - No context injection (canInjectSessionContext: false)
19
+ * - context injection now via chat.system.transform surrogate (OC-1)
18
20
  * - No routing file auto-write (avoid dirtying project trees)
19
21
  * - Session cleanup happens at plugin init (no SessionStart)
20
22
  */
21
- import { dirname, resolve } from "node:path";
23
+ import { dirname, resolve, join } from "node:path";
22
24
  import { fileURLToPath, pathToFileURL } from "node:url";
23
25
  import { existsSync, readFileSync } from "node:fs";
24
26
  import { SessionDB } from "./session/db.js";
25
- import { extractEvents } from "./session/extract.js";
27
+ import { extractEvents, extractUserEvents } from "./session/extract.js";
26
28
  import { buildResumeSnapshot } from "./session/snapshot.js";
27
29
  import { OpenCodeAdapter } from "./adapters/opencode/index.js";
28
30
  import { PLATFORM_ENV_VARS } from "./adapters/detect.js";
@@ -41,6 +43,19 @@ const VERSION = (() => {
41
43
  catch { /* fall through */ }
42
44
  return "unknown";
43
45
  })();
46
+ // Synthetic message tags emitted by harnesses (CCv2 inline filter). When the
47
+ // user "message" is actually a system-generated nudge (e.g. tool-result, system
48
+ // reminder), capturing it as user_prompt would flood the DB with noise.
49
+ const SYNTHETIC_MESSAGE_PREFIXES = [
50
+ "<task-notification>",
51
+ "<system-reminder>",
52
+ "<context_guidance>",
53
+ "<tool-result>",
54
+ ];
55
+ function isSyntheticMessage(text) {
56
+ const trimmed = text.trim();
57
+ return SYNTHETIC_MESSAGE_PREFIXES.some((p) => trimmed.startsWith(p));
58
+ }
44
59
  // ── Helpers ───────────────────────────────────────────────
45
60
  /**
46
61
  * Detect whether the plugin is running under KiloCode or OpenCode.
@@ -79,12 +94,27 @@ function getPlatform() {
79
94
  */
80
95
  async function createContextModePlugin(ctx) {
81
96
  // Resolve build dir from compiled JS location
82
- const adapter = new OpenCodeAdapter(getPlatform());
97
+ const platform = getPlatform();
98
+ const adapter = new OpenCodeAdapter(platform);
83
99
  const buildDir = dirname(fileURLToPath(import.meta.url));
84
100
  // Load routing module (ESM .mjs, lives outside build/ in hooks/)
85
101
  const routingPath = resolve(buildDir, "..", "hooks", "core", "routing.mjs");
86
102
  const routing = await import(pathToFileURL(routingPath).href);
87
103
  await routing.initSecurity(buildDir);
104
+ // OC-1 / OC-3: Load hook helpers once at plugin init. Dynamic import keeps
105
+ // the .mjs ESM islands isolated from the .ts compile graph.
106
+ const routingBlockPath = resolve(buildDir, "..", "hooks", "routing-block.mjs");
107
+ const routingBlockMod = await import(pathToFileURL(routingBlockPath).href);
108
+ const toolNamingPath = resolve(buildDir, "..", "hooks", "core", "tool-naming.mjs");
109
+ const toolNamingMod = await import(pathToFileURL(toolNamingPath).href);
110
+ const autoInjectionPath = resolve(buildDir, "..", "hooks", "auto-injection.mjs");
111
+ const autoInjectionMod = await import(pathToFileURL(autoInjectionPath).href);
112
+ // Pre-build the routing block once per process — it is platform-specific
113
+ // (tool naming differs between opencode and kilo) but does NOT depend on
114
+ // sessionID, so we cache it. createToolNamer accepts both "opencode" and
115
+ // "kilo" per hooks/core/tool-naming.mjs:25-26.
116
+ const toolNamer = toolNamingMod.createToolNamer(platform);
117
+ const routingBlock = routingBlockMod.createRoutingBlock(toolNamer);
88
118
  // Initialize per-process state. We do NOT fabricate a sessionId here —
89
119
  // OpenCode/Kilo provide the real `input.sessionID` on every hook, and a
90
120
  // process-global UUID would (a) never match prior-session resume rows and
@@ -97,6 +127,51 @@ async function createContextModePlugin(ctx) {
97
127
  // many sessions, so the gate must be keyed by sessionID — NOT a single
98
128
  // boolean closure flag (Mickey #2 root cause).
99
129
  const resumeInjected = new Set();
130
+ // OC-1: Routing block first-fire gate per session. Distinct from
131
+ // resumeInjected because routing block must always inject (regardless of
132
+ // whether a resume row exists), but resume only on rows present.
133
+ const routingInjected = new Set();
134
+ // OC-4: AGENTS.md/CLAUDE.md captured-once-per-projectDir gate. Idempotent
135
+ // across many sessions reusing the same plugin process + project tree.
136
+ const agentsCaptured = new Set();
137
+ /**
138
+ * OC-4: Read AGENTS.md (and CLAUDE.md fallback if both exist) from the
139
+ * project directory and persist as `rule` + `rule_content` events. Mirrors
140
+ * the CC SessionStart pattern at hooks/sessionstart.mjs:121-132. Idempotent
141
+ * via `agentsCaptured` Set keyed by projectDir.
142
+ */
143
+ function captureAgentsMd(sessionId) {
144
+ if (agentsCaptured.has(projectDir))
145
+ return;
146
+ agentsCaptured.add(projectDir);
147
+ // Mirror OpenCode's instruction.ts FILES order: AGENTS.md, CLAUDE.md, CONTEXT.md.
148
+ const candidates = ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"];
149
+ for (const name of candidates) {
150
+ try {
151
+ const p = join(projectDir, name);
152
+ if (!existsSync(p))
153
+ continue;
154
+ const content = readFileSync(p, "utf-8");
155
+ if (!content.trim())
156
+ continue;
157
+ db.insertEvent(sessionId, {
158
+ type: "rule",
159
+ category: "rule",
160
+ data: p,
161
+ priority: 1,
162
+ }, "PluginInit");
163
+ db.insertEvent(sessionId, {
164
+ type: "rule_content",
165
+ category: "rule",
166
+ data: content,
167
+ priority: 1,
168
+ }, "PluginInit");
169
+ }
170
+ catch {
171
+ // file missing or unreadable — skip silently
172
+ }
173
+ }
174
+ }
100
175
  return {
101
176
  // ── PreToolUse: Routing enforcement ─────────────────
102
177
  "tool.execute.before": async (input, output) => {
@@ -128,6 +203,9 @@ async function createContextModePlugin(ctx) {
128
203
  return;
129
204
  try {
130
205
  db.ensureSession(sessionId, projectDir);
206
+ // OC-4: Capture AGENTS.md/CLAUDE.md as rule events on first hook
207
+ // fire per projectDir. Idempotent via `agentsCaptured` Set.
208
+ captureAgentsMd(sessionId);
131
209
  const hookInput = {
132
210
  tool_name: input.tool ?? "",
133
211
  tool_input: input.args ?? {},
@@ -144,6 +222,43 @@ async function createContextModePlugin(ctx) {
144
222
  // Silent — session capture must never break the tool call
145
223
  }
146
224
  },
225
+ // ── chat.message: User-prompt capture (OC-2 / Z2) ───
226
+ // SDK signature verified at refs/platforms/opencode/packages/plugin/src/
227
+ // index.ts:233. Orchestrator reference at refs/plugin-examples/opencode/
228
+ // opencode-orchestrator/src/plugin-handlers/chat-message-handler.ts:41-65.
229
+ // CCv2 inline filter: skip synthetic harness messages (system reminders,
230
+ // tool results, etc.) so we don't pollute the user-prompt event stream.
231
+ "chat.message": async (input, output) => {
232
+ const sessionId = input?.sessionID;
233
+ if (!sessionId)
234
+ return;
235
+ try {
236
+ const parts = Array.isArray(output?.parts) ? output.parts : [];
237
+ const textPart = parts.find((p) => p && p.type === "text" && typeof p.text === "string" && p.text.length > 0);
238
+ if (!textPart || !textPart.text)
239
+ return;
240
+ const message = textPart.text;
241
+ if (isSyntheticMessage(message))
242
+ return;
243
+ db.ensureSession(sessionId, projectDir);
244
+ captureAgentsMd(sessionId);
245
+ // 1. Always save the raw prompt
246
+ db.insertEvent(sessionId, {
247
+ type: "user_prompt",
248
+ category: "user-prompt",
249
+ data: message,
250
+ priority: 1,
251
+ }, "UserPromptSubmit");
252
+ // 2. Extract role/decision/intent/skill events from the prompt body
253
+ const userEvents = extractUserEvents(message);
254
+ for (const ev of userEvents) {
255
+ db.insertEvent(sessionId, ev, "UserPromptSubmit");
256
+ }
257
+ }
258
+ catch {
259
+ // Silent — chat.message must never break the turn
260
+ }
261
+ },
147
262
  // ── PreCompact: Snapshot generation ─────────────────
148
263
  "experimental.session.compacting": async (input, output) => {
149
264
  const sessionId = input.sessionID;
@@ -162,6 +277,19 @@ async function createContextModePlugin(ctx) {
162
277
  db.incrementCompactCount(sessionId);
163
278
  // Mutate output.context to inject the snapshot
164
279
  output.context.push(snapshot);
280
+ // OC-3 / Z3: Add budget-capped auto-injection (P1 role / P2 rules /
281
+ // P3 skills / P4 intent — ≤500 tokens / ~2000 chars per
282
+ // hooks/auto-injection.mjs). Pushed as a separate context entry so
283
+ // OpenCode can fold it independently from the verbose snapshot.
284
+ try {
285
+ const autoBlock = autoInjectionMod.buildAutoInjection(events);
286
+ if (autoBlock && autoBlock.length > 0) {
287
+ output.context.push(autoBlock);
288
+ }
289
+ }
290
+ catch {
291
+ // Auto-injection failure must NOT break the snapshot path.
292
+ }
165
293
  return snapshot;
166
294
  }
167
295
  catch {
@@ -180,6 +308,25 @@ async function createContextModePlugin(ctx) {
180
308
  const sessionId = input?.sessionID;
181
309
  if (!sessionId)
182
310
  return;
311
+ // ── OC-1 / CCv1: ROUTING_BLOCK injection ──────────────
312
+ // Inject the <context_window_protection> XML block on the first
313
+ // chat.system.transform per session. This is INDEPENDENT of the
314
+ // resume snapshot path below — routing block must fire even when
315
+ // no prior session row exists. Splice at index 1 (NOT unshift) for
316
+ // the same OpenCode llm.ts:117-128 cache-fold reason as resume.
317
+ if (!routingInjected.has(sessionId) && Array.isArray(output?.system)) {
318
+ try {
319
+ // Visible marker — mirror the resume-snapshot pattern below so
320
+ // users can grep OPENCODE_DEBUG logs to confirm the routing block
321
+ // reached the model (Mickey-class verification path).
322
+ const marker = `<!-- context-mode v${VERSION}: routing block injected (sessionID=${sessionId.slice(0, 8)}) -->\n`;
323
+ output.system.splice(1, 0, marker + routingBlock);
324
+ routingInjected.add(sessionId);
325
+ }
326
+ catch {
327
+ // Never break the chat turn on routing-block injection failure.
328
+ }
329
+ }
183
330
  if (resumeInjected.has(sessionId))
184
331
  return;
185
332
  try {
@@ -14,7 +14,7 @@ import { createHash } from "node:crypto";
14
14
  import { existsSync, mkdirSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
16
  import { join, resolve, dirname } from "node:path";
17
- import { fileURLToPath } from "node:url";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import { SessionDB } from "./session/db.js";
19
19
  import { extractEvents, extractUserEvents } from "./session/extract.js";
20
20
  import { buildResumeSnapshot } from "./session/snapshot.js";
@@ -45,6 +45,38 @@ const BLOCKED_BASH_PATTERNS = [
45
45
  // ── Module-level DB singleton ────────────────────────────
46
46
  let _db = null;
47
47
  let _sessionId = "";
48
+ // Per-session gate: routing block injected at most once per session_id.
49
+ const _routingInjected = new Set();
50
+ // Cached routing-block string (built once per process from hooks/routing-block.mjs).
51
+ let _routingBlock = null;
52
+ async function getRoutingBlock(pluginRoot) {
53
+ if (_routingBlock !== null)
54
+ return _routingBlock;
55
+ try {
56
+ const routingMod = await import(pathToFileURL(join(pluginRoot, "hooks", "routing-block.mjs")).href);
57
+ const namingMod = await import(pathToFileURL(join(pluginRoot, "hooks", "core", "tool-naming.mjs")).href);
58
+ const t = namingMod.createToolNamer("pi");
59
+ _routingBlock = String(routingMod.createRoutingBlock(t));
60
+ }
61
+ catch {
62
+ _routingBlock = "";
63
+ }
64
+ return _routingBlock;
65
+ }
66
+ // Cached buildAutoInjection (500-token cap, prioritized).
67
+ let _buildAutoInjection = undefined;
68
+ async function getAutoInjection(pluginRoot) {
69
+ if (_buildAutoInjection !== undefined)
70
+ return _buildAutoInjection;
71
+ try {
72
+ const mod = await import(pathToFileURL(join(pluginRoot, "hooks", "auto-injection.mjs")).href);
73
+ _buildAutoInjection = mod.buildAutoInjection;
74
+ }
75
+ catch {
76
+ _buildAutoInjection = null;
77
+ }
78
+ return _buildAutoInjection ?? null;
79
+ }
48
80
  // ── Helpers ──────────────────────────────────────────────
49
81
  function getSessionDir() {
50
82
  const dir = join(homedir(), ".pi", "context-mode", "sessions");
@@ -218,8 +250,8 @@ export default function piExtension(pi) {
218
250
  // Silent — session capture must never break the tool call
219
251
  }
220
252
  });
221
- // ── 4. before_agent_start — Resume injection + user events
222
- pi.on("before_agent_start", (event) => {
253
+ // ── 4. before_agent_start — Routing + active_memory + resume injection
254
+ pi.on("before_agent_start", async (event) => {
223
255
  try {
224
256
  if (!_sessionId)
225
257
  return;
@@ -231,37 +263,64 @@ export default function piExtension(pi) {
231
263
  db.insertEvent(_sessionId, ev, "UserPromptSubmit");
232
264
  }
233
265
  }
234
- // Check for unconsumed resume snapshot
235
- const resume = db.getResume(_sessionId);
236
- if (!resume || resume.consumed)
237
- return;
238
- // Build FTS5 active memory from the current prompt
239
- const stats = db.getSessionStats(_sessionId);
240
- if ((stats?.compact_count ?? 0) === 0)
241
- return;
242
- // Mark resume as consumed so it is not re-injected
243
- db.markResumeConsumed(_sessionId);
244
- // Build memory context from recent high-priority events
245
- const allEvents = db.getEvents(_sessionId, { minPriority: 3, limit: 50 });
246
- let memoryContext = "";
247
- if (allEvents.length > 0) {
248
- const memoryLines = ["<active_memory>"];
249
- for (const ev of allEvents) {
250
- memoryLines.push(` <event type="${ev.type}" category="${ev.category}">${ev.data}</event>`);
251
- }
252
- memoryLines.push("</active_memory>");
253
- memoryContext = memoryLines.join("\n");
254
- }
255
- // Compose the augmented system prompt
256
266
  const existingPrompt = String(event?.systemPrompt ?? "");
257
267
  const parts = [];
258
268
  if (existingPrompt)
259
269
  parts.push(existingPrompt);
260
- if (resume.snapshot)
270
+ // Pi-1: Inject routing block once per session (gated by _routingInjected).
271
+ // v1.0.107 — visible marker so Pi users can verify the routing block
272
+ // reached the model (Mickey-class verification path; mirrors OpenCode).
273
+ if (!_routingInjected.has(_sessionId)) {
274
+ const routingBlock = await getRoutingBlock(pluginRoot);
275
+ if (routingBlock) {
276
+ const marker = `<!-- context-mode: routing block injected (sessionID=${String(_sessionId).slice(0, 8)}) -->`;
277
+ parts.push(marker + "\n" + routingBlock);
278
+ _routingInjected.add(_sessionId);
279
+ }
280
+ }
281
+ // Pi-3 + Pi-4: Always build active_memory (not just post-compact),
282
+ // capped at 500 tokens via buildAutoInjection. Falls back to inline
283
+ // budget loop if the helper is unavailable.
284
+ const activeEvents = db.getEvents(_sessionId, {
285
+ minPriority: 3,
286
+ limit: 50,
287
+ });
288
+ if (activeEvents.length > 0) {
289
+ const buildAuto = await getAutoInjection(pluginRoot);
290
+ let memoryContext = "";
291
+ if (buildAuto) {
292
+ memoryContext = buildAuto(activeEvents.map((e) => ({
293
+ category: String(e.category ?? ""),
294
+ data: String(e.data ?? ""),
295
+ })));
296
+ }
297
+ // Fallback (or if helper produced empty output): inline 500-token cap.
298
+ if (!memoryContext) {
299
+ const memoryLines = ["<active_memory>"];
300
+ let budget = 2000; // ~500 tokens at 4 chars/token
301
+ for (const ev of activeEvents) {
302
+ const line = ` <event type="${ev.type}" category="${ev.category}">${ev.data}</event>`;
303
+ if (line.length > budget)
304
+ break;
305
+ memoryLines.push(line);
306
+ budget -= line.length;
307
+ }
308
+ memoryLines.push("</active_memory>");
309
+ if (memoryLines.length > 2)
310
+ memoryContext = memoryLines.join("\n");
311
+ }
312
+ if (memoryContext)
313
+ parts.push(memoryContext);
314
+ }
315
+ // Resume snapshot (only when present and unconsumed).
316
+ const resume = db.getResume(_sessionId);
317
+ if (resume && !resume.consumed && resume.snapshot) {
261
318
  parts.push(resume.snapshot);
262
- if (memoryContext)
263
- parts.push(memoryContext);
264
- if (parts.length > (existingPrompt ? 1 : 0)) {
319
+ db.markResumeConsumed(_sessionId);
320
+ }
321
+ // Return modified systemPrompt only if we added something beyond existing.
322
+ const baseLen = existingPrompt ? 1 : 0;
323
+ if (parts.length > baseLen) {
265
324
  return { systemPrompt: parts.join("\n\n") };
266
325
  }
267
326
  }
@@ -269,6 +328,40 @@ export default function piExtension(pi) {
269
328
  // best effort — never break agent start
270
329
  }
271
330
  });
331
+ // ── 4b. before_provider_response — capture response metadata ───
332
+ // Pi-2: Register the missing event so providers can record latency,
333
+ // model, and token usage when Pi exposes them. Best-effort only;
334
+ // the handler must never throw or modify the response.
335
+ pi.on("before_provider_response", (event) => {
336
+ try {
337
+ if (!_sessionId)
338
+ return;
339
+ const meta = {
340
+ model: event?.model ?? event?.providerModel,
341
+ provider: event?.provider,
342
+ latencyMs: event?.latencyMs ?? event?.latency,
343
+ tokens: event?.usage ?? event?.tokens,
344
+ };
345
+ // Skip when Pi gives us nothing useful — avoids noise in the DB.
346
+ if (meta.model == null &&
347
+ meta.provider == null &&
348
+ meta.latencyMs == null &&
349
+ meta.tokens == null) {
350
+ return;
351
+ }
352
+ const data = JSON.stringify(meta);
353
+ db.insertEvent(_sessionId, {
354
+ type: "provider_response",
355
+ category: "pi",
356
+ data,
357
+ priority: 1,
358
+ data_hash: createHash("sha256").update(data).digest("hex").slice(0, 16),
359
+ }, "PostToolUse");
360
+ }
361
+ catch {
362
+ // best effort — never break provider response
363
+ }
364
+ });
272
365
  // ── 5. session_before_compact — Build resume snapshot ──
273
366
  pi.on("session_before_compact", () => {
274
367
  try {
@@ -305,6 +398,7 @@ export default function piExtension(pi) {
305
398
  _db.cleanupOldSessions(7);
306
399
  }
307
400
  _db = null;
401
+ _routingInjected.clear();
308
402
  _sessionId = "";
309
403
  }
310
404
  catch {
package/build/server.d.ts CHANGED
@@ -37,6 +37,7 @@ interface BatchExecutor {
37
37
  timedOut?: boolean;
38
38
  }>;
39
39
  }
40
+ export declare function buildBatchNodeOptionsPrefix(shellPath: string, preloadPath: string): string;
40
41
  /**
41
42
  * Execute batch commands. concurrency=1 preserves the legacy serial path
42
43
  * (shared timeout budget + cascading skip-on-timeout). concurrency>1 runs
package/build/server.js CHANGED
@@ -675,6 +675,24 @@ export function formatBatchQueryResults(store, queries, source, maxOutput = 80 *
675
675
  sections.push(`\n> **Tip:** Results are scoped to this batch only. To search across all indexed sources, use \`ctx_search(queries: [...])\`.`);
676
676
  return sections;
677
677
  }
678
+ function quotePosixSingle(value) {
679
+ return `'${value.replace(/'/g, "'\\''")}'`;
680
+ }
681
+ function quotePowerShellSingle(value) {
682
+ return `'${value.replace(/'/g, "''")}'`;
683
+ }
684
+ export function buildBatchNodeOptionsPrefix(shellPath, preloadPath) {
685
+ const option = `--require ${preloadPath}`;
686
+ const shell = shellPath.toLowerCase();
687
+ const base = shell.split(/[\\/]/).pop() ?? shell;
688
+ if (shell.includes("powershell") || shell.includes("pwsh")) {
689
+ return `$env:NODE_OPTIONS=${quotePowerShellSingle(option)}; `;
690
+ }
691
+ if (base === "cmd" || base === "cmd.exe") {
692
+ return `set "NODE_OPTIONS=${option.replace(/"/g, '""')}" && `;
693
+ }
694
+ return `NODE_OPTIONS=${quotePosixSingle(option)} `;
695
+ }
678
696
  function formatCommandOutput(label, raw, onFsBytes) {
679
697
  let output = raw || "(no output)";
680
698
  const fsMatches = output.matchAll(/__CM_FS__:(\d+)/g);
@@ -2070,7 +2088,7 @@ server.registerTool("ctx_batch_execute", {
2070
2088
  // Inject NODE_OPTIONS for FS read tracking in spawned Node processes.
2071
2089
  // The executor denies NODE_OPTIONS in its env (security), so we set it
2072
2090
  // as an inline shell prefix. This only affects child `node` invocations.
2073
- const nodeOptsPrefix = `NODE_OPTIONS="--require ${CM_FS_PRELOAD}" `;
2091
+ const nodeOptsPrefix = buildBatchNodeOptionsPrefix(runtimes.shell, CM_FS_PRELOAD);
2074
2092
  // Full stdout is preserved per-command and indexed into FTS5 (Issue #61, #197).
2075
2093
  // Concurrency>1 switches to a worker pool with per-command timeouts.
2076
2094
  const { outputs: perCommandOutputs, timedOut } = await runBatchCommands(commands, {
@@ -2816,6 +2834,13 @@ async function main() {
2816
2834
  fetchLatestVersion().then(v => { if (v !== "unknown")
2817
2835
  _latestVersion = v; });
2818
2836
  }, 60 * 60 * 1000).unref();
2837
+ // Stats heartbeat — keep the statusline truthful while the user works in
2838
+ // tools other than MCP (Bash/Read/Edit during long sessions or post-/compact
2839
+ // pauses). Without this, stats.updated_at only advances on MCP tool calls,
2840
+ // so bin/statusline.mjs falsely flips to "stale — restart to resume saving"
2841
+ // even though the server is alive. Heartbeat refreshes updated_at every 60s;
2842
+ // statusline staleness threshold is 30min (cliff is 30 missed ticks away).
2843
+ setInterval(() => persistStats(), 60_000).unref();
2819
2844
  console.error(`Context Mode MCP server v${VERSION} running on stdio`);
2820
2845
  console.error(`Detected runtimes:\n${getRuntimeSummary(runtimes)}`);
2821
2846
  if (!hasBunRuntime()) {
@@ -477,18 +477,30 @@ function dataBar(bytes, maxBytes, width = 40) {
477
477
  * actual remaining count (Bug #5 — was hardcoded "9 more").
478
478
  */
479
479
  function renderProjectMemory(pm, opts) {
480
- if (pm.total_events === 0 && (opts?.lifetime?.totalEvents ?? 0) === 0)
480
+ const sessionTokensSaved = opts?.sessionTokensSaved ?? 0;
481
+ // Render when EITHER disk has data OR current session has earnings.
482
+ if (pm.total_events === 0 &&
483
+ (opts?.lifetime?.totalEvents ?? 0) === 0 &&
484
+ sessionTokensSaved === 0) {
481
485
  return [];
486
+ }
482
487
  const topN = opts?.topN ?? 2;
483
488
  const out = [];
484
489
  out.push("");
485
490
  out.push("Persistent memory ✓ preserved across compact, restart & upgrade");
486
- // Lifetime line (Bug #3) collapses to project-only when lifetime missing.
491
+ // Lifetime line — disk-aggregated lifetime PLUS current session's in-memory
492
+ // savings. Two separate accounting pipelines (server bytes vs hook events)
493
+ // get unified at the render edge so the user always sees a monotonic total
494
+ // (lifetime ≥ session). Without this, fresh users / pre-b8e11bf sidecars /
495
+ // not-yet-flushed events show $0 lifetime even when the session earned $X.
487
496
  const lifeEvents = opts?.lifetime?.totalEvents ?? pm.total_events;
488
497
  const lifeSessions = opts?.lifetime?.totalSessions ?? pm.session_count;
489
- const sessionLabel = lifeSessions === 1 ? "1 session" : `${fmtNum(lifeSessions)} sessions`;
490
- // Estimate lifetime savings: ~1KB per event ~256 tokens/event at Opus rates.
491
- const lifetimeTokens = lifeEvents * 256;
498
+ // Current session counts as 1 when no prior session has been recorded yet.
499
+ const effectiveSessions = lifeSessions === 0 && sessionTokensSaved > 0 ? 1 : lifeSessions;
500
+ const sessionLabel = effectiveSessions === 1 ? "1 session" : `${fmtNum(effectiveSessions)} sessions`;
501
+ // Estimate lifetime savings: ~1KB per event → ~256 tokens/event at Opus rates,
502
+ // plus current session's already-tracked token savings (in-memory).
503
+ const lifetimeTokens = lifeEvents * 256 + sessionTokensSaved;
492
504
  out.push(` ${fmtNum(lifeEvents)} events · ${sessionLabel} · ~${tokensToUsd(lifetimeTokens)} saved lifetime`);
493
505
  out.push("");
494
506
  const cats = pm.by_category;
@@ -526,8 +538,11 @@ function renderAutoMemory(lifetime) {
526
538
  function renderBottomLine(sessionTokensSaved, lifetime) {
527
539
  const out = [];
528
540
  const sessionUsd = tokensToUsd(sessionTokensSaved);
529
- // Lifetime estimate: ~1KB/event ÷ 4 bytes/token = 256 tokens/event.
530
- const lifetimeTokens = (lifetime?.totalEvents ?? 0) * 256;
541
+ // Lifetime = disk-aggregated events × 256 tokens + current session's
542
+ // in-memory token savings. Two pipelines unified at the render edge so
543
+ // lifetime ≥ session always (never the surprising "$X session · $0 lifetime"
544
+ // a fresh user sees pre-flush).
545
+ const lifetimeTokens = (lifetime?.totalEvents ?? 0) * 256 + sessionTokensSaved;
531
546
  const lifetimeUsd = tokensToUsd(lifetimeTokens);
532
547
  out.push("");
533
548
  out.push("─".repeat(65));
@@ -572,7 +587,7 @@ export function formatReport(report, version, latestVersion, opts) {
572
587
  lines.push(`${kb(totalReturned)} entered context | 0 tokens saved`);
573
588
  }
574
589
  // Project memory + auto-memory + bottom line
575
- lines.push(...renderProjectMemory(report.projectMemory, { lifetime }));
590
+ lines.push(...renderProjectMemory(report.projectMemory, { lifetime, sessionTokensSaved: 0 }));
576
591
  lines.push(...renderAutoMemory(lifetime));
577
592
  lines.push(...renderBottomLine(0, lifetime));
578
593
  // Footer
@@ -627,15 +642,23 @@ export function formatReport(report, version, latestVersion, opts) {
627
642
  lines.push(` ${name.padEnd(22)} ${String(t.calls).padStart(4)} calls ${kb(t.estimatedSaved).padStart(8)} saved`);
628
643
  }
629
644
  }
630
- // ── MCP concurrency usage (only when batch tools recorded a concurrency) ──
645
+ // ── Parallel I/O value-forward framing for concurrent batch tools.
646
+ // Suppressed when no tool ran with max_concurrency > 1 (don't claim
647
+ // parallelism we didn't deliver). Internal mcp__*__ namespace stripped
648
+ // for user-facing readability.
631
649
  if (mcpUsage && mcpUsage.length > 0) {
632
- const concurrent = mcpUsage.filter((u) => u.median_concurrency != null);
633
- for (const u of concurrent) {
634
- lines.push(`MCP concurrency usage: ${u.tool_name} median=${u.median_concurrency} max=${u.max_concurrency} (${u.calls} calls)`);
650
+ const concurrent = mcpUsage.filter((u) => u.median_concurrency != null && (u.max_concurrency ?? 1) > 1);
651
+ if (concurrent.length > 0) {
652
+ lines.push("");
653
+ lines.push("Parallel I/O ✓ one call did the work of many — faster runs, lower bill, same answer.");
654
+ for (const u of concurrent) {
655
+ const name = u.tool_name.replace(/^mcp__.*?__/, "");
656
+ lines.push(` ${name.padEnd(22)} ${u.calls} batches · ${u.median_concurrency} typical, ${u.max_concurrency} peak`);
657
+ }
635
658
  }
636
659
  }
637
660
  // ── Project memory — persistent across sessions (Bug #3 + #5) ──
638
- lines.push(...renderProjectMemory(report.projectMemory, { lifetime }));
661
+ lines.push(...renderProjectMemory(report.projectMemory, { lifetime, sessionTokensSaved: tokensSaved }));
639
662
  // ── Auto-memory — Claude Code's preference learnings (Bug #4) ──
640
663
  lines.push(...renderAutoMemory(lifetime));
641
664
  // ── Bottom line — business value framing (Bug #8) ──
@@ -45,7 +45,7 @@ export declare function resetIterationLoopState(): void;
45
45
  * Accepts the raw hook JSON shape (snake_case keys) as received from stdin.
46
46
  * Returns an array of zero or more SessionEvents. Never throws.
47
47
  */
48
- export declare function extractEvents(input: HookInput): SessionEvent[];
48
+ export declare function extractEvents(rawInput: HookInput): SessionEvent[];
49
49
  /**
50
50
  * Extract session events from a UserPromptSubmit hook input (user message text).
51
51
  *