opencodekit 0.13.2 → 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.
Files changed (29) hide show
  1. package/dist/index.js +16 -4
  2. package/dist/template/.opencode/AGENTS.md +13 -4
  3. package/dist/template/.opencode/README.md +98 -2
  4. package/dist/template/.opencode/command/brainstorm.md +25 -2
  5. package/dist/template/.opencode/command/finish.md +21 -4
  6. package/dist/template/.opencode/command/handoff.md +17 -0
  7. package/dist/template/.opencode/command/implement.md +38 -0
  8. package/dist/template/.opencode/command/plan.md +32 -0
  9. package/dist/template/.opencode/command/research.md +61 -5
  10. package/dist/template/.opencode/command/resume.md +31 -0
  11. package/dist/template/.opencode/command/start.md +31 -0
  12. package/dist/template/.opencode/command/triage.md +16 -1
  13. package/dist/template/.opencode/memory/observations/.gitkeep +0 -0
  14. package/dist/template/.opencode/memory/project/conventions.md +31 -0
  15. package/dist/template/.opencode/memory/vector_db/memories.lance/_transactions/0-8d00d272-cb80-463b-9774-7120a1c994e7.txn +0 -0
  16. package/dist/template/.opencode/memory/vector_db/memories.lance/_transactions/1-a3bea825-dad3-47dd-a6d6-ff41b76ff7b0.txn +0 -0
  17. package/dist/template/.opencode/memory/vector_db/memories.lance/_versions/1.manifest +0 -0
  18. package/dist/template/.opencode/memory/vector_db/memories.lance/_versions/2.manifest +0 -0
  19. package/dist/template/.opencode/memory/vector_db/memories.lance/data/001010101000000101110001f998d04b63936ff83f9a34152d.lance +0 -0
  20. package/dist/template/.opencode/memory/vector_db/memories.lance/data/010000101010000000010010701b3840d38c2b5f275da99978.lance +0 -0
  21. package/dist/template/.opencode/opencode.json +587 -523
  22. package/dist/template/.opencode/package.json +3 -1
  23. package/dist/template/.opencode/plugin/memory.ts +610 -0
  24. package/dist/template/.opencode/tool/memory-embed.ts +183 -0
  25. package/dist/template/.opencode/tool/memory-index.ts +769 -0
  26. package/dist/template/.opencode/tool/memory-search.ts +358 -66
  27. package/dist/template/.opencode/tool/observation.ts +301 -12
  28. package/dist/template/.opencode/tool/repo-map.ts +451 -0
  29. package/package.json +16 -4
@@ -11,7 +11,9 @@
11
11
  "author": "",
12
12
  "license": "ISC",
13
13
  "dependencies": {
14
- "@opencode-ai/plugin": "1.1.6"
14
+ "@lancedb/lancedb": "^0.23.0",
15
+ "@opencode-ai/plugin": "1.1.6",
16
+ "openai": "^6.15.0"
15
17
  },
16
18
  "devDependencies": {
17
19
  "@types/node": "^25.0.3",
@@ -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;