@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 +1 -1
- package/src/core/index.ts +1 -0
- package/src/core/partial-memory.ts +354 -0
- package/src/core/session-init.ts +0 -3
- package/src/core/session-summary-logic.ts +2 -6
- package/src/opencode/hooks.ts +207 -21
- package/src/opencode/types.ts +98 -3
package/package.json
CHANGED
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
|
+
}
|
package/src/core/session-init.ts
CHANGED
|
@@ -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
|
-
{
|
|
222
|
+
{ cwd, stdio: "pipe", timeout: 5000 },
|
|
227
223
|
);
|
|
228
224
|
|
|
229
225
|
return true;
|
package/src/opencode/hooks.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
533
|
-
|
|
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) {
|
package/src/opencode/types.ts
CHANGED
|
@@ -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: {
|
|
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: {
|
|
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: {
|
|
176
|
+
input: {
|
|
177
|
+
sessionID?: string;
|
|
178
|
+
model: { providerID: string; modelID: string };
|
|
179
|
+
},
|
|
85
180
|
output: { system: string[] },
|
|
86
181
|
) => Promise<void>;
|
|
87
182
|
|