openrecall 0.1.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/src/tools.ts ADDED
@@ -0,0 +1,658 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import {
3
+ storeMemory,
4
+ searchMemories,
5
+ updateMemory,
6
+ deleteMemory,
7
+ listMemories,
8
+ getStats,
9
+ refreshMemory,
10
+ getMemory,
11
+ addTags,
12
+ removeTags,
13
+ getTagsForMemory,
14
+ listAllTags,
15
+ searchByTag,
16
+ addLink,
17
+ removeLink,
18
+ getLinksForMemory,
19
+ } from "./memory"
20
+ import { getConfig } from "./config"
21
+ import { isDbAvailable } from "./db"
22
+ import {
23
+ runMaintenance,
24
+ purgeOldMemories,
25
+ getDbSize,
26
+ vacuumDb,
27
+ } from "./maintenance"
28
+
29
+ function safeExecute<T>(fn: () => T, fallback: string): T | string {
30
+ if (!isDbAvailable()) {
31
+ return "[OpenRecall] Memory database is unavailable. Plugin may not have initialized correctly."
32
+ }
33
+ try {
34
+ return fn()
35
+ } catch (e: any) {
36
+ console.error("[OpenRecall] Tool error:", e)
37
+ return `${fallback}: ${e.message || e}`
38
+ }
39
+ }
40
+
41
+ export function createTools(projectId: string) {
42
+ const config = getConfig()
43
+ return {
44
+ memory_store: tool({
45
+ description:
46
+ "Store an important finding, decision, pattern, or learning in persistent cross-session memory. " +
47
+ "Use this to save things worth remembering: architectural decisions, debugging insights, " +
48
+ "user preferences, code patterns, project conventions, or important discoveries. " +
49
+ "These memories persist across sessions and can be searched later.",
50
+ args: {
51
+ content: tool.schema
52
+ .string()
53
+ .describe(
54
+ "The memory content to store. Be specific and include context. " +
55
+ "Good: 'The auth module uses JWT with RS256 signing, keys stored in /etc/app/keys'. " +
56
+ "Bad: 'auth uses JWT'.",
57
+ ),
58
+ category: tool.schema
59
+ .enum([
60
+ "decision",
61
+ "pattern",
62
+ "debugging",
63
+ "preference",
64
+ "convention",
65
+ "discovery",
66
+ "general",
67
+ ])
68
+ .optional()
69
+ .describe(
70
+ "Category of this memory. " +
71
+ "decision: architectural/design decisions. " +
72
+ "pattern: code patterns and idioms. " +
73
+ "debugging: debugging insights and solutions. " +
74
+ "preference: user preferences and workflow. " +
75
+ "convention: project conventions and standards. " +
76
+ "discovery: important findings. " +
77
+ "general: anything else.",
78
+ ),
79
+ source: tool.schema
80
+ .string()
81
+ .optional()
82
+ .describe(
83
+ "Where this memory came from, e.g. a file path or context description",
84
+ ),
85
+ tags: tool.schema
86
+ .string()
87
+ .optional()
88
+ .describe(
89
+ "Comma-separated tags for this memory, e.g. 'auth,jwt,security'. " +
90
+ "Tags help organize and filter memories across categories.",
91
+ ),
92
+ global: tool.schema
93
+ .boolean()
94
+ .optional()
95
+ .describe(
96
+ "If true, this memory applies to ALL projects (not just the current one). " +
97
+ "Use for user preferences, workflow conventions, or cross-project knowledge.",
98
+ ),
99
+ force: tool.schema
100
+ .boolean()
101
+ .optional()
102
+ .describe(
103
+ "If true, skip deduplication check and always create a new memory. " +
104
+ "By default, duplicates are detected and merged.",
105
+ ),
106
+ },
107
+ async execute(args, context) {
108
+ return safeExecute(() => {
109
+ const tags = args.tags
110
+ ? args.tags.split(",").map((t: string) => t.trim()).filter(Boolean)
111
+ : undefined
112
+ const memory = storeMemory({
113
+ content: args.content,
114
+ category: args.category || "general",
115
+ sessionId: context.sessionID,
116
+ projectId,
117
+ source: args.source,
118
+ tags,
119
+ global: args.global,
120
+ force: args.force,
121
+ })
122
+ const scope = memory.project_id ? "project" : "global"
123
+ return `Stored ${scope} memory [${memory.id}] in category "${memory.category}".`
124
+ }, "Failed to store memory")
125
+ },
126
+ }),
127
+
128
+ memory_search: tool({
129
+ description:
130
+ "Search persistent cross-session memory for relevant past context. " +
131
+ "Use this to recall previous findings, decisions, patterns, or debugging insights. " +
132
+ "Searches across all sessions using full-text search with BM25 ranking. " +
133
+ "Query with natural language or keywords.",
134
+ args: {
135
+ query: tool.schema
136
+ .string()
137
+ .describe(
138
+ "Search query. Use keywords or natural language. " +
139
+ "Examples: 'authentication JWT', 'database migration strategy', 'user preference dark mode'.",
140
+ ),
141
+ category: tool.schema
142
+ .enum([
143
+ "decision",
144
+ "pattern",
145
+ "debugging",
146
+ "preference",
147
+ "convention",
148
+ "discovery",
149
+ "general",
150
+ ])
151
+ .optional()
152
+ .describe("Filter results to a specific category"),
153
+ limit: tool.schema
154
+ .number()
155
+ .optional()
156
+ .describe("Max results to return (default: 10)"),
157
+ },
158
+ async execute(args) {
159
+ return safeExecute(() => {
160
+ const results = searchMemories({
161
+ query: args.query,
162
+ category: args.category,
163
+ projectId,
164
+ limit: args.limit || config.searchLimit,
165
+ })
166
+
167
+ if (results.length === 0) {
168
+ return "No memories found matching the query."
169
+ }
170
+
171
+ const formatted = results
172
+ .map((r, i) => {
173
+ const time = new Date(r.memory.time_created * 1000).toISOString()
174
+ return [
175
+ `[${i + 1}] ${r.memory.category.toUpperCase()}`,
176
+ ` ${r.memory.content}`,
177
+ r.memory.source ? ` Source: ${r.memory.source}` : "",
178
+ ` Stored: ${time} | ID: ${r.memory.id}`,
179
+ ]
180
+ .filter(Boolean)
181
+ .join("\n")
182
+ })
183
+ .join("\n\n")
184
+
185
+ return `Found ${results.length} memories:\n\n${formatted}`
186
+ }, "Failed to search memories")
187
+ },
188
+ }),
189
+
190
+ memory_update: tool({
191
+ description:
192
+ "Update an existing memory's content, category, or source. " +
193
+ "Use this to refine or correct a previously stored memory without losing its ID and creation timestamp.",
194
+ args: {
195
+ id: tool.schema.string().describe("The memory ID to update"),
196
+ content: tool.schema
197
+ .string()
198
+ .optional()
199
+ .describe("New content to replace the existing content"),
200
+ category: tool.schema
201
+ .enum([
202
+ "decision",
203
+ "pattern",
204
+ "debugging",
205
+ "preference",
206
+ "convention",
207
+ "discovery",
208
+ "general",
209
+ ])
210
+ .optional()
211
+ .describe("New category"),
212
+ source: tool.schema
213
+ .string()
214
+ .optional()
215
+ .describe("New source description"),
216
+ },
217
+ async execute(args) {
218
+ return safeExecute(() => {
219
+ const updated = updateMemory(args.id, {
220
+ content: args.content,
221
+ category: args.category,
222
+ source: args.source,
223
+ })
224
+ if (!updated) return `Memory ${args.id} not found.`
225
+ return `Updated memory ${args.id}. Category: "${updated.category}".`
226
+ }, "Failed to update memory")
227
+ },
228
+ }),
229
+
230
+ memory_delete: tool({
231
+ description: "Delete a specific memory by its ID.",
232
+ args: {
233
+ id: tool.schema.string().describe("The memory ID to delete"),
234
+ },
235
+ async execute(args) {
236
+ return safeExecute(() => {
237
+ const deleted = deleteMemory(args.id)
238
+ return deleted
239
+ ? `Deleted memory ${args.id}.`
240
+ : `Memory ${args.id} not found.`
241
+ }, "Failed to delete memory")
242
+ },
243
+ }),
244
+
245
+ memory_list: tool({
246
+ description:
247
+ "List recent memories, optionally filtered by category and scope. " +
248
+ "Use this to browse what has been remembered without a specific search query.",
249
+ args: {
250
+ category: tool.schema
251
+ .enum([
252
+ "decision",
253
+ "pattern",
254
+ "debugging",
255
+ "preference",
256
+ "convention",
257
+ "discovery",
258
+ "general",
259
+ ])
260
+ .optional()
261
+ .describe("Filter by category"),
262
+ scope: tool.schema
263
+ .enum(["project", "global", "all"])
264
+ .optional()
265
+ .describe(
266
+ "Filter by scope: 'project' (current project only), 'global' (cross-project), " +
267
+ "'all' (both). Default: 'all'.",
268
+ ),
269
+ limit: tool.schema
270
+ .number()
271
+ .optional()
272
+ .describe("Max results (default: 20)"),
273
+ },
274
+ async execute(args) {
275
+ return safeExecute(() => {
276
+ const memories = listMemories({
277
+ category: args.category,
278
+ projectId,
279
+ scope: args.scope || "all",
280
+ limit: args.limit || 20,
281
+ })
282
+
283
+ if (memories.length === 0) {
284
+ return "No memories stored yet."
285
+ }
286
+
287
+ const formatted = memories
288
+ .map((m, i) => {
289
+ const time = new Date(m.time_created * 1000).toISOString()
290
+ return [
291
+ `[${i + 1}] ${m.category.toUpperCase()}`,
292
+ ` ${m.content}`,
293
+ m.source ? ` Source: ${m.source}` : "",
294
+ ` Stored: ${time} | ID: ${m.id}`,
295
+ ]
296
+ .filter(Boolean)
297
+ .join("\n")
298
+ })
299
+ .join("\n\n")
300
+
301
+ return `${memories.length} memories:\n\n${formatted}`
302
+ }, "Failed to list memories")
303
+ },
304
+ }),
305
+
306
+ memory_refresh: tool({
307
+ description:
308
+ "Manually boost a memory's relevance so it ranks higher in future searches. " +
309
+ "Use this when you encounter a memory that is still highly relevant and should not decay.",
310
+ args: {
311
+ id: tool.schema.string().describe("The memory ID to refresh"),
312
+ },
313
+ async execute(args) {
314
+ return safeExecute(() => {
315
+ const refreshed = refreshMemory(args.id)
316
+ if (!refreshed) return `Memory ${args.id} not found.`
317
+ return `Refreshed memory ${args.id}. Access count: ${refreshed.access_count}.`
318
+ }, "Failed to refresh memory")
319
+ },
320
+ }),
321
+
322
+ memory_tag: tool({
323
+ description:
324
+ "Manage tags on a memory: add, remove, or list tags. " +
325
+ "Also list all known tags with counts, or find memories by tag.",
326
+ args: {
327
+ action: tool.schema
328
+ .enum(["add", "remove", "list", "list_all", "search"])
329
+ .describe(
330
+ "Action to perform. " +
331
+ "add: add tags to a memory. " +
332
+ "remove: remove tags from a memory. " +
333
+ "list: list tags for a specific memory. " +
334
+ "list_all: list all known tags with counts. " +
335
+ "search: find memories with a specific tag.",
336
+ ),
337
+ id: tool.schema
338
+ .string()
339
+ .optional()
340
+ .describe("Memory ID (required for add/remove/list)"),
341
+ tags: tool.schema
342
+ .string()
343
+ .optional()
344
+ .describe("Comma-separated tags (required for add/remove/search)"),
345
+ },
346
+ async execute(args) {
347
+ return safeExecute(() => {
348
+ const tagList = args.tags
349
+ ? args.tags.split(",").map((t: string) => t.trim()).filter(Boolean)
350
+ : []
351
+
352
+ switch (args.action) {
353
+ case "add": {
354
+ if (!args.id) return "Memory ID is required for add action."
355
+ if (tagList.length === 0) return "At least one tag is required."
356
+ addTags(args.id, tagList)
357
+ return `Added tags [${tagList.join(", ")}] to memory ${args.id}.`
358
+ }
359
+ case "remove": {
360
+ if (!args.id) return "Memory ID is required for remove action."
361
+ if (tagList.length === 0) return "At least one tag is required."
362
+ removeTags(args.id, tagList)
363
+ return `Removed tags [${tagList.join(", ")}] from memory ${args.id}.`
364
+ }
365
+ case "list": {
366
+ if (!args.id) return "Memory ID is required for list action."
367
+ const tags = getTagsForMemory(args.id)
368
+ if (tags.length === 0) return `No tags on memory ${args.id}.`
369
+ return `Tags for ${args.id}: ${tags.join(", ")}`
370
+ }
371
+ case "list_all": {
372
+ const all = listAllTags()
373
+ if (all.length === 0) return "No tags exist yet."
374
+ return all.map((t) => ` ${t.tag}: ${t.count} memories`).join("\n")
375
+ }
376
+ case "search": {
377
+ if (tagList.length === 0) return "A tag is required for search."
378
+ const memories = searchByTag(tagList[0]!, { projectId })
379
+ if (memories.length === 0) return `No memories tagged "${tagList[0]}".`
380
+ const formatted = memories
381
+ .map((m, i) => {
382
+ const time = new Date(m.time_created * 1000).toISOString()
383
+ return `[${i + 1}] ${m.category.toUpperCase()}\n ${m.content}\n Stored: ${time} | ID: ${m.id}`
384
+ })
385
+ .join("\n\n")
386
+ return `Memories tagged "${tagList[0]}":\n\n${formatted}`
387
+ }
388
+ default:
389
+ return "Unknown action."
390
+ }
391
+ }, "Failed to manage tags")
392
+ },
393
+ }),
394
+
395
+ memory_link: tool({
396
+ description:
397
+ "Manage relationships between memories: link, unlink, or view links. " +
398
+ "Relationships: 'related' (general connection), 'supersedes' (replaces older memory), " +
399
+ "'contradicts' (conflicts with another), 'extends' (builds upon another).",
400
+ args: {
401
+ action: tool.schema
402
+ .enum(["link", "unlink", "list"])
403
+ .describe(
404
+ "Action: link (create relationship), unlink (remove relationship), " +
405
+ "list (show all links for a memory).",
406
+ ),
407
+ source_id: tool.schema
408
+ .string()
409
+ .describe("The source memory ID"),
410
+ target_id: tool.schema
411
+ .string()
412
+ .optional()
413
+ .describe("The target memory ID (required for link/unlink)"),
414
+ relationship: tool.schema
415
+ .enum(["related", "supersedes", "contradicts", "extends"])
416
+ .optional()
417
+ .describe("Relationship type (required for link)"),
418
+ },
419
+ async execute(args) {
420
+ return safeExecute(() => {
421
+ switch (args.action) {
422
+ case "link": {
423
+ if (!args.target_id) return "Target memory ID is required for link action."
424
+ if (!args.relationship) return "Relationship type is required for link action."
425
+ const ok = addLink(args.source_id, args.target_id, args.relationship)
426
+ if (!ok) return "Failed to link: one or both memory IDs not found, or same ID."
427
+ return `Linked ${args.source_id} → ${args.relationship} → ${args.target_id}.`
428
+ }
429
+ case "unlink": {
430
+ if (!args.target_id) return "Target memory ID is required for unlink action."
431
+ const removed = removeLink(args.source_id, args.target_id)
432
+ return removed
433
+ ? `Unlinked ${args.source_id} from ${args.target_id}.`
434
+ : "Link not found."
435
+ }
436
+ case "list": {
437
+ const links = getLinksForMemory(args.source_id)
438
+ if (links.length === 0) return `No links for memory ${args.source_id}.`
439
+ const formatted = links
440
+ .map((l, i) => {
441
+ const dir = l.source_id === args.source_id ? "→" : "←"
442
+ return `[${i + 1}] ${dir} ${l.relationship}: ${l.linked_memory.content} (ID: ${l.linked_memory.id})`
443
+ })
444
+ .join("\n")
445
+ return `Links for ${args.source_id}:\n${formatted}`
446
+ }
447
+ default:
448
+ return "Unknown action."
449
+ }
450
+ }, "Failed to manage memory links")
451
+ },
452
+ }),
453
+
454
+ memory_stats: tool({
455
+ description: "Show memory statistics: total count and breakdown by category.",
456
+ args: {},
457
+ async execute() {
458
+ return safeExecute(() => {
459
+ const stats = getStats()
460
+
461
+ if (stats.total === 0) {
462
+ return "No memories stored yet."
463
+ }
464
+
465
+ const breakdown = Object.entries(stats.byCategory)
466
+ .map(([cat, count]) => ` ${cat}: ${count}`)
467
+ .join("\n")
468
+
469
+ const sizeBytes = getDbSize()
470
+ const sizeStr =
471
+ sizeBytes > 1048576
472
+ ? `${(sizeBytes / 1048576).toFixed(1)} MB`
473
+ : `${(sizeBytes / 1024).toFixed(1)} KB`
474
+
475
+ return `Total memories: ${stats.total}\nDB size: ${sizeStr}\n\nBy category:\n${breakdown}`
476
+ }, "Failed to get memory stats")
477
+ },
478
+ }),
479
+
480
+ memory_cleanup: tool({
481
+ description:
482
+ "Run database maintenance: optimize FTS index, enforce memory limits, " +
483
+ "optionally purge old unused memories or vacuum the database.",
484
+ args: {
485
+ purge_days: tool.schema
486
+ .number()
487
+ .optional()
488
+ .describe(
489
+ "Purge memories older than this many days that have never been accessed. " +
490
+ "Only affects unaccessed memories.",
491
+ ),
492
+ vacuum: tool.schema
493
+ .boolean()
494
+ .optional()
495
+ .describe("If true, also vacuum the database to reclaim disk space."),
496
+ },
497
+ async execute(args) {
498
+ return safeExecute(() => {
499
+ const result = runMaintenance()
500
+ const lines = [
501
+ `FTS optimized: ${result.ftsOptimized ? "yes" : "no"}`,
502
+ `Memories trimmed (over limit): ${result.memoriesTrimmed}`,
503
+ ]
504
+
505
+ if (args.purge_days) {
506
+ const purged = purgeOldMemories(args.purge_days)
507
+ lines.push(`Purged (older than ${args.purge_days} days, never accessed): ${purged}`)
508
+ }
509
+
510
+ if (args.vacuum) {
511
+ vacuumDb()
512
+ lines.push("Database vacuumed.")
513
+ }
514
+
515
+ const sizeBytes = getDbSize()
516
+ const sizeStr =
517
+ sizeBytes > 1048576
518
+ ? `${(sizeBytes / 1048576).toFixed(1)} MB`
519
+ : `${(sizeBytes / 1024).toFixed(1)} KB`
520
+ lines.push(`DB size: ${sizeStr}`)
521
+
522
+ return `Maintenance complete:\n${lines.join("\n")}`
523
+ }, "Failed to run maintenance")
524
+ },
525
+ }),
526
+
527
+ memory_export: tool({
528
+ description:
529
+ "Export memories to JSON format for backup or migration. " +
530
+ "Includes all metadata, tags, and relationships.",
531
+ args: {
532
+ category: tool.schema
533
+ .enum([
534
+ "decision", "pattern", "debugging", "preference",
535
+ "convention", "discovery", "general",
536
+ ])
537
+ .optional()
538
+ .describe("Filter by category"),
539
+ scope: tool.schema
540
+ .enum(["project", "global", "all"])
541
+ .optional()
542
+ .describe("Filter by scope (default: all)"),
543
+ },
544
+ async execute(args) {
545
+ return safeExecute(() => {
546
+ const memories = listMemories({
547
+ category: args.category,
548
+ projectId,
549
+ scope: args.scope || "all",
550
+ limit: 10000,
551
+ })
552
+
553
+ const exportData = {
554
+ version: 1,
555
+ exported_at: new Date().toISOString(),
556
+ memories: memories.map((m) => ({
557
+ id: m.id,
558
+ content: m.content,
559
+ category: m.category,
560
+ source: m.source,
561
+ project_id: m.project_id,
562
+ time_created: m.time_created,
563
+ time_updated: m.time_updated,
564
+ access_count: m.access_count,
565
+ tags: getTagsForMemory(m.id),
566
+ links: getLinksForMemory(m.id).map((l) => ({
567
+ target_id: l.source_id === m.id ? l.target_id : l.source_id,
568
+ relationship: l.relationship,
569
+ })),
570
+ })),
571
+ }
572
+
573
+ return JSON.stringify(exportData, null, 2)
574
+ }, "Failed to export memories")
575
+ },
576
+ }),
577
+
578
+ memory_import: tool({
579
+ description:
580
+ "Import memories from JSON format (as produced by memory_export). " +
581
+ "Handles ID conflicts by skipping duplicates.",
582
+ args: {
583
+ data: tool.schema
584
+ .string()
585
+ .describe("The JSON string of exported memories to import"),
586
+ },
587
+ async execute(args) {
588
+ return safeExecute(() => {
589
+ let parsed: any
590
+ try {
591
+ parsed = JSON.parse(args.data)
592
+ } catch {
593
+ return "Invalid JSON data."
594
+ }
595
+
596
+ if (!parsed.memories || !Array.isArray(parsed.memories)) {
597
+ return "Invalid export format: missing 'memories' array."
598
+ }
599
+
600
+ let added = 0
601
+ let skipped = 0
602
+ let errors = 0
603
+ const idMap = new Map<string, string>()
604
+
605
+ for (const entry of parsed.memories) {
606
+ try {
607
+ // Skip if memory with same ID already exists
608
+ if (getMemory(entry.id)) {
609
+ idMap.set(entry.id, entry.id)
610
+ skipped++
611
+ continue
612
+ }
613
+
614
+ const memory = storeMemory({
615
+ content: entry.content,
616
+ category: entry.category || "general",
617
+ projectId: entry.project_id || undefined,
618
+ source: entry.source || undefined,
619
+ tags: entry.tags,
620
+ global: !entry.project_id,
621
+ force: true,
622
+ })
623
+ idMap.set(entry.id, memory.id)
624
+ added++
625
+ } catch {
626
+ errors++
627
+ }
628
+ }
629
+
630
+ // Restore links using the ID map
631
+ let linksRestored = 0
632
+ for (const entry of parsed.memories) {
633
+ if (entry.links && Array.isArray(entry.links)) {
634
+ for (const link of entry.links) {
635
+ const sourceId = idMap.get(entry.id)
636
+ const targetId = idMap.get(link.target_id)
637
+ if (sourceId && targetId) {
638
+ try {
639
+ addLink(sourceId, targetId, link.relationship)
640
+ linksRestored++
641
+ } catch { /* skip invalid links */ }
642
+ }
643
+ }
644
+ }
645
+ }
646
+
647
+ return [
648
+ `Import complete:`,
649
+ ` Added: ${added}`,
650
+ ` Skipped (existing): ${skipped}`,
651
+ ` Errors: ${errors}`,
652
+ ` Links restored: ${linksRestored}`,
653
+ ].join("\n")
654
+ }, "Failed to import memories")
655
+ },
656
+ }),
657
+ }
658
+ }