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.
Files changed (30) hide show
  1. package/README.md +2 -2
  2. package/dist/index.js +16 -4
  3. package/dist/template/.opencode/AGENTS.md +13 -4
  4. package/dist/template/.opencode/README.md +100 -4
  5. package/dist/template/.opencode/command/brainstorm.md +25 -2
  6. package/dist/template/.opencode/command/finish.md +21 -4
  7. package/dist/template/.opencode/command/handoff.md +17 -0
  8. package/dist/template/.opencode/command/implement.md +38 -0
  9. package/dist/template/.opencode/command/plan.md +32 -0
  10. package/dist/template/.opencode/command/research.md +61 -5
  11. package/dist/template/.opencode/command/resume.md +31 -0
  12. package/dist/template/.opencode/command/start.md +31 -0
  13. package/dist/template/.opencode/command/triage.md +16 -1
  14. package/dist/template/.opencode/memory/observations/.gitkeep +0 -0
  15. package/dist/template/.opencode/memory/project/conventions.md +31 -0
  16. package/dist/template/.opencode/memory/vector_db/memories.lance/_transactions/0-8d00d272-cb80-463b-9774-7120a1c994e7.txn +0 -0
  17. package/dist/template/.opencode/memory/vector_db/memories.lance/_transactions/1-a3bea825-dad3-47dd-a6d6-ff41b76ff7b0.txn +0 -0
  18. package/dist/template/.opencode/memory/vector_db/memories.lance/_versions/1.manifest +0 -0
  19. package/dist/template/.opencode/memory/vector_db/memories.lance/_versions/2.manifest +0 -0
  20. package/dist/template/.opencode/memory/vector_db/memories.lance/data/001010101000000101110001f998d04b63936ff83f9a34152d.lance +0 -0
  21. package/dist/template/.opencode/memory/vector_db/memories.lance/data/010000101010000000010010701b3840d38c2b5f275da99978.lance +0 -0
  22. package/dist/template/.opencode/opencode.json +587 -511
  23. package/dist/template/.opencode/package.json +3 -1
  24. package/dist/template/.opencode/plugin/memory.ts +610 -0
  25. package/dist/template/.opencode/tool/memory-embed.ts +183 -0
  26. package/dist/template/.opencode/tool/memory-index.ts +769 -0
  27. package/dist/template/.opencode/tool/memory-search.ts +358 -66
  28. package/dist/template/.opencode/tool/observation.ts +301 -12
  29. package/dist/template/.opencode/tool/repo-map.ts +451 -0
  30. package/package.json +16 -4
@@ -1,10 +1,82 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { tool } from "@opencode-ai/plugin";
4
+ import { searchVectorStore } from "./memory-index";
4
5
 
5
6
  interface SearchResult {
6
7
  file: string;
7
8
  matches: { line: number; content: string }[];
9
+ score?: number;
10
+ }
11
+
12
+ interface SemanticResult {
13
+ file: string;
14
+ title: string;
15
+ preview: string;
16
+ type: string;
17
+ score?: number;
18
+ confidence?: string;
19
+ age_days?: number;
20
+ }
21
+
22
+ // Confidence decay factor based on age (Graphiti-inspired)
23
+ // Older observations with lower confidence rank lower
24
+ function applyConfidenceDecay(
25
+ results: SemanticResult[],
26
+ contents: Map<string, string>,
27
+ ): SemanticResult[] {
28
+ const now = Date.now();
29
+
30
+ return results.map((result) => {
31
+ const content = contents.get(result.file) || "";
32
+
33
+ // Extract metadata from YAML frontmatter
34
+ const createdMatch = content.match(/created:\s*(.+)/);
35
+ const confidenceMatch = content.match(/confidence:\s*(\w+)/);
36
+ const validUntilMatch = content.match(/valid_until:\s*(.+)/);
37
+ const supersededByMatch = content.match(/superseded_by:\s*(.+)/);
38
+
39
+ // Check if superseded (should rank very low)
40
+ if (supersededByMatch && supersededByMatch[1] !== "null") {
41
+ return { ...result, score: (result.score || 1) * 0.1 };
42
+ }
43
+
44
+ // Check if expired
45
+ if (validUntilMatch && validUntilMatch[1] !== "null") {
46
+ const validUntil = new Date(validUntilMatch[1]).getTime();
47
+ if (validUntil < now) {
48
+ return { ...result, score: (result.score || 1) * 0.2 };
49
+ }
50
+ }
51
+
52
+ // Calculate age in days
53
+ let ageDays = 0;
54
+ if (createdMatch) {
55
+ const created = new Date(createdMatch[1]).getTime();
56
+ ageDays = Math.floor((now - created) / (1000 * 60 * 60 * 24));
57
+ }
58
+
59
+ // Confidence multiplier
60
+ const confidenceMultiplier: Record<string, number> = {
61
+ high: 1.0,
62
+ medium: 0.8,
63
+ low: 0.6,
64
+ };
65
+ const confidence = confidenceMatch?.[1] || "high";
66
+ const confMult = confidenceMultiplier[confidence] || 1.0;
67
+
68
+ // Age decay: lose 5% per 30 days, minimum 50%
69
+ const ageDecay = Math.max(0.5, 1 - (ageDays / 30) * 0.05);
70
+
71
+ const finalScore = (result.score || 1) * confMult * ageDecay;
72
+
73
+ return {
74
+ ...result,
75
+ score: finalScore,
76
+ confidence,
77
+ age_days: ageDays,
78
+ };
79
+ });
8
80
  }
