opencodekit 0.13.1 → 0.14.0
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/README.md +2 -2
- package/dist/index.js +16 -4
- package/dist/template/.opencode/AGENTS.md +13 -4
- package/dist/template/.opencode/README.md +100 -4
- package/dist/template/.opencode/command/brainstorm.md +25 -2
- package/dist/template/.opencode/command/finish.md +21 -4
- package/dist/template/.opencode/command/handoff.md +17 -0
- package/dist/template/.opencode/command/implement.md +38 -0
- package/dist/template/.opencode/command/plan.md +32 -0
- package/dist/template/.opencode/command/research.md +61 -5
- package/dist/template/.opencode/command/resume.md +31 -0
- package/dist/template/.opencode/command/start.md +31 -0
- package/dist/template/.opencode/command/triage.md +16 -1
- package/dist/template/.opencode/memory/observations/.gitkeep +0 -0
- package/dist/template/.opencode/memory/project/conventions.md +31 -0
- package/dist/template/.opencode/memory/vector_db/memories.lance/_transactions/0-8d00d272-cb80-463b-9774-7120a1c994e7.txn +0 -0
- package/dist/template/.opencode/memory/vector_db/memories.lance/_transactions/1-a3bea825-dad3-47dd-a6d6-ff41b76ff7b0.txn +0 -0
- package/dist/template/.opencode/memory/vector_db/memories.lance/_versions/1.manifest +0 -0
- package/dist/template/.opencode/memory/vector_db/memories.lance/_versions/2.manifest +0 -0
- package/dist/template/.opencode/memory/vector_db/memories.lance/data/001010101000000101110001f998d04b63936ff83f9a34152d.lance +0 -0
- package/dist/template/.opencode/memory/vector_db/memories.lance/data/010000101010000000010010701b3840d38c2b5f275da99978.lance +0 -0
- package/dist/template/.opencode/opencode.json +587 -511
- package/dist/template/.opencode/package.json +3 -1
- package/dist/template/.opencode/plugin/memory.ts +610 -0
- package/dist/template/.opencode/tool/memory-embed.ts +183 -0
- package/dist/template/.opencode/tool/memory-index.ts +769 -0
- package/dist/template/.opencode/tool/memory-search.ts +358 -66
- package/dist/template/.opencode/tool/observation.ts +301 -12
- package/dist/template/.opencode/tool/repo-map.ts +451 -0
- package/package.json +16 -4
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Plugin (Refactored for OpenCode Event System)
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* 1. Keyword Detection: Detects "remember", "save this", etc. and nudges agent
|
|
6
|
+
* 2. Auto-Index Rebuild: Rebuilds vector index when memory files change
|
|
7
|
+
* 3. Code-Change Awareness: Flags stale observations when related code changes
|
|
8
|
+
* 4. Toast Notifications: Alerts agent when observations need review
|
|
9
|
+
* 5. Session Idle Hook: Prompts memory summary at session end
|
|
10
|
+
*
|
|
11
|
+
* Uses OpenCode's official event system (v1.1.2+):
|
|
12
|
+
* - file.edited: When OpenCode edits files
|
|
13
|
+
* - file.watcher.updated: External file changes
|
|
14
|
+
* - session.idle: Session completed
|
|
15
|
+
* - tui.toast.show: Display notifications
|
|
16
|
+
*
|
|
17
|
+
* Inspired by: Supermemory, Nia, Graphiti, GKG
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import fsPromises from "node:fs/promises";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Configuration
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
const MEMORY_DIR = ".opencode/memory";
|
|
30
|
+
const BEADS_DIR = ".beads/artifacts";
|
|
31
|
+
const SRC_DIR = "src";
|
|
32
|
+
const CODE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs"];
|
|
33
|
+
const DEBOUNCE_MS = 30000; // 30 seconds
|
|
34
|
+
const CODE_CHANGE_DEBOUNCE_MS = 10000; // 10 seconds for toast responsiveness
|
|
35
|
+
|
|
36
|
+
// Default trigger patterns (case-insensitive)
|
|
37
|
+
const DEFAULT_PATTERNS = [
|
|
38
|
+
// English
|
|
39
|
+
"remember\\s+(this|that)",
|
|
40
|
+
"save\\s+(this|that)",
|
|
41
|
+
"don'?t\\s+forget",
|
|
42
|
+
"note\\s+(this|that)",
|
|
43
|
+
"keep\\s+(this\\s+)?in\\s+mind",
|
|
44
|
+
"store\\s+this",
|
|
45
|
+
"log\\s+this",
|
|
46
|
+
"write\\s+(this\\s+)?down",
|
|
47
|
+
"make\\s+a\\s+note",
|
|
48
|
+
"add\\s+to\\s+memory",
|
|
49
|
+
"commit\\s+to\\s+memory",
|
|
50
|
+
"take\\s+note",
|
|
51
|
+
"jot\\s+(this\\s+)?down",
|
|
52
|
+
"file\\s+this\\s+away",
|
|
53
|
+
"bookmark\\s+this",
|
|
54
|
+
"pin\\s+this",
|
|
55
|
+
"important\\s+to\\s+remember",
|
|
56
|
+
"for\\s+future\\s+reference",
|
|
57
|
+
"never\\s+forget",
|
|
58
|
+
"always\\s+remember",
|
|
59
|
+
|
|
60
|
+
// Vietnamese
|
|
61
|
+
"nhớ\\s+(điều\\s+)?này",
|
|
62
|
+
"ghi\\s+nhớ",
|
|
63
|
+
"lưu\\s+(lại\\s+)?(điều\\s+)?này",
|
|
64
|
+
"đừng\\s+quên",
|
|
65
|
+
"không\\s+được\\s+quên",
|
|
66
|
+
"ghi\\s+chú",
|
|
67
|
+
"note\\s+lại",
|
|
68
|
+
"save\\s+lại",
|
|
69
|
+
"để\\s+ý",
|
|
70
|
+
"chú\\s+ý",
|
|
71
|
+
"quan\\s+trọng",
|
|
72
|
+
"cần\\s+nhớ",
|
|
73
|
+
"phải\\s+nhớ",
|
|
74
|
+
"luôn\\s+nhớ",
|
|
75
|
+
"ghi\\s+lại",
|
|
76
|
+
"lưu\\s+ý",
|
|
77
|
+
"đánh\\s+dấu",
|
|
78
|
+
"bookmark\\s+lại",
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const DEFAULT_NUDGE = `
|
|
82
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
83
|
+
[MEMORY TRIGGER DETECTED]
|
|
84
|
+
|
|
85
|
+
The user wants you to remember something. You MUST:
|
|
86
|
+
|
|
87
|
+
1. Extract the key information to save
|
|
88
|
+
2. Call the \`observation\` tool with:
|
|
89
|
+
- type: Choose from "learning", "decision", "pattern", "preference", or "warning"
|
|
90
|
+
- title: A concise, searchable title
|
|
91
|
+
- content: The full context of what to remember
|
|
92
|
+
- concepts: Keywords for semantic search (comma-separated)
|
|
93
|
+
|
|
94
|
+
Example:
|
|
95
|
+
\`\`\`typescript
|
|
96
|
+
observation({
|
|
97
|
+
type: "preference",
|
|
98
|
+
title: "Use bun instead of npm",
|
|
99
|
+
content: "This project uses bun as the package manager, not npm",
|
|
100
|
+
concepts: "bun, npm, package-manager"
|
|
101
|
+
});
|
|
102
|
+
\`\`\`
|
|
103
|
+
|
|
104
|
+
After saving, confirm: "✓ Saved to memory for future reference."
|
|
105
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
// ============================================================================
|
|
109
|
+
// Types
|
|
110
|
+
// ============================================================================
|
|
111
|
+
|
|
112
|
+
interface MemoryConfig {
|
|
113
|
+
enabled?: boolean;
|
|
114
|
+
patterns?: string[];
|
|
115
|
+
nudgeTemplate?: string;
|
|
116
|
+
watcherEnabled?: boolean;
|
|
117
|
+
toastEnabled?: boolean;
|
|
118
|
+
sessionSummaryEnabled?: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface PendingChange {
|
|
122
|
+
file: string;
|
|
123
|
+
type: "code" | "memory";
|
|
124
|
+
timestamp: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ============================================================================
|
|
128
|
+
// Helpers
|
|
129
|
+
// ============================================================================
|
|
130
|
+
|
|
131
|
+
async function loadConfig(): Promise<MemoryConfig> {
|
|
132
|
+
const configPaths = [
|
|
133
|
+
path.join(process.cwd(), ".opencode", "memory.json"),
|
|
134
|
+
path.join(process.cwd(), ".opencode", "memory.jsonc"),
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
for (const configPath of configPaths) {
|
|
138
|
+
try {
|
|
139
|
+
const content = await fsPromises.readFile(configPath, "utf-8");
|
|
140
|
+
const jsonContent = content.replace(/\/\/.*$|\/\*[\s\S]*?\*\//gm, "");
|
|
141
|
+
return JSON.parse(jsonContent);
|
|
142
|
+
} catch {
|
|
143
|
+
// Config doesn't exist
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function buildPattern(patterns: string[]): RegExp {
|
|
151
|
+
return new RegExp(`(${patterns.join("|")})`, "i");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function extractContext(message: string, matchIndex: number): string {
|
|
155
|
+
const before = message.substring(0, matchIndex);
|
|
156
|
+
const after = message.substring(matchIndex);
|
|
157
|
+
|
|
158
|
+
const sentenceStart = Math.max(
|
|
159
|
+
before.lastIndexOf(".") + 1,
|
|
160
|
+
before.lastIndexOf("!") + 1,
|
|
161
|
+
before.lastIndexOf("?") + 1,
|
|
162
|
+
0,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const afterPeriod = after.search(/[.!?]/);
|
|
166
|
+
const sentenceEnd =
|
|
167
|
+
afterPeriod === -1 ? message.length : matchIndex + afterPeriod + 1;
|
|
168
|
+
|
|
169
|
+
return message.substring(sentenceStart, sentenceEnd).trim();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isCodeFile(filePath: string): boolean {
|
|
173
|
+
return CODE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isMemoryFile(filePath: string): boolean {
|
|
177
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
178
|
+
return (
|
|
179
|
+
normalized.includes(MEMORY_DIR) &&
|
|
180
|
+
filePath.endsWith(".md") &&
|
|
181
|
+
!normalized.includes("vector_db")
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Dynamic import for indexer
|
|
186
|
+
async function getIndexer() {
|
|
187
|
+
const toolPath = path.join(process.cwd(), ".opencode/tool/memory-index.ts");
|
|
188
|
+
try {
|
|
189
|
+
const module = await import(toolPath);
|
|
190
|
+
return module.indexMemoryFiles as (
|
|
191
|
+
memoryDir: string,
|
|
192
|
+
beadsDir: string,
|
|
193
|
+
) => Promise<{ indexed: number; skipped: number; errors: string[] }>;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ============================================================================
|
|
200
|
+
// Plugin
|
|
201
|
+
// ============================================================================
|
|
202
|
+
|
|
203
|
+
export const MemoryPlugin: Plugin = async ({ client, $ }) => {
|
|
204
|
+
const config = await loadConfig();
|
|
205
|
+
|
|
206
|
+
if (config.enabled === false) {
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Logger helper
|
|
211
|
+
const log = async (message: string, level: "info" | "warn" = "info") => {
|
|
212
|
+
await client.app
|
|
213
|
+
.log({
|
|
214
|
+
body: {
|
|
215
|
+
service: "memory",
|
|
216
|
+
level,
|
|
217
|
+
message,
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
.catch(() => {});
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Toast notification helper using TUI (cross-platform)
|
|
224
|
+
const showToast = async (
|
|
225
|
+
title: string,
|
|
226
|
+
message: string,
|
|
227
|
+
variant: "info" | "success" | "warning" | "error" = "info",
|
|
228
|
+
) => {
|
|
229
|
+
if (config.toastEnabled === false) return;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
// Use OpenCode's TUI toast API (cross-platform, integrated)
|
|
233
|
+
await client.tui.showToast({
|
|
234
|
+
body: {
|
|
235
|
+
title: `Memory: ${title}`,
|
|
236
|
+
message,
|
|
237
|
+
variant,
|
|
238
|
+
duration: variant === "error" ? 8000 : 5000,
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Also log for debugging
|
|
243
|
+
await log(`[TOAST] ${title}: ${message}`, "info");
|
|
244
|
+
} catch {
|
|
245
|
+
// Toast failed, continue silently
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// -------------------------------------------------------------------------
|
|
250
|
+
// Part 1: Keyword Detection
|
|
251
|
+
// -------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
const patterns = config.patterns || DEFAULT_PATTERNS;
|
|
254
|
+
const triggerPattern = buildPattern(patterns);
|
|
255
|
+
const nudgeTemplate = config.nudgeTemplate || DEFAULT_NUDGE;
|
|
256
|
+
const processedMessages = new Set<string>();
|
|
257
|
+
|
|
258
|
+
// -------------------------------------------------------------------------
|
|
259
|
+
// Part 2: Index Rebuild
|
|
260
|
+
// -------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
let rebuildTimer: ReturnType<typeof setTimeout> | null = null;
|
|
263
|
+
let pendingRebuild = false;
|
|
264
|
+
|
|
265
|
+
const triggerRebuild = async () => {
|
|
266
|
+
if (pendingRebuild) return;
|
|
267
|
+
pendingRebuild = true;
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const memoryDir = path.join(process.cwd(), MEMORY_DIR);
|
|
271
|
+
const beadsDir = path.join(process.cwd(), BEADS_DIR);
|
|
272
|
+
|
|
273
|
+
const indexMemoryFiles = await getIndexer();
|
|
274
|
+
if (!indexMemoryFiles) {
|
|
275
|
+
await log("Failed to load indexer - skipping rebuild", "warn");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
await log("Rebuilding vector index...");
|
|
280
|
+
const result = await indexMemoryFiles(memoryDir, beadsDir);
|
|
281
|
+
|
|
282
|
+
await log(
|
|
283
|
+
`Vector index rebuilt: ${result.indexed} indexed, ${result.skipped} skipped`,
|
|
284
|
+
);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
287
|
+
await log(`Vector index rebuild failed: ${msg}`, "warn");
|
|
288
|
+
} finally {
|
|
289
|
+
pendingRebuild = false;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const scheduleRebuild = () => {
|
|
294
|
+
if (rebuildTimer) {
|
|
295
|
+
clearTimeout(rebuildTimer);
|
|
296
|
+
}
|
|
297
|
+
rebuildTimer = setTimeout(() => {
|
|
298
|
+
rebuildTimer = null;
|
|
299
|
+
triggerRebuild();
|
|
300
|
+
}, DEBOUNCE_MS);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// -------------------------------------------------------------------------
|
|
304
|
+
// Part 3: Code-Change Awareness with Toast Notifications
|
|
305
|
+
// -------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
let codeChangeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
308
|
+
const pendingCodeChanges = new Set<string>();
|
|
309
|
+
|
|
310
|
+
const flagStaleObservations = async (changedFiles: string[]) => {
|
|
311
|
+
const obsDir = path.join(process.cwd(), MEMORY_DIR, "observations");
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
const files = await fsPromises.readdir(obsDir);
|
|
315
|
+
const observations = files.filter((f) => f.endsWith(".md"));
|
|
316
|
+
|
|
317
|
+
let flaggedCount = 0;
|
|
318
|
+
const flaggedTitles: string[] = [];
|
|
319
|
+
|
|
320
|
+
for (const obsFile of observations) {
|
|
321
|
+
const obsPath = path.join(obsDir, obsFile);
|
|
322
|
+
const content = await fsPromises.readFile(obsPath, "utf-8");
|
|
323
|
+
|
|
324
|
+
// Skip already flagged
|
|
325
|
+
if (content.includes("needs_review: true")) continue;
|
|
326
|
+
|
|
327
|
+
// Check if observation references any changed files
|
|
328
|
+
const referencesChanged = changedFiles.some((changedFile) => {
|
|
329
|
+
const normalizedChanged = changedFile.replace(/\\/g, "/");
|
|
330
|
+
return (
|
|
331
|
+
content.includes(normalizedChanged) ||
|
|
332
|
+
content.includes(path.basename(changedFile))
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
if (referencesChanged) {
|
|
337
|
+
// Extract title from content
|
|
338
|
+
const titleMatch = content.match(/^# .* (.+)$/m);
|
|
339
|
+
const title = titleMatch ? titleMatch[1] : obsFile;
|
|
340
|
+
|
|
341
|
+
// Add needs_review flag
|
|
342
|
+
const updatedContent = content.replace(
|
|
343
|
+
/^(---\n)/,
|
|
344
|
+
`$1needs_review: true\nreview_reason: "Related code changed on ${new Date().toISOString().split("T")[0]}"\n`,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
if (updatedContent !== content) {
|
|
348
|
+
await fsPromises.writeFile(obsPath, updatedContent, "utf-8");
|
|
349
|
+
flaggedCount++;
|
|
350
|
+
flaggedTitles.push(title);
|
|
351
|
+
await log(`Flagged observation for review: ${obsFile}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Show toast notification if observations were flagged
|
|
357
|
+
if (flaggedCount > 0) {
|
|
358
|
+
const message =
|
|
359
|
+
flaggedCount === 1
|
|
360
|
+
? `"${flaggedTitles[0]}" may be outdated`
|
|
361
|
+
: `${flaggedCount} observations may be outdated`;
|
|
362
|
+
|
|
363
|
+
await showToast("Review Needed", message, "warning");
|
|
364
|
+
}
|
|
365
|
+
} catch (err) {
|
|
366
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
367
|
+
await log(`Failed to flag stale observations: ${msg}`, "warn");
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const processCodeChanges = async () => {
|
|
372
|
+
const changedFiles = Array.from(pendingCodeChanges);
|
|
373
|
+
pendingCodeChanges.clear();
|
|
374
|
+
|
|
375
|
+
if (changedFiles.length > 0) {
|
|
376
|
+
await log(`Processing ${changedFiles.length} code change(s)`);
|
|
377
|
+
await flagStaleObservations(changedFiles);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const handleCodeChange = (filePath: string) => {
|
|
382
|
+
pendingCodeChanges.add(filePath);
|
|
383
|
+
|
|
384
|
+
if (codeChangeTimer) {
|
|
385
|
+
clearTimeout(codeChangeTimer);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
codeChangeTimer = setTimeout(() => {
|
|
389
|
+
codeChangeTimer = null;
|
|
390
|
+
processCodeChanges();
|
|
391
|
+
}, CODE_CHANGE_DEBOUNCE_MS);
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
// -------------------------------------------------------------------------
|
|
395
|
+
// Part 4: Session Idle Summary
|
|
396
|
+
// -------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
const SESSION_SUMMARY_NUDGE = `
|
|
399
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
400
|
+
[SESSION ENDING - MEMORY CHECK]
|
|
401
|
+
|
|
402
|
+
Before this session ends, consider:
|
|
403
|
+
|
|
404
|
+
1. **Any key learnings** from this session worth remembering?
|
|
405
|
+
2. **Decisions made** that should be documented?
|
|
406
|
+
3. **Patterns discovered** that could help future sessions?
|
|
407
|
+
|
|
408
|
+
Use \`observation\` tool to save important insights, or skip if nothing notable.
|
|
409
|
+
|
|
410
|
+
Quick check: \`memory-search\` to see if related knowledge already exists.
|
|
411
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
412
|
+
`;
|
|
413
|
+
|
|
414
|
+
// -------------------------------------------------------------------------
|
|
415
|
+
// Return Event Hooks (Official OpenCode Event System)
|
|
416
|
+
// -------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
// Hook: Detect memory trigger keywords in user messages
|
|
420
|
+
"message.updated": async ({ event }) => {
|
|
421
|
+
// Only process user messages
|
|
422
|
+
const message = event.properties?.message;
|
|
423
|
+
if (!message || message.role !== "user") return;
|
|
424
|
+
|
|
425
|
+
const parts = message.parts;
|
|
426
|
+
if (!parts) return;
|
|
427
|
+
|
|
428
|
+
// Get text content
|
|
429
|
+
const textParts = parts.filter(
|
|
430
|
+
(p: { type: string; text?: string }): p is {
|
|
431
|
+
type: "text";
|
|
432
|
+
text: string;
|
|
433
|
+
} => p.type === "text",
|
|
434
|
+
);
|
|
435
|
+
if (textParts.length === 0) return;
|
|
436
|
+
|
|
437
|
+
const fullText = textParts.map((p: { text: string }) => p.text).join(" ");
|
|
438
|
+
|
|
439
|
+
// Avoid processing same message twice
|
|
440
|
+
const messageHash = `${message.role}:${fullText.substring(0, 100)}`;
|
|
441
|
+
if (processedMessages.has(messageHash)) return;
|
|
442
|
+
processedMessages.add(messageHash);
|
|
443
|
+
|
|
444
|
+
// Limit cache size
|
|
445
|
+
if (processedMessages.size > 100) {
|
|
446
|
+
const first = processedMessages.values().next().value;
|
|
447
|
+
if (first) processedMessages.delete(first);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Check for trigger keywords
|
|
451
|
+
const match = fullText.match(triggerPattern);
|
|
452
|
+
if (!match) return;
|
|
453
|
+
|
|
454
|
+
const context = extractContext(fullText, match.index || 0);
|
|
455
|
+
await log(
|
|
456
|
+
`Detected memory trigger: "${match[0]}" in: "${context.substring(0, 50)}..."`,
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
// Note: The nudge would be injected via chat.message hook if available
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
// Hook: file.edited - OpenCode edited a file
|
|
463
|
+
"file.edited": async ({ event }) => {
|
|
464
|
+
const filePath = event.properties?.file || event.properties?.path;
|
|
465
|
+
if (!filePath) return;
|
|
466
|
+
|
|
467
|
+
const absolutePath =
|
|
468
|
+
typeof filePath === "string" && path.isAbsolute(filePath)
|
|
469
|
+
? filePath
|
|
470
|
+
: path.join(process.cwd(), filePath);
|
|
471
|
+
|
|
472
|
+
// Check if it's a code file in src/
|
|
473
|
+
if (isCodeFile(absolutePath) && absolutePath.includes(`/${SRC_DIR}/`)) {
|
|
474
|
+
await log(`Code edited by OpenCode: ${filePath}`);
|
|
475
|
+
handleCodeChange(absolutePath);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Check if it's a memory file
|
|
479
|
+
if (isMemoryFile(absolutePath)) {
|
|
480
|
+
await log(`Memory file edited: ${filePath}`);
|
|
481
|
+
scheduleRebuild();
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
// Hook: file.watcher.updated - External file changes
|
|
486
|
+
"file.watcher.updated": async ({ event }) => {
|
|
487
|
+
const filePath = event.properties?.file || event.properties?.path;
|
|
488
|
+
if (!filePath) return;
|
|
489
|
+
|
|
490
|
+
const absolutePath =
|
|
491
|
+
typeof filePath === "string" && path.isAbsolute(filePath)
|
|
492
|
+
? filePath
|
|
493
|
+
: path.join(process.cwd(), filePath);
|
|
494
|
+
|
|
495
|
+
// Ignore node_modules and vector_db
|
|
496
|
+
if (
|
|
497
|
+
absolutePath.includes("node_modules") ||
|
|
498
|
+
absolutePath.includes("vector_db")
|
|
499
|
+
) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Code file changes
|
|
504
|
+
if (isCodeFile(absolutePath) && absolutePath.includes(`/${SRC_DIR}/`)) {
|
|
505
|
+
await log(`External code change: ${filePath}`);
|
|
506
|
+
handleCodeChange(absolutePath);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Memory file changes
|
|
510
|
+
if (isMemoryFile(absolutePath)) {
|
|
511
|
+
await log(`External memory change: ${filePath}`);
|
|
512
|
+
scheduleRebuild();
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
// Hook: session.idle - Session completed
|
|
517
|
+
"session.idle": async () => {
|
|
518
|
+
if (config.sessionSummaryEnabled === false) return;
|
|
519
|
+
|
|
520
|
+
await log("Session idle - prompting memory summary");
|
|
521
|
+
await showToast(
|
|
522
|
+
"Session Ending",
|
|
523
|
+
"Consider saving key learnings before ending",
|
|
524
|
+
"info",
|
|
525
|
+
);
|
|
526
|
+
},
|
|
527
|
+
|
|
528
|
+
// Hook: tool.execute.after - Notify when observation is saved
|
|
529
|
+
"tool.execute.after": async (input, output) => {
|
|
530
|
+
if (input.tool === "observation") {
|
|
531
|
+
await showToast("Memory Saved", "Observation added to memory", "info");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// LSP Nudge Injection: If tool output contains LSP hints, wrap them in a prompt
|
|
535
|
+
if (
|
|
536
|
+
output.output.includes("lsp_lsp_goto_definition") ||
|
|
537
|
+
output.output.includes("lsp_lsp_document_symbols") ||
|
|
538
|
+
output.output.includes("lsp_lsp_find_references")
|
|
539
|
+
) {
|
|
540
|
+
// Avoid double injection
|
|
541
|
+
if (output.output.includes("[LSP NAVIGATION AVAILABLE]")) return;
|
|
542
|
+
|
|
543
|
+
const LSP_PROMPT = `
|
|
544
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
545
|
+
[LSP NAVIGATION AVAILABLE]
|
|
546
|
+
|
|
547
|
+
The tool output contains actionable LSP navigation links (🔍).
|
|
548
|
+
You can use these to immediately jump to the relevant code context.
|
|
549
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
550
|
+
`;
|
|
551
|
+
output.output += LSP_PROMPT;
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
// Hook: session.error - Warn about potential lost context
|
|
556
|
+
"session.error": async () => {
|
|
557
|
+
await showToast(
|
|
558
|
+
"Session Error",
|
|
559
|
+
"Consider saving important learnings with observation tool",
|
|
560
|
+
"warning",
|
|
561
|
+
);
|
|
562
|
+
},
|
|
563
|
+
|
|
564
|
+
// Hook: Inject nudge for memory triggers (chat.message if available)
|
|
565
|
+
"chat.message": async (input, output) => {
|
|
566
|
+
const { sessionID, messageID } = input;
|
|
567
|
+
const { message, parts } = output;
|
|
568
|
+
|
|
569
|
+
// Only process user messages
|
|
570
|
+
if (message.role !== "user") return;
|
|
571
|
+
|
|
572
|
+
// Get text content
|
|
573
|
+
const textParts = parts.filter(
|
|
574
|
+
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
|
|
575
|
+
);
|
|
576
|
+
if (textParts.length === 0) return;
|
|
577
|
+
|
|
578
|
+
const fullText = textParts.map((p) => p.text).join(" ");
|
|
579
|
+
|
|
580
|
+
// Avoid processing same message twice (use different cache for this hook)
|
|
581
|
+
const messageKey = `chat:${fullText.substring(0, 100)}`;
|
|
582
|
+
if (processedMessages.has(messageKey)) return;
|
|
583
|
+
processedMessages.add(messageKey);
|
|
584
|
+
|
|
585
|
+
// Check for trigger keywords
|
|
586
|
+
const match = fullText.match(triggerPattern);
|
|
587
|
+
if (!match) return;
|
|
588
|
+
|
|
589
|
+
const context = extractContext(fullText, match.index || 0);
|
|
590
|
+
await log(
|
|
591
|
+
`Detected memory trigger: "${match[0]}" in: "${context.substring(0, 50)}..."`,
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
// Inject the nudge as a synthetic text part
|
|
595
|
+
const partId = `memory-nudge-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
596
|
+
|
|
597
|
+
parts.push({
|
|
598
|
+
id: partId,
|
|
599
|
+
sessionID,
|
|
600
|
+
messageID: messageID || "",
|
|
601
|
+
type: "text",
|
|
602
|
+
text: nudgeTemplate,
|
|
603
|
+
synthetic: true,
|
|
604
|
+
} as import("@opencode-ai/sdk").Part);
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
// Default export for OpenCode plugin loader
|
|
610
|
+
export default MemoryPlugin;
|