opencodekit 0.16.10 → 0.16.13

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