9
81
 
10
82
  async function searchDirectory(
@@ -20,8 +92,8 @@ async function searchDirectory(
20
92
  const fullPath = path.join(dir, entry.name);
21
93
 
22
94
  if (entry.isDirectory()) {
23
- // Skip hidden directories except _templates
24
- if (entry.name.startsWith(".") && entry.name !== "_templates") {
95
+ // Skip hidden directories and vector_db
96
+ if (entry.name.startsWith(".") || entry.name === "vector_db") {
25
97
  continue;
26
98
  }
27
99
  await searchDirectory(fullPath, pattern, results, baseDir);
@@ -50,14 +122,270 @@ async function searchDirectory(
50
122
  }
51
123
  }
52
124
 
125
+ async function keywordSearch(
126
+ query: string,
127
+ type: string | undefined,
128
+ limit: number,
129
+ ): Promise<SearchResult[]> {
130
+ const memoryDir = path.join(process.cwd(), ".opencode/memory");
131
+ const beadsDir = path.join(process.cwd(), ".beads/artifacts");
132
+ const globalMemoryDir = path.join(
133
+ process.env.HOME || "",
134
+ ".config/opencode/memory",
135
+ );
136
+
137
+ // Create case-insensitive regex from query
138
+ let pattern: RegExp;
139
+ try {
140
+ pattern = new RegExp(query, "i");
141
+ } catch {
142
+ // Escape special chars if not valid regex
143
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
144
+ pattern = new RegExp(escaped, "i");
145
+ }
146
+
147
+ const results: SearchResult[] = [];
148
+
149
+ // Handle type filtering
150
+ if (type === "beads") {
151
+ await searchDirectory(beadsDir, pattern, results, beadsDir);
152
+ } else if (type && type !== "all") {
153
+ const typeMap: Record<string, string> = {
154
+ handoffs: "handoffs",
155
+ research: "research",
156
+ templates: "_templates",
157
+ observations: "observations",
158
+ };
159
+ const subdir = typeMap[type];
160
+ if (subdir) {
161
+ const searchDir = path.join(memoryDir, subdir);
162
+ await searchDirectory(searchDir, pattern, results, memoryDir);
163
+ }
164
+ } else {
165
+ // Search all: memory + beads
166
+ await searchDirectory(memoryDir, pattern, results, memoryDir);
167
+ await searchDirectory(beadsDir, pattern, results, beadsDir);
168
+ await searchDirectory(globalMemoryDir, pattern, results, globalMemoryDir);
169
+ }
170
+
171
+ return results;
172
+ }
173
+
174
+ async function semanticSearch(
175
+ query: string,
176
+ type: string | undefined,
177
+ limit: number,
178
+ ): Promise<SemanticResult[]> {
179
+ const typeMap: Record<string, string> = {
180
+ handoffs: "handoff",
181
+ observations: "observation",
182
+ beads: "bead",
183
+ project: "project",
184
+ templates: "template",
185
+ };
186
+
187
+ const fileType = type && type !== "all" ? typeMap[type] : undefined;
188
+ const docs = await searchVectorStore(query, limit * 2, fileType); // Fetch extra for decay filtering
189
+
190
+ // Build content map for decay calculation
191
+ const contents = new Map<string, string>();
192
+ for (const doc of docs) {
193
+ try {
194
+ const content = await fs.readFile(doc.file_path, "utf-8");
195
+ contents.set(doc.file_path, content);
196
+ } catch {
197
+ // File not found, skip
198
+ }
199
+ }
200
+
201
+ const results: SemanticResult[] = docs.map((doc) => ({
202
+ file: doc.file_path,
203
+ title: doc.title,
204
+ preview: doc.content_preview,
205
+ type: doc.file_type,
206
+ score: 1.0,
207
+ }));
208
+
209
+ // Apply confidence decay and re-sort
210
+ const decayedResults = applyConfidenceDecay(results, contents);
211
+ decayedResults.sort((a, b) => (b.score || 0) - (a.score || 0));
212
+
213
+ return decayedResults.slice(0, limit);
214
+ }
215
+
216
+ function formatKeywordResults(
217
+ query: string,
218
+ results: SearchResult[],
219
+ limit: number,
220
+ ): string {
221
+ if (results.length === 0) {
222
+ return `No keyword matches found for "${query}".\n\nTip: Try 'mode: semantic' for conceptual search, or run 'vector-store rebuild' first.`;
223
+ }
224
+
225
+ let output = `# Keyword Search: "${query}"\n\n`;
226
+ output += `Found ${results.length} file(s) with matches.\n\n`;
227
+
228
+ for (const result of results) {
229
+ output += `## ${result.file}\n\n`;
230
+ const matchesToShow = result.matches.slice(0, limit);
231
+ for (const match of matchesToShow) {
232
+ output += `- **Line ${match.line}:** ${match.content}\n`;
233
+ }
234
+ if (result.matches.length > limit) {
235
+ output += `- ... and ${result.matches.length - limit} more matches\n`;
236
+ }
237
+ // Add LSP navigation hint for code files
238
+ if (
239
+ result.file.endsWith(".ts") ||
240
+ result.file.endsWith(".tsx") ||
241
+ result.file.endsWith(".js") ||
242
+ result.file.endsWith(".jsx") ||
243
+ result.file.endsWith(".py") ||
244
+ result.file.endsWith(".go") ||
245
+ result.file.endsWith(".rs")
246
+ ) {
247
+ const lineNum = result.matches[0]?.line;
248
+ if (lineNum) {
249
+ output += `\n🔍 **LSP Nudge:**\n`;
250
+ output += ` \`lsp_lsp_goto_definition({ filePath: "${result.file}", line: ${lineNum}, character: 1 })\`\n`;
251
+ }
252
+ }
253
+ output += "\n";
254
+ }
255
+
256
+ return output;
257
+ }
258
+
259
+ function formatSemanticResults(
260
+ query: string,
261
+ results: SemanticResult[],
262
+ ): string {
263
+ if (results.length === 0) {
264
+ return `No semantic matches found for "${query}".\n\nTip: Run 'vector-store rebuild' to index memory files first.`;
265
+ }
266
+
267
+ let output = `# Semantic Search: "${query}"\n\n`;
268
+ output += `Found ${results.length} similar document(s).\n\n`;
269
+
270
+ const confidenceIcons: Record<string, string> = {
271
+ high: "🟢",
272
+ medium: "🟡",
273
+ low: "🔴",
274
+ };
275
+
276
+ for (const result of results) {
277
+ output += `## ${result.title}\n\n`;
278
+ output += `**File:** \`${result.file}\`\n`;
279
+ output += `**Type:** ${result.type}`;
280
+ if (result.confidence) {
281
+ const icon = confidenceIcons[result.confidence] || "";
282
+ output += ` | **Confidence:** ${icon} ${result.confidence}`;
283
+ }
284
+ if (result.age_days !== undefined && result.age_days > 0) {
285
+ output += ` | **Age:** ${result.age_days}d`;
286
+ }
287
+ output += "\n\n";
288
+ output += `${result.preview}...\n\n`;
289
+
290
+ // Add LSP navigation for code files
291
+ if (
292
+ result.file.endsWith(".ts") ||
293
+ result.file.endsWith(".tsx") ||
294
+ result.file.endsWith(".js") ||
295
+ result.file.endsWith(".jsx") ||
296
+ result.file.endsWith(".py") ||
297
+ result.file.endsWith(".go") ||
298
+ result.file.endsWith(".rs")
299
+ ) {
300
+ output += `\n🔍 **LSP Nudge:**\n`;
301
+ output += ` Get symbols: \`lsp_lsp_document_symbols({ filePath: "${result.file}" })\`\n`;
302
+ output += ` Find references: \`lsp_lsp_find_references({ filePath: "${result.file}", line: 1, character: 1 })\`\n`;
303
+ }
304
+
305
+ output += "---\n\n";
306
+ }
307
+
308
+ return output;
309
+ }
310
+
311
+ function formatHybridResults(
312
+ query: string,
313
+ keywordResults: SearchResult[],
314
+ semanticResults: SemanticResult[],
315
+ limit: number,
316
+ ): string {
317
+ let output = `# Hybrid Search: "${query}"\n\n`;
318
+
319
+ // Semantic results first (conceptual matches)
320
+ output += `## Semantic Matches (${semanticResults.length})\n\n`;
321
+ if (semanticResults.length === 0) {
322
+ output += `_No semantic matches. Run 'vector-store rebuild' to enable._\n\n`;
323
+ } else {
324
+ for (const result of semanticResults.slice(0, limit)) {
325
+ output += `- **${result.title}** (\`${result.file}\`)\n`;
326
+ output += ` ${result.preview.substring(0, 100)}...\n`;
327
+ // Add LSP hint for code files
328
+ if (
329
+ result.file.endsWith(".ts") ||
330
+ result.file.endsWith(".tsx") ||
331
+ result.file.endsWith(".js") ||
332
+ result.file.endsWith(".jsx") ||
333
+ result.file.endsWith(".py") ||
334
+ result.file.endsWith(".go") ||
335
+ result.file.endsWith(".rs")
336
+ ) {
337
+ output += ` 🔍 **LSP Nudge:** \`lsp_lsp_document_symbols({ filePath: "${result.file}" })\`\n`;
338
+ }
339
+ }
340
+ output += "\n";
341
+ }
342
+
343
+ // Keyword results (exact matches)
344
+ output += `## Keyword Matches (${keywordResults.length})\n\n`;
345
+ if (keywordResults.length === 0) {
346
+ output += "_No exact keyword matches._\n\n";
347
+ } else {
348
+ for (const result of keywordResults.slice(0, limit)) {
349
+ output += `- **${result.file}**\n`;
350
+ for (const match of result.matches.slice(0, 2)) {
351
+ output += ` - Line ${match.line}: ${match.content.substring(0, 80)}...\n`;
352
+ }
353
+ // Add LSP hint for code files
354
+ if (
355
+ result.file.endsWith(".ts") ||
356
+ result.file.endsWith(".tsx") ||
357
+ result.file.endsWith(".js") ||
358
+ result.file.endsWith(".jsx") ||
359
+ result.file.endsWith(".py") ||
360
+ result.file.endsWith(".go") ||
361
+ result.file.endsWith(".rs")
362
+ ) {
363
+ const lineNum = result.matches[0]?.line;
364
+ if (lineNum) {
365
+ output += ` 🔍 **LSP Nudge:** \`lsp_lsp_goto_definition({ filePath: "${result.file}", line: ${lineNum}, character: 1 })\`\n`;
366
+ }
367
+ }
368
+ }
369
+ output += "\n";
370
+ }
371
+
372
+ return output;
373
+ }
374
+
53
375
  export default tool({
54
376
  description:
55
- "Search across all memory files using keywords or patterns. Returns matching files with line numbers and context. Useful for finding past decisions, research, or handoffs.",
377
+ "Search across all memory files using keywords, semantic similarity, or hybrid mode. Returns matching files with context. Useful for finding past decisions, research, or handoffs.",
56
378
  args: {
57
379
  query: tool.schema
58
380
  .string()
59
381
  .describe(
60
- "Search query: keywords or regex pattern (e.g., 'authentication', 'bugfix.*session')",
382
+ "Search query: keywords, regex pattern, or natural language for semantic search",
383
+ ),
384
+ mode: tool.schema
385
+ .enum(["keyword", "semantic", "hybrid"])
386
+ .optional()
387
+ .describe(
388
+ "Search mode: 'keyword' (default, regex matching), 'semantic' (vector similarity), 'hybrid' (both)",
61
389
  ),
62
390
  type: tool.schema
63
391
  .string()
@@ -65,76 +393,40 @@ export default tool({
65
393
  .describe(
66
394
  "Filter by type: 'all' (default), 'handoffs', 'research', 'templates', 'observations', 'beads'",
67
395
  ),
68
- limit: tool.schema
69
- .number()
70
- .optional()
71
- .describe("Max results per file (default: 5)"),
396
+ limit: tool.schema.number().optional().describe("Max results (default: 5)"),
72
397
  },
73
- execute: async (args: { query: string; type?: string; limit?: number }) => {
74
- const memoryDir = path.join(process.cwd(), ".opencode/memory");
75
- const beadsDir = path.join(process.cwd(), ".beads/artifacts");
76
- const globalMemoryDir = path.join(
77
- process.env.HOME || "",
78
- ".config/opencode/memory",
79
- );
80
-
398
+ execute: async (args: {
399
+ query: string;
400
+ mode?: "keyword" | "semantic" | "hybrid";
401
+ type?: string;
402
+ limit?: number;
403
+ }) => {
404
+ const mode = args.mode || "keyword";
81
405
  const limit = args.limit || 5;
82
406
 
83
- // Create case-insensitive regex from query
84
- let pattern: RegExp;
85
- try {
86
- pattern = new RegExp(args.query, "i");
87
- } catch {
88
- // Escape special chars if not valid regex
89
- const escaped = args.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
90
- pattern = new RegExp(escaped, "i");
91
- }
92
-
93
- const results: SearchResult[] = [];
94
-
95
- // Handle type filtering
96
- if (args.type === "beads") {
97
- // Search only bead artifacts
98
- await searchDirectory(beadsDir, pattern, results, beadsDir);
99
- } else if (args.type && args.type !== "all") {
100
- const typeMap: Record<string, string> = {
101
- handoffs: "handoffs",
102
- research: "research",
103
- templates: "_templates",
104
- observations: "observations",
105
- };
106
- const subdir = typeMap[args.type];
107
- if (subdir) {
108
- const searchDir = path.join(memoryDir, subdir);
109
- await searchDirectory(searchDir, pattern, results, memoryDir);
110
- }
111
- } else {
112
- // Search all: memory + beads
113
- await searchDirectory(memoryDir, pattern, results, memoryDir);
114
- await searchDirectory(beadsDir, pattern, results, beadsDir);
115
- await searchDirectory(globalMemoryDir, pattern, results, globalMemoryDir);
407
+ if (mode === "keyword") {
408
+ const results = await keywordSearch(args.query, args.type, limit);
409
+ return formatKeywordResults(args.query, results, limit);
116
410
  }
117
411
 
118
- if (results.length === 0) {
119
- return `No matches found for "${args.query}" in memory files.\n\nTip: Try broader search terms or check available types: handoffs, research, templates, observations, beads`;
412
+ if (mode === "semantic") {
413
+ const results = await semanticSearch(args.query, args.type, limit);
414
+ return formatSemanticResults(args.query, results);
120
415
  }
121
416
 
122
- // Build output
123
- let output = `# Memory Search: "${args.query}"\n\n`;
124
- output += `Found ${results.length} file(s) with matches.\n\n`;
125
-
126
- for (const result of results) {
127
- output += `## ${result.file}\n\n`;
128
- const matchesToShow = result.matches.slice(0, limit);
129
- for (const match of matchesToShow) {
130
- output += `- **Line ${match.line}:** ${match.content}\n`;
131
- }
132
- if (result.matches.length > limit) {
133
- output += `- ... and ${result.matches.length - limit} more matches\n`;
134
- }
135
- output += "\n";
417
+ if (mode === "hybrid") {
418
+ const [keywordResults, semanticResults] = await Promise.all([
419
+ keywordSearch(args.query, args.type, limit),
420
+ semanticSearch(args.query, args.type, limit),
421
+ ]);
422
+ return formatHybridResults(
423
+ args.query,
424
+ keywordResults,
425
+ semanticResults,
426
+ limit,
427
+ );
136
428
  }
137
429
 
138
- return output;
430
+ return "Unknown search mode";
139
431
  },
140
432
  });