@jmylchreest/aide-plugin 0.0.24 → 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.
@@ -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";
@@ -81,6 +96,8 @@ interface AideState {
81
96
  binary: string | null;
82
97
  cwd: string;
83
98
  worktree: string;
99
+ /** Root of the aide plugin package (for finding bundled skills) */
100
+ pluginRoot: string | null;
84
101
  sessionState: SessionState | null;
85
102
  memories: MemoryInjection | null;
86
103
  welcomeContext: string | null;
@@ -90,6 +107,8 @@ interface AideState {
90
107
  processedMessageParts: Set<string>;
91
108
  /** Matched skills pending injection via system transform */
92
109
  pendingSkillsContext: string | null;
110
+ /** Last user prompt text, used for skill matching in system transform */
111
+ lastUserPrompt: string | null;
93
112
  /** Per-session metadata for agent-like tracking */
94
113
  sessionInfoMap: Map<string, SessionInfo>;
95
114
  client: OpenCodeClient;
@@ -102,18 +121,21 @@ export async function createHooks(
102
121
  cwd: string,
103
122
  worktree: string,
104
123
  client: OpenCodeClient,
124
+ pluginRoot?: string,
105
125
  ): Promise<Hooks> {
106
126
  const state: AideState = {
107
127
  initialized: false,
108
128
  binary: null,
109
129
  cwd,
110
130
  worktree,
131
+ pluginRoot: pluginRoot || null,
111
132
  sessionState: null,
112
133
  memories: null,
113
134
  welcomeContext: null,
114
135
  initializedSessions: new Set(),
115
136
  processedMessageParts: new Set(),
116
137
  pendingSkillsContext: null,
138
+ lastUserPrompt: null,
117
139
  sessionInfoMap: new Map(),
118
140
  client,
119
141
  };
@@ -123,6 +145,8 @@ export async function createHooks(
123
145
 
124
146
  return {
125
147
  event: createEventHandler(state),
148
+ config: createConfigHandler(state),
149
+ "command.execute.before": createCommandHandler(state),
126
150
  "tool.execute.before": createToolBeforeHandler(state),
127
151
  "tool.execute.after": createToolAfterHandler(state),
128
152
  "experimental.session.compacting": createCompactionHandler(state),
@@ -132,6 +156,94 @@ export async function createHooks(
132
156
  };
133
157
  }
134
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
+
135
247
  // =============================================================================
136
248
  // Initialization
137
249
  // =============================================================================
@@ -195,11 +307,33 @@ function createEventHandler(
195
307
  };
196
308
  }
197
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
+
198
332
  async function handleSessionCreated(
199
333
  state: AideState,
200
334
  event: OpenCodeEvent,
201
335
  ): Promise<void> {
202
- const sessionId = (event.properties.sessionID as string) || "unknown";
336
+ const sessionId = extractSessionId(event);
203
337
 
204
338
  if (state.initializedSessions.has(sessionId)) return;
205
339
  state.initializedSessions.add(sessionId);
@@ -254,7 +388,7 @@ async function handleSessionIdle(
254
388
  state: AideState,
255
389
  event: OpenCodeEvent,
256
390
  ): Promise<void> {
257
- const sessionId = (event.properties.sessionID as string) || "unknown";
391
+ const sessionId = extractSessionId(event);
258
392
 
259
393
  // Check persistence: if ralph/autopilot mode is active, re-prompt the session
260
394
  if (state.binary) {
@@ -306,10 +440,32 @@ async function handleSessionIdle(
306
440
  }
307
441
 
308
442
  // Capture session summary (best effort, no transcript available)
443
+ // Uses partials if available for a richer summary.
309
444
  if (state.binary) {
310
- 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
+
311
459
  if (summary) {
312
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);
313
469
  }
314
470
  }
315
471
  }
@@ -318,7 +474,7 @@ async function handleSessionDeleted(
318
474
  state: AideState,
319
475
  event: OpenCodeEvent,
320
476
  ): Promise<void> {
321
- const sessionId = (event.properties.sessionID as string) || "unknown";
477
+ const sessionId = extractSessionId(event);
322
478
 
323
479
  if (state.binary) {
324
480
  cleanupSession(state.binary, state.cwd, sessionId);
@@ -333,19 +489,17 @@ async function handleMessagePartUpdated(
333
489
  state: AideState,
334
490
  event: OpenCodeEvent,
335
491
  ): Promise<void> {
336
- // Skill injection: only process user messages
337
- const part = event.properties.part as
338
- | { type?: string; text?: string; role?: string }
339
- | undefined;
492
+ // Skill injection: only process user text parts
493
+ const part = event.properties.part as OpenCodePart | undefined;
340
494
  if (!part) return;
341
495
 
342
- // Only match skills for user text parts
343
- const role = (event.properties.role as string) || part.role;
344
- if (role !== "user") return;
345
- 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;
346
500
 
347
501
  // Dedup: don't re-process the same part
348
- const partId = (event.properties.partID as string) || "";
502
+ const partId = part.id || "";
349
503
  if (partId && state.processedMessageParts.has(partId)) return;
350
504
  if (partId) state.processedMessageParts.add(partId);
351
505
 
@@ -363,8 +517,13 @@ async function handleMessagePartUpdated(
363
517
  );
364
518
  }
365
519
 
366
- const prompt = part.text;
367
- const skills = discoverSkills(state.cwd);
520
+ const prompt = textContent;
521
+
522
+ // Store latest user prompt so system transform can match skills even if
523
+ // this event fires after the transform (defensive against ordering).
524
+ state.lastUserPrompt = prompt;
525
+
526
+ const skills = discoverSkills(state.cwd, state.pluginRoot ?? undefined);
368
527
  const matched = matchSkills(prompt, skills, 3);
369
528
 
370
529
  if (matched.length > 0) {
@@ -377,8 +536,6 @@ async function handleMessagePartUpdated(
377
536
  message: `Matched ${matched.length} skills: ${matched.map((s) => s.name).join(", ")}`,
378
537
  },
379
538
  });
380
- // Note: OpenCode doesn't have a direct skill injection hook like Claude Code's
381
- // UserPromptSubmit. Skills are logged here; injection happens via system transform.
382
539
  // Store matched skills for injection in system transform
383
540
  state.pendingSkillsContext = context;
384
541
  } catch (err) {
@@ -470,6 +627,23 @@ function createToolAfterHandler(
470
627
 
471
628
  updateToolStats(state.binary, state.cwd, input.tool, input.sessionID);
472
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
+
473
647
  // Comment checker: detect excessive comments in Write/Edit output
474
648
  try {
475
649
  const toolArgs = (_output.metadata?.args || {}) as Record<
@@ -514,18 +688,40 @@ function createCompactionHandler(
514
688
 
515
689
  // Persist a session summary as a memory before context is compacted.
516
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.
517
692
  try {
518
- 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
+
519
714
  if (summary) {
520
- 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(
521
718
  state.binary,
522
- state.cwd,
523
- input.sessionID,
524
- summary,
719
+ ["memory", "add", "--category=session", `--tags=${tags}`, summary],
720
+ { cwd: state.cwd, stdio: "pipe", timeout: 5000 },
525
721
  );
526
722
  debug(
527
723
  SOURCE,
528
- `Saved pre-compaction session summary for ${input.sessionID.slice(0, 8)}`,
724
+ `Saved pre-compaction partial session summary for ${input.sessionID.slice(0, 8)}`,
529
725
  );
530
726
  }
531
727
  } catch (err) {
@@ -629,10 +825,26 @@ function createSystemTransformHandler(
629
825
  }
630
826
  }
631
827
 
632
- // Inject any pending matched skills
828
+ // Inject matched skills. If message.part.updated already matched skills,
829
+ // use the pre-computed context. Otherwise, match inline from the last user
830
+ // prompt as a fallback (guards against event ordering issues).
633
831
  if (state.pendingSkillsContext) {
634
832
  output.system.push(state.pendingSkillsContext);
635
833
  state.pendingSkillsContext = null;
834
+ } else if (state.lastUserPrompt) {
835
+ try {
836
+ const skills = discoverSkills(state.cwd, state.pluginRoot ?? undefined);
837
+ const matched = matchSkills(state.lastUserPrompt, skills, 3);
838
+ if (matched.length > 0) {
839
+ output.system.push(formatSkillsContext(matched));
840
+ debug(
841
+ SOURCE,
842
+ `System transform fallback matched ${matched.length} skills`,
843
+ );
844
+ }
845
+ } catch (err) {
846
+ debug(SOURCE, `Fallback skill matching failed (non-critical): ${err}`);
847
+ }
636
848
  }
637
849
 
638
850
  // Inject messaging protocol for multi-instance coordination
@@ -25,12 +25,21 @@
25
25
  * ```
26
26
  */
27
27
 
28
+ import { dirname, join } from "path";
29
+ import { fileURLToPath } from "url";
28
30
  import { createHooks } from "./hooks.js";
29
31
  import type { Plugin, PluginInput, Hooks } from "./types.js";
30
32
 
33
+ // Resolve the plugin package root so we can find bundled skills.
34
+ // Works whether running from source (repo) or installed via npm.
35
+ const __filename = fileURLToPath(import.meta.url);
36
+ const __dirname = dirname(__filename);
37
+ // index.ts lives in src/opencode/, so the package root is two levels up.
38
+ const pluginRoot = join(__dirname, "..", "..");
39
+
31
40
  export const AidePlugin: Plugin = async (ctx: PluginInput): Promise<Hooks> => {
32
41
  const cwd = ctx.worktree || ctx.directory;
33
- return createHooks(cwd, ctx.worktree, ctx.client);
42
+ return createHooks(cwd, ctx.worktree, ctx.client, pluginRoot);
34
43
  };
35
44
 
36
45
  export default AidePlugin;
@@ -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