@jmylchreest/aide-plugin 0.0.25 → 0.0.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jmylchreest/aide-plugin",
3
- "version": "0.0.25",
3
+ "version": "0.0.26",
4
4
  "description": "aide plugin for OpenCode — multi-agent orchestration, memory, skills, and persistence",
5
5
  "type": "module",
6
6
  "main": "./src/opencode/index.ts",
package/src/core/index.ts CHANGED
@@ -13,6 +13,7 @@ export * from "./tool-tracking.js";
13
13
  export * from "./persistence-logic.js";
14
14
  export * from "./session-summary-logic.js";
15
15
  export * from "./pre-compact-logic.js";
16
+ export * from "./partial-memory.js";
16
17
  export * from "./cleanup.js";
17
18
  export * from "./mcp-sync.js";
18
19
  export * from "./tool-enforcement.js";
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Partial memory logic — platform-agnostic.
3
+ *
4
+ * Writes granular "partial" memories on significant tool events.
5
+ * These are tagged with "partial" so they:
6
+ * - Are excluded from normal recall (via ExcludeTags)
7
+ * - Can be gathered and rolled up into a final session summary
8
+ * - Get cleaned up (tagged "forget") after the final summary is written
9
+ *
10
+ * What counts as "significant":
11
+ * - Write/Edit/MultiEdit to a file (code change)
12
+ * - Bash commands that succeed (shell operations)
13
+ * - Task tool completions (subagent work)
14
+ * - NOT: Read, Grep, Glob (pure reads — no side effects)
15
+ */
16
+
17
+ import { execFileSync } from "child_process";
18
+ import { debug } from "../lib/logger.js";
19
+
20
+ const SOURCE = "partial-memory";
21
+
22
+ /** Tools that represent significant (state-changing) actions */
23
+ const SIGNIFICANT_TOOLS = new Set([
24
+ "Write",
25
+ "Edit",
26
+ "MultiEdit",
27
+ "Bash",
28
+ "Task",
29
+ ]);
30
+
31
+ /** Information about a completed tool use */
32
+ export interface ToolCompletionInfo {
33
+ toolName: string;
34
+ sessionId: string;
35
+ /** File path affected (Write/Edit) */
36
+ filePath?: string;
37
+ /** Bash command executed */
38
+ command?: string;
39
+ /** Task description */
40
+ description?: string;
41
+ /** Whether the tool succeeded */
42
+ success?: boolean;
43
+ }
44
+
45
+ /**
46
+ * Check whether a tool completion is "significant" enough to write a partial.
47
+ */
48
+ export function isSignificantToolUse(info: ToolCompletionInfo): boolean {
49
+ if (!SIGNIFICANT_TOOLS.has(info.toolName)) return false;
50
+
51
+ // For Bash, only track if we have a command (skip empty/failed)
52
+ if (info.toolName === "Bash") {
53
+ if (!info.command) return false;
54
+ // Skip trivial read-only commands
55
+ const cmd = info.command.trim().toLowerCase();
56
+ if (
57
+ cmd.startsWith("cat ") ||
58
+ cmd.startsWith("ls ") ||
59
+ cmd.startsWith("echo ") ||
60
+ cmd.startsWith("pwd") ||
61
+ cmd.startsWith("which ") ||
62
+ cmd.startsWith("type ")
63
+ ) {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ return true;
69
+ }
70
+
71
+ /**
72
+ * Build a concise partial memory content string from tool completion info.
73
+ */
74
+ export function buildPartialContent(info: ToolCompletionInfo): string {
75
+ switch (info.toolName) {
76
+ case "Write":
77
+ return info.filePath
78
+ ? `Created file: ${info.filePath}`
79
+ : "Created a file";
80
+ case "Edit":
81
+ case "MultiEdit":
82
+ return info.filePath ? `Edited file: ${info.filePath}` : "Edited a file";
83
+ case "Bash": {
84
+ const cmd =
85
+ info.command && info.command.length > 100
86
+ ? info.command.slice(0, 97) + "..."
87
+ : info.command;
88
+ return `Ran command: ${cmd}`;
89
+ }
90
+ case "Task":
91
+ return info.description
92
+ ? `Completed task: ${info.description}`
93
+ : "Completed a subtask";
94
+ default:
95
+ return `Used tool: ${info.toolName}`;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Build the tags for a partial memory.
101
+ */
102
+ export function buildPartialTags(
103
+ sessionId: string,
104
+ info: ToolCompletionInfo,
105
+ ): string[] {
106
+ const tags = [
107
+ "partial",
108
+ `session:${sessionId.slice(0, 8)}`,
109
+ `tool:${info.toolName.toLowerCase()}`,
110
+ ];
111
+ if (info.filePath) {
112
+ // Add a tag for the file extension to allow grouping
113
+ const ext = info.filePath.split(".").pop();
114
+ if (ext) tags.push(`ext:${ext}`);
115
+ }
116
+ return tags;
117
+ }
118
+
119
+ /**
120
+ * Store a partial memory for a significant tool event.
121
+ *
122
+ * Returns true if stored successfully.
123
+ */
124
+ export function storePartialMemory(
125
+ binary: string,
126
+ cwd: string,
127
+ info: ToolCompletionInfo,
128
+ ): boolean {
129
+ if (!isSignificantToolUse(info)) return false;
130
+
131
+ try {
132
+ const content = buildPartialContent(info);
133
+ const tags = buildPartialTags(info.sessionId, info);
134
+
135
+ execFileSync(
136
+ binary,
137
+ [
138
+ "memory",
139
+ "add",
140
+ "--category=session",
141
+ `--tags=${tags.join(",")}`,
142
+ content,
143
+ ],
144
+ { cwd, stdio: "pipe", timeout: 3000 },
145
+ );
146
+
147
+ debug(SOURCE, `Stored partial: ${content} [${tags.join(", ")}]`);
148
+ return true;
149
+ } catch (err) {
150
+ debug(SOURCE, `Failed to store partial (non-fatal): ${err}`);
151
+ return false;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Gather all partial memories for a session.
157
+ *
158
+ * Uses `aide memory list` with tag filtering to find all partials.
159
+ * Returns the raw output or null if none found.
160
+ */
161
+ export function gatherPartials(
162
+ binary: string,
163
+ cwd: string,
164
+ sessionId: string,
165
+ ): string[] {
166
+ try {
167
+ const sessionTag = `session:${sessionId.slice(0, 8)}`;
168
+
169
+ const output = execFileSync(
170
+ binary,
171
+ [
172
+ "memory",
173
+ "list",
174
+ "--tags=partial",
175
+ "--all", // Include even if tagged forget (shouldn't be, but defensive)
176
+ "--format=json",
177
+ "--limit=500",
178
+ ],
179
+ {
180
+ cwd,
181
+ encoding: "utf-8",
182
+ stdio: ["pipe", "pipe", "pipe"],
183
+ timeout: 5000,
184
+ },
185
+ ).trim();
186
+
187
+ if (!output || output === "[]") return [];
188
+
189
+ interface PartialMemory {
190
+ id: string;
191
+ tags: string[];
192
+ content: string;
193
+ }
194
+
195
+ const memories: PartialMemory[] = JSON.parse(output);
196
+ // Filter to this session's partials
197
+ return memories
198
+ .filter((m) => m.tags?.includes(sessionTag))
199
+ .map((m) => m.content);
200
+ } catch (err) {
201
+ debug(SOURCE, `Failed to gather partials: ${err}`);
202
+ return [];
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Gather partial memory IDs for a session (for cleanup).
208
+ */
209
+ export function gatherPartialIds(
210
+ binary: string,
211
+ cwd: string,
212
+ sessionId: string,
213
+ ): string[] {
214
+ try {
215
+ const sessionTag = `session:${sessionId.slice(0, 8)}`;
216
+
217
+ const output = execFileSync(
218
+ binary,
219
+ [
220
+ "memory",
221
+ "list",
222
+ "--tags=partial",
223
+ "--all",
224
+ "--format=json",
225
+ "--limit=500",
226
+ ],
227
+ {
228
+ cwd,
229
+ encoding: "utf-8",
230
+ stdio: ["pipe", "pipe", "pipe"],
231
+ timeout: 5000,
232
+ },
233
+ ).trim();
234
+
235
+ if (!output || output === "[]") return [];
236
+
237
+ interface PartialMemory {
238
+ id: string;
239
+ tags: string[];
240
+ }
241
+
242
+ const memories: PartialMemory[] = JSON.parse(output);
243
+ return memories
244
+ .filter((m) => m.tags?.includes(sessionTag))
245
+ .map((m) => m.id);
246
+ } catch (err) {
247
+ debug(SOURCE, `Failed to gather partial IDs: ${err}`);
248
+ return [];
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Clean up partials for a session by tagging them as "forget".
254
+ *
255
+ * This soft-deletes them so they won't appear in future queries
256
+ * but remain recoverable if needed.
257
+ */
258
+ export function cleanupPartials(
259
+ binary: string,
260
+ cwd: string,
261
+ sessionId: string,
262
+ ): number {
263
+ const ids = gatherPartialIds(binary, cwd, sessionId);
264
+ let cleaned = 0;
265
+
266
+ for (const id of ids) {
267
+ try {
268
+ execFileSync(binary, ["memory", "tag", id, "--add=forget"], {
269
+ cwd,
270
+ stdio: "pipe",
271
+ timeout: 3000,
272
+ });
273
+ cleaned++;
274
+ } catch (err) {
275
+ debug(SOURCE, `Failed to cleanup partial ${id}: ${err}`);
276
+ }
277
+ }
278
+
279
+ if (cleaned > 0) {
280
+ debug(
281
+ SOURCE,
282
+ `Cleaned up ${cleaned} partials for session ${sessionId.slice(0, 8)}`,
283
+ );
284
+ }
285
+ return cleaned;
286
+ }
287
+
288
+ /**
289
+ * Build a final session summary that incorporates partials.
290
+ *
291
+ * If partials are available, they're included alongside git data
292
+ * to produce a richer summary than either source alone.
293
+ */
294
+ export function buildSummaryFromPartials(
295
+ partials: string[],
296
+ gitCommits: string[],
297
+ gitFiles: string[],
298
+ ): string | null {
299
+ const summaryParts: string[] = [];
300
+
301
+ // Deduplicate and categorise partials
302
+ const fileChanges = new Set<string>();
303
+ const commands: string[] = [];
304
+ const tasks: string[] = [];
305
+ const other: string[] = [];
306
+
307
+ for (const p of partials) {
308
+ if (p.startsWith("Created file: ") || p.startsWith("Edited file: ")) {
309
+ fileChanges.add(p.replace(/^(Created|Edited) file: /, ""));
310
+ } else if (p.startsWith("Ran command: ")) {
311
+ commands.push(p.replace("Ran command: ", ""));
312
+ } else if (p.startsWith("Completed task: ")) {
313
+ tasks.push(p.replace("Completed task: ", ""));
314
+ } else {
315
+ other.push(p);
316
+ }
317
+ }
318
+
319
+ if (tasks.length > 0) {
320
+ summaryParts.push(
321
+ `## Tasks\n${tasks
322
+ .slice(0, 5)
323
+ .map((t) => `- ${t}`)
324
+ .join("\n")}`,
325
+ );
326
+ }
327
+
328
+ if (gitCommits.length > 0) {
329
+ summaryParts.push(
330
+ `## Commits\n${gitCommits.map((c) => `- ${c}`).join("\n")}`,
331
+ );
332
+ }
333
+
334
+ // Merge file changes from partials and git
335
+ const allFiles = new Set([...fileChanges, ...gitFiles]);
336
+ if (allFiles.size > 0) {
337
+ const files = Array.from(allFiles).slice(0, 15);
338
+ summaryParts.push(
339
+ `## Files Modified\n${files.map((f) => `- ${f}`).join("\n")}`,
340
+ );
341
+ }
342
+
343
+ if (commands.length > 0) {
344
+ summaryParts.push(
345
+ `## Commands\n${commands
346
+ .slice(0, 10)
347
+ .map((c) => `- ${c}`)
348
+ .join("\n")}`,
349
+ );
350
+ }
351
+
352
+ const summary = summaryParts.join("\n\n");
353
+ return summary.length >= 50 ? summary : null;
354
+ }
@@ -241,8 +241,6 @@ export function runSessionInit(
241
241
  return result;
242
242
  }
243
243
 
244
- const dbPath = join(cwd, ".aide", "memory", "store.db");
245
-
246
244
  try {
247
245
  const args = [
248
246
  "session",
@@ -255,7 +253,6 @@ export function runSessionInit(
255
253
  cwd,
256
254
  encoding: "utf-8",
257
255
  timeout: 15000,
258
- env: { ...process.env, AIDE_MEMORY_DB: dbPath },
259
256
  }).trim();
260
257
 
261
258
  if (!output) return result;
@@ -169,9 +169,7 @@ export function buildSessionSummaryFromState(cwd: string): string | null {
169
169
  const summaryParts: string[] = [];
170
170
 
171
171
  if (commits.length > 0) {
172
- summaryParts.push(
173
- `## Commits\n${commits.map((c) => `- ${c}`).join("\n")}`,
174
- );
172
+ summaryParts.push(`## Commits\n${commits.map((c) => `- ${c}`).join("\n")}`);
175
173
  }
176
174
 
177
175
  // Check for modified files via git
@@ -216,14 +214,12 @@ export function storeSessionSummary(
216
214
  summary: string,
217
215
  ): boolean {
218
216
  try {
219
- const dbPath = join(cwd, ".aide", "memory", "store.db");
220
- const env = { ...process.env, AIDE_MEMORY_DB: dbPath };
221
217
  const tags = `session-summary,session:${sessionId.slice(0, 8)}`;
222
218
 
223
219
  execFileSync(
224
220
  binary,
225
221
  ["memory", "add", "--category=session", `--tags=${tags}`, summary],
226
- { env, stdio: "pipe", timeout: 5000 },
222
+ { cwd, stdio: "pipe", timeout: 5000 },
227
223
  );
228
224
 
229
225
  return true;
@@ -25,6 +25,7 @@
25
25
  * Stop (blocking) → session.idle re-prompts via session.prompt() for persistence
26
26
  */
27
27
 
28
+ import { execFileSync } from "child_process";
28
29
  import { findAideBinary } from "../core/aide-client.js";
29
30
  import {
30
31
  ensureDirectories,
@@ -56,10 +57,24 @@ import { saveStateSnapshot } from "../core/pre-compact-logic.js";
56
57
  import { cleanupSession } from "../core/cleanup.js";
57
58
  import {
58
59
  buildSessionSummaryFromState,
60
+ getSessionCommits,
59
61
  storeSessionSummary,
60
62
  } from "../core/session-summary-logic.js";
63
+ import {
64
+ storePartialMemory,
65
+ gatherPartials,
66
+ buildSummaryFromPartials,
67
+ cleanupPartials,
68
+ } from "../core/partial-memory.js";
61
69
  import type { MemoryInjection, SessionState } from "../core/types.js";
62
- import type { Hooks, OpenCodeClient, OpenCodeEvent } from "./types.js";
70
+ import type {
71
+ Hooks,
72
+ OpenCodeClient,
73
+ OpenCodeConfig,
74
+ OpenCodeEvent,
75
+ OpenCodeSession,
76
+ OpenCodePart,
77
+ } from "./types.js";
63
78
  import { debug } from "../lib/logger.js";
64
79
 
65
80
  const SOURCE = "opencode-hooks";
@@ -130,6 +145,8 @@ export async function createHooks(
130
145
 
131
146
  return {
132
147
  event: createEventHandler(state),
148
+ config: createConfigHandler(state),
149
+ "command.execute.before": createCommandHandler(state),
133
150
  "tool.execute.before": createToolBeforeHandler(state),
134
151
  "tool.execute.after": createToolAfterHandler(state),
135
152
  "experimental.session.compacting": createCompactionHandler(state),
@@ -139,6 +156,94 @@ export async function createHooks(
139
156
  };
140
157
  }
141
158
 
159
+ // =============================================================================
160
+ // Config handler (register aide commands as OpenCode slash commands)
161
+ // =============================================================================
162
+
163
+ function createConfigHandler(
164
+ state: AideState,
165
+ ): (input: OpenCodeConfig) => Promise<void> {
166
+ return async (input) => {
167
+ try {
168
+ // Discover all skills and register them as OpenCode commands
169
+ const skills = discoverSkills(state.cwd, state.pluginRoot ?? undefined);
170
+
171
+ if (!input.command) {
172
+ input.command = {};
173
+ }
174
+
175
+ for (const skill of skills) {
176
+ const commandName = `aide:${skill.name}`;
177
+ // Only register if not already defined (user config takes priority)
178
+ if (!input.command[commandName]) {
179
+ input.command[commandName] = {
180
+ template: `Activate the aide "${skill.name}" skill. {{arguments}}`,
181
+ description: skill.description || `aide ${skill.name} skill`,
182
+ };
183
+ }
184
+ }
185
+
186
+ debug(
187
+ SOURCE,
188
+ `Registered ${skills.length} aide commands via config hook`,
189
+ );
190
+ } catch (err) {
191
+ debug(SOURCE, `Config hook failed (non-fatal): ${err}`);
192
+ }
193
+ };
194
+ }
195
+
196
+ // =============================================================================
197
+ // Command handler (intercept aide slash command execution)
198
+ // =============================================================================
199
+
200
+ function createCommandHandler(state: AideState): (
201
+ input: { command: string; sessionID: string; arguments: string },
202
+ output: {
203
+ parts: Array<{ type: string; text: string; [key: string]: unknown }>;
204
+ },
205
+ ) => Promise<void> {
206
+ return async (input, output) => {
207
+ // Only handle aide: prefixed commands
208
+ if (!input.command.startsWith("aide:")) return;
209
+
210
+ const skillName = input.command.slice("aide:".length);
211
+ const args = input.arguments || "";
212
+
213
+ try {
214
+ const skills = discoverSkills(state.cwd, state.pluginRoot ?? undefined);
215
+ const skill = skills.find((s) => s.name === skillName);
216
+
217
+ if (skill) {
218
+ // Format the skill content for injection
219
+ const context = formatSkillsContext([skill]);
220
+
221
+ // Store for system transform injection
222
+ state.pendingSkillsContext = context;
223
+
224
+ // Also store the arguments as the user prompt for the transform
225
+ if (args) {
226
+ state.lastUserPrompt = args;
227
+ }
228
+
229
+ debug(SOURCE, `Command handler activated skill: ${skillName}`);
230
+
231
+ await state.client.app.log({
232
+ body: {
233
+ service: "aide",
234
+ level: "info",
235
+ message: `Activated skill: ${skillName}${args ? ` with args: ${args.slice(0, 50)}` : ""}`,
236
+ },
237
+ });
238
+ } else {
239
+ debug(SOURCE, `Command handler: unknown skill "${skillName}"`);
240
+ }
241
+ } catch (err) {
242
+ debug(SOURCE, `Command handler failed (non-fatal): ${err}`);
243
+ }
244
+ };
245
+ }
246
+
142
247
  // =============================================================================
143
248
  // Initialization
144
249
  // =============================================================================
@@ -202,11 +307,33 @@ function createEventHandler(
202
307
  };
203
308
  }
204
309
 
310
+ /**
311
+ * Extract session ID from an event. OpenCode uses different shapes:
312
+ * - session.created/deleted/updated: { properties: { info: { id: string } } }
313
+ * - session.idle/compacted: { properties: { sessionID: string } }
314
+ * - message.part.updated: { properties: { part: { sessionID: string } } }
315
+ */
316
+ function extractSessionId(event: OpenCodeEvent): string {
317
+ // session.created / session.deleted / session.updated — nested under info
318
+ const info = event.properties.info as OpenCodeSession | undefined;
319
+ if (info?.id) return info.id;
320
+
321
+ // session.idle, session.compacted, tool hooks — direct sessionID
322
+ const sessionID = event.properties.sessionID as string | undefined;
323
+ if (sessionID) return sessionID;
324
+
325
+ // message.part.updated — sessionID on the part object
326
+ const part = event.properties.part as OpenCodePart | undefined;
327
+ if (part && "sessionID" in part && part.sessionID) return part.sessionID;
328
+
329
+ return "unknown";
330
+ }
331
+
205
332
  async function handleSessionCreated(
206
333
  state: AideState,
207
334
  event: OpenCodeEvent,
208
335
  ): Promise<void> {
209
- const sessionId = (event.properties.sessionID as string) || "unknown";
336
+ const sessionId = extractSessionId(event);
210
337
 
211
338
  if (state.initializedSessions.has(sessionId)) return;
212
339
  state.initializedSessions.add(sessionId);
@@ -261,7 +388,7 @@ async function handleSessionIdle(
261
388
  state: AideState,
262
389
  event: OpenCodeEvent,
263
390
  ): Promise<void> {
264
- const sessionId = (event.properties.sessionID as string) || "unknown";
391
+ const sessionId = extractSessionId(event);
265
392
 
266
393
  // Check persistence: if ralph/autopilot mode is active, re-prompt the session
267
394
  if (state.binary) {
@@ -313,10 +440,32 @@ async function handleSessionIdle(
313
440
  }
314
441
 
315
442
  // Capture session summary (best effort, no transcript available)
443
+ // Uses partials if available for a richer summary.
316
444
  if (state.binary) {
317
- const summary = buildSessionSummaryFromState(state.cwd);
445
+ const partials = gatherPartials(state.binary, state.cwd, sessionId);
446
+ let summary: string | null = null;
447
+
448
+ if (partials.length > 0) {
449
+ const commits = getSessionCommits(state.cwd);
450
+ summary = buildSummaryFromPartials(partials, commits, []);
451
+ debug(SOURCE, `Built summary from ${partials.length} partials`);
452
+ }
453
+
454
+ // Fall back to state-only summary if no partials
455
+ if (!summary) {
456
+ summary = buildSessionSummaryFromState(state.cwd);
457
+ }
458
+
318
459
  if (summary) {
319
460
  storeSessionSummary(state.binary, state.cwd, sessionId, summary);
461
+ // Clean up partials now that the final summary is stored
462
+ const cleaned = cleanupPartials(state.binary, state.cwd, sessionId);
463
+ if (cleaned > 0) {
464
+ debug(SOURCE, `Cleaned up ${cleaned} partials after final summary`);
465
+ }
466
+ } else if (partials.length > 0) {
467
+ // Clean up partials even if no summary was generated
468
+ cleanupPartials(state.binary, state.cwd, sessionId);
320
469
  }
321
470
  }
322
471
  }
@@ -325,7 +474,7 @@ async function handleSessionDeleted(
325
474
  state: AideState,
326
475
  event: OpenCodeEvent,
327
476
  ): Promise<void> {
328
- const sessionId = (event.properties.sessionID as string) || "unknown";
477
+ const sessionId = extractSessionId(event);
329
478
 
330
479
  if (state.binary) {
331
480
  cleanupSession(state.binary, state.cwd, sessionId);
@@ -340,19 +489,17 @@ async function handleMessagePartUpdated(
340
489
  state: AideState,
341
490
  event: OpenCodeEvent,
342
491
  ): Promise<void> {
343
- // Skill injection: only process user messages
344
- const part = event.properties.part as
345
- | { type?: string; text?: string; role?: string }
346
- | undefined;
492
+ // Skill injection: only process user text parts
493
+ const part = event.properties.part as OpenCodePart | undefined;
347
494
  if (!part) return;
348
495
 
349
- // Only match skills for user text parts
350
- const role = (event.properties.role as string) || part.role;
351
- if (role !== "user") return;
352
- if (part.type !== "text" || !part.text) return;
496
+ // Only match skills for text parts (user messages)
497
+ if (part.type !== "text") return;
498
+ const textContent = (part as { text?: string }).text;
499
+ if (!textContent) return;
353
500
 
354
501
  // Dedup: don't re-process the same part
355
- const partId = (event.properties.partID as string) || "";
502
+ const partId = part.id || "";
356
503
  if (partId && state.processedMessageParts.has(partId)) return;
357
504
  if (partId) state.processedMessageParts.add(partId);
358
505
 
@@ -370,7 +517,7 @@ async function handleMessagePartUpdated(
370
517
  );
371
518
  }
372
519
 
373
- const prompt = part.text;
520
+ const prompt = textContent;
374
521
 
375
522
  // Store latest user prompt so system transform can match skills even if
376
523
  // this event fires after the transform (defensive against ordering).
@@ -480,6 +627,23 @@ function createToolAfterHandler(
480
627
 
481
628
  updateToolStats(state.binary, state.cwd, input.tool, input.sessionID);
482
629
 
630
+ // Write a partial memory for significant tool uses
631
+ try {
632
+ const toolArgs = (_output.metadata?.args || {}) as Record<
633
+ string,
634
+ unknown
635
+ >;
636
+ storePartialMemory(state.binary, state.cwd, {
637
+ toolName: input.tool,
638
+ sessionId: input.sessionID,
639
+ filePath: toolArgs.file_path as string | undefined,
640
+ command: toolArgs.command as string | undefined,
641
+ description: toolArgs.description as string | undefined,
642
+ });
643
+ } catch (err) {
644
+ debug(SOURCE, `Partial memory write failed (non-fatal): ${err}`);
645
+ }
646
+
483
647
  // Comment checker: detect excessive comments in Write/Edit output
484
648
  try {
485
649
  const toolArgs = (_output.metadata?.args || {}) as Record<
@@ -524,18 +688,40 @@ function createCompactionHandler(
524
688
 
525
689
  // Persist a session summary as a memory before context is compacted.
526
690
  // This ensures the work-so-far is recoverable after compaction.
691
+ // Uses partials (if available) for a richer summary, falling back to git-only.
527
692
  try {
528
- const summary = buildSessionSummaryFromState(state.cwd);
693
+ const partials = gatherPartials(
694
+ state.binary,
695
+ state.cwd,
696
+ input.sessionID,
697
+ );
698
+ let summary: string | null = null;
699
+
700
+ if (partials.length > 0) {
701
+ const commits = getSessionCommits(state.cwd);
702
+ summary = buildSummaryFromPartials(partials, commits, []);
703
+ debug(
704
+ SOURCE,
705
+ `Built pre-compact summary from ${partials.length} partials`,
706
+ );
707
+ }
708
+
709
+ // Fall back to state-only summary if no partials
710
+ if (!summary) {
711
+ summary = buildSessionSummaryFromState(state.cwd);
712
+ }
713
+
529
714
  if (summary) {
530
- storeSessionSummary(
715
+ // Tag as partial so the session-end summary supersedes it
716
+ const tags = `partial,session-summary,session:${input.sessionID.slice(0, 8)}`;
717
+ execFileSync(
531
718
  state.binary,
532
- state.cwd,
533
- input.sessionID,
534
- summary,
719
+ ["memory", "add", "--category=session", `--tags=${tags}`, summary],
720
+ { cwd: state.cwd, stdio: "pipe", timeout: 5000 },
535
721
  );
536
722
  debug(
537
723
  SOURCE,
538
- `Saved pre-compaction session summary for ${input.sessionID.slice(0, 8)}`,
724
+ `Saved pre-compaction partial session summary for ${input.sessionID.slice(0, 8)}`,
539
725
  );
540
726
  }
541
727
  } catch (err) {
@@ -31,7 +31,9 @@ export interface PluginInput {
31
31
  /** Minimal SDK client interface — only the parts we use */
32
32
  export interface OpenCodeClient {
33
33
  app: {
34
- log(opts: { body: { service: string; level: string; message: string } }): Promise<void>;
34
+ log(opts: {
35
+ body: { service: string; level: string; message: string };
36
+ }): Promise<void>;
35
37
  };
36
38
  session: {
37
39
  create(opts: { body: { title?: string } }): Promise<{ id: string }>;
@@ -54,11 +56,86 @@ export interface OpenCodeClient {
54
56
  // Events
55
57
  // =============================================================================
56
58
 
59
+ /** Session object as returned in session.created/updated/deleted events */
60
+ export interface OpenCodeSession {
61
+ id: string;
62
+ projectID: string;
63
+ directory: string;
64
+ parentID?: string;
65
+ title: string;
66
+ version: string;
67
+ time: {
68
+ created: number;
69
+ updated: number;
70
+ compacting?: number;
71
+ };
72
+ }
73
+
74
+ /** Text part with session context */
75
+ export interface OpenCodeTextPart {
76
+ id: string;
77
+ sessionID: string;
78
+ messageID: string;
79
+ type: "text";
80
+ text: string;
81
+ synthetic?: boolean;
82
+ ignored?: boolean;
83
+ }
84
+
85
+ /** Generic part — may be text, tool, step, etc. */
86
+ export type OpenCodePart =
87
+ | OpenCodeTextPart
88
+ | {
89
+ id: string;
90
+ sessionID: string;
91
+ messageID: string;
92
+ type: string;
93
+ [key: string]: unknown;
94
+ };
95
+
57
96
  export interface OpenCodeEvent {
58
97
  type: string;
59
98
  properties: Record<string, unknown>;
60
99
  }
61
100
 
101
+ /** Typed event shapes matching the OpenCode SDK */
102
+ export interface EventSessionCreated {
103
+ type: "session.created";
104
+ properties: { info: OpenCodeSession };
105
+ }
106
+
107
+ export interface EventSessionDeleted {
108
+ type: "session.deleted";
109
+ properties: { info: OpenCodeSession };
110
+ }
111
+
112
+ export interface EventSessionIdle {
113
+ type: "session.idle";
114
+ properties: { sessionID: string };
115
+ }
116
+
117
+ export interface EventMessagePartUpdated {
118
+ type: "message.part.updated";
119
+ properties: { part: OpenCodePart; delta?: string };
120
+ }
121
+
122
+ // =============================================================================
123
+ // OpenCode Config (command registration)
124
+ // =============================================================================
125
+
126
+ export interface OpenCodeConfig {
127
+ command?: {
128
+ [key: string]: {
129
+ template: string;
130
+ description?: string;
131
+ agent?: string;
132
+ model?: string;
133
+ subtask?: boolean;
134
+ };
135
+ };
136
+ [key: string]: unknown;
137
+ }
138
+
62
139
  // =============================================================================
63
140
  // Hook signatures
64
141
  // =============================================================================
@@ -67,6 +144,17 @@ export interface Hooks {
67
144
  /** Generic event listener for all OpenCode events */
68
145
  event?: (input: { event: OpenCodeEvent }) => Promise<void>;
69
146
 
147
+ /** Modify OpenCode config (register commands, etc.) */
148
+ config?: (input: OpenCodeConfig) => Promise<void>;
149
+
150
+ /** Intercept command execution (slash commands) */
151
+ "command.execute.before"?: (
152
+ input: { command: string; sessionID: string; arguments: string },
153
+ output: {
154
+ parts: Array<{ type: string; text: string; [key: string]: unknown }>;
155
+ },
156
+ ) => Promise<void>;
157
+
70
158
  /** Modify tool arguments before execution */
71
159
  "tool.execute.before"?: (
72
160
  input: { tool: string; sessionID: string; callID: string },
@@ -76,12 +164,19 @@ export interface Hooks {
76
164
  /** React after tool completes */
77
165
  "tool.execute.after"?: (
78
166
  input: { tool: string; sessionID: string; callID: string },
79
- output: { title: string; output: string; metadata: Record<string, unknown> },
167
+ output: {
168
+ title: string;
169
+ output: string;
170
+ metadata: Record<string, unknown>;
171
+ },
80
172
  ) => Promise<void>;
81
173
 
82
174
  /** Modify system prompt */
83
175
  "experimental.chat.system.transform"?: (
84
- input: { sessionID?: string; model: { providerID: string; modelID: string } },
176
+ input: {
177
+ sessionID?: string;
178
+ model: { providerID: string; modelID: string };
179
+ },
85
180
  output: { system: string[] },
86
181
  ) => Promise<void>;
87
182