memory-lancedb-pro 1.0.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,639 @@
1
+ /**
2
+ * Agent Tool Definitions
3
+ * Memory management tools for AI agents
4
+ */
5
+
6
+ import { Type } from "@sinclair/typebox";
7
+ import { stringEnum } from "openclaw/plugin-sdk";
8
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
9
+ import type { MemoryRetriever, RetrievalResult } from "./retriever.js";
10
+ import type { MemoryStore } from "./store.js";
11
+ import { isNoise } from "./noise-filter.js";
12
+ import type { MemoryScopeManager } from "./scopes.js";
13
+ import type { Embedder } from "./embedder.js";
14
+
15
+ // ============================================================================
16
+ // Types
17
+ // ============================================================================
18
+
19
+ export const MEMORY_CATEGORIES = ["preference", "fact", "decision", "entity", "other"] as const;
20
+
21
+ interface ToolContext {
22
+ retriever: MemoryRetriever;
23
+ store: MemoryStore;
24
+ scopeManager: MemoryScopeManager;
25
+ embedder: Embedder;
26
+ agentId?: string;
27
+ }
28
+
29
+ // ============================================================================
30
+ // Utility Functions
31
+ // ============================================================================
32
+
33
+ function clampInt(value: number, min: number, max: number): number {
34
+ if (!Number.isFinite(value)) return min;
35
+ return Math.min(max, Math.max(min, Math.floor(value)));
36
+ }
37
+
38
+ function clamp01(value: number, fallback = 0.7): number {
39
+ if (!Number.isFinite(value)) return fallback;
40
+ return Math.min(1, Math.max(0, value));
41
+ }
42
+
43
+ function sanitizeMemoryForSerialization(results: RetrievalResult[]) {
44
+ return results.map(r => ({
45
+ id: r.entry.id,
46
+ text: r.entry.text,
47
+ category: r.entry.category,
48
+ scope: r.entry.scope,
49
+ importance: r.entry.importance,
50
+ score: r.score,
51
+ sources: r.sources,
52
+ }));
53
+ }
54
+
55
+ // ============================================================================
56
+ // Core Tools (Backward Compatible)
57
+ // ============================================================================
58
+
59
+ export function registerMemoryRecallTool(api: OpenClawPluginApi, context: ToolContext) {
60
+ api.registerTool(
61
+ {
62
+ name: "memory_recall",
63
+ label: "Memory Recall",
64
+ description: "Search through long-term memories using hybrid retrieval (vector + keyword search). Use when you need context about user preferences, past decisions, or previously discussed topics.",
65
+ parameters: Type.Object({
66
+ query: Type.String({ description: "Search query for finding relevant memories" }),
67
+ limit: Type.Optional(Type.Number({ description: "Max results to return (default: 5, max: 20)" })),
68
+ scope: Type.Optional(Type.String({ description: "Specific memory scope to search in (optional)" })),
69
+ category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
70
+ }),
71
+ async execute(_toolCallId, params) {
72
+ const { query, limit = 5, scope, category } = params as {
73
+ query: string;
74
+ limit?: number;
75
+ scope?: string;
76
+ category?: string;
77
+ };
78
+
79
+ try {
80
+ const safeLimit = clampInt(limit, 1, 20);
81
+
82
+ // Determine accessible scopes
83
+ let scopeFilter = context.scopeManager.getAccessibleScopes(context.agentId);
84
+ if (scope) {
85
+ if (context.scopeManager.isAccessible(scope, context.agentId)) {
86
+ scopeFilter = [scope];
87
+ } else {
88
+ return {
89
+ content: [{ type: "text", text: `Access denied to scope: ${scope}` }],
90
+ details: { error: "scope_access_denied", requestedScope: scope },
91
+ };
92
+ }
93
+ }
94
+
95
+ const results = await context.retriever.retrieve({
96
+ query,
97
+ limit: safeLimit,
98
+ scopeFilter,
99
+ category,
100
+ });
101
+
102
+ if (results.length === 0) {
103
+ return {
104
+ content: [{ type: "text", text: "No relevant memories found." }],
105
+ details: { count: 0, query, scopes: scopeFilter },
106
+ };
107
+ }
108
+
109
+ const text = results
110
+ .map((r, i) => {
111
+ const sources = [];
112
+ if (r.sources.vector) sources.push("vector");
113
+ if (r.sources.bm25) sources.push("BM25");
114
+ if (r.sources.reranked) sources.push("reranked");
115
+
116
+ return `${i + 1}. [${r.entry.category}:${r.entry.scope}] ${r.entry.text} (${(r.score * 100).toFixed(0)}%${sources.length > 0 ? `, ${sources.join('+')}` : ''})`;
117
+ })
118
+ .join("\n");
119
+
120
+ return {
121
+ content: [{ type: "text", text: `Found ${results.length} memories:\n\n${text}` }],
122
+ details: {
123
+ count: results.length,
124
+ memories: sanitizeMemoryForSerialization(results),
125
+ query,
126
+ scopes: scopeFilter,
127
+ retrievalMode: context.retriever.getConfig().mode,
128
+ },
129
+ };
130
+ } catch (error) {
131
+ return {
132
+ content: [{ type: "text", text: `Memory recall failed: ${error instanceof Error ? error.message : String(error)}` }],
133
+ details: { error: "recall_failed", message: String(error) },
134
+ };
135
+ }
136
+ },
137
+ },
138
+ { name: "memory_recall" }
139
+ );
140
+ }
141
+
142
+ export function registerMemoryStoreTool(api: OpenClawPluginApi, context: ToolContext) {
143
+ api.registerTool(
144
+ {
145
+ name: "memory_store",
146
+ label: "Memory Store",
147
+ description: "Save important information in long-term memory. Use for preferences, facts, decisions, and other notable information.",
148
+ parameters: Type.Object({
149
+ text: Type.String({ description: "Information to remember" }),
150
+ importance: Type.Optional(Type.Number({ description: "Importance score 0-1 (default: 0.7)" })),
151
+ category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
152
+ scope: Type.Optional(Type.String({ description: "Memory scope (optional, defaults to agent scope)" })),
153
+ }),
154
+ async execute(_toolCallId, params) {
155
+ const {
156
+ text,
157
+ importance = 0.7,
158
+ category = "other",
159
+ scope,
160
+ } = params as {
161
+ text: string;
162
+ importance?: number;
163
+ category?: string;
164
+ scope?: string;
165
+ };
166
+
167
+ try {
168
+ // Determine target scope
169
+ let targetScope = scope || context.scopeManager.getDefaultScope(context.agentId);
170
+
171
+ // Validate scope access
172
+ if (!context.scopeManager.isAccessible(targetScope, context.agentId)) {
173
+ return {
174
+ content: [{ type: "text", text: `Access denied to scope: ${targetScope}` }],
175
+ details: { error: "scope_access_denied", requestedScope: targetScope },
176
+ };
177
+ }
178
+
179
+ // Reject noise before wasting an embedding API call
180
+ if (isNoise(text)) {
181
+ return {
182
+ content: [{ type: "text", text: `Skipped: text detected as noise (greeting, boilerplate, or meta-question)` }],
183
+ details: { action: "noise_filtered", text: text.slice(0, 60) },
184
+ };
185
+ }
186
+
187
+ const safeImportance = clamp01(importance, 0.7);
188
+ const vector = await context.embedder.embedPassage(text);
189
+
190
+ // Check for duplicates using raw vector similarity (bypasses importance/recency weighting)
191
+ const existing = await context.store.vectorSearch(vector, 1, 0.1, [targetScope]);
192
+
193
+ if (existing.length > 0 && existing[0].score > 0.98) {
194
+ return {
195
+ content: [
196
+ {
197
+ type: "text",
198
+ text: `Similar memory already exists: "${existing[0].entry.text}"`,
199
+ },
200
+ ],
201
+ details: {
202
+ action: "duplicate",
203
+ existingId: existing[0].entry.id,
204
+ existingText: existing[0].entry.text,
205
+ existingScope: existing[0].entry.scope,
206
+ similarity: existing[0].score,
207
+ },
208
+ };
209
+ }
210
+
211
+ const entry = await context.store.store({
212
+ text,
213
+ vector,
214
+ importance: safeImportance,
215
+ category: category as any,
216
+ scope: targetScope,
217
+ });
218
+
219
+ return {
220
+ content: [{ type: "text", text: `Stored: "${text.slice(0, 100)}${text.length > 100 ? '...' : ''}" in scope '${targetScope}'` }],
221
+ details: {
222
+ action: "created",
223
+ id: entry.id,
224
+ scope: entry.scope,
225
+ category: entry.category,
226
+ importance: entry.importance,
227
+ },
228
+ };
229
+ } catch (error) {
230
+ return {
231
+ content: [{ type: "text", text: `Memory storage failed: ${error instanceof Error ? error.message : String(error)}` }],
232
+ details: { error: "store_failed", message: String(error) },
233
+ };
234
+ }
235
+ },
236
+ },
237
+ { name: "memory_store" }
238
+ );
239
+ }
240
+
241
+ export function registerMemoryForgetTool(api: OpenClawPluginApi, context: ToolContext) {
242
+ api.registerTool(
243
+ {
244
+ name: "memory_forget",
245
+ label: "Memory Forget",
246
+ description: "Delete specific memories. Supports both search-based and direct ID-based deletion.",
247
+ parameters: Type.Object({
248
+ query: Type.Optional(Type.String({ description: "Search query to find memory to delete" })),
249
+ memoryId: Type.Optional(Type.String({ description: "Specific memory ID to delete" })),
250
+ scope: Type.Optional(Type.String({ description: "Scope to search/delete from (optional)" })),
251
+ }),
252
+ async execute(_toolCallId, params) {
253
+ const { query, memoryId, scope } = params as {
254
+ query?: string;
255
+ memoryId?: string;
256
+ scope?: string;
257
+ };
258
+
259
+ try {
260
+ // Determine accessible scopes
261
+ let scopeFilter = context.scopeManager.getAccessibleScopes(context.agentId);
262
+ if (scope) {
263
+ if (context.scopeManager.isAccessible(scope, context.agentId)) {
264
+ scopeFilter = [scope];
265
+ } else {
266
+ return {
267
+ content: [{ type: "text", text: `Access denied to scope: ${scope}` }],
268
+ details: { error: "scope_access_denied", requestedScope: scope },
269
+ };
270
+ }
271
+ }
272
+
273
+ if (memoryId) {
274
+ const deleted = await context.store.delete(memoryId, scopeFilter);
275
+ if (deleted) {
276
+ return {
277
+ content: [{ type: "text", text: `Memory ${memoryId} forgotten.` }],
278
+ details: { action: "deleted", id: memoryId },
279
+ };
280
+ } else {
281
+ return {
282
+ content: [{ type: "text", text: `Memory ${memoryId} not found or access denied.` }],
283
+ details: { error: "not_found", id: memoryId },
284
+ };
285
+ }
286
+ }
287
+
288
+ if (query) {
289
+ const results = await context.retriever.retrieve({
290
+ query,
291
+ limit: 5,
292
+ scopeFilter,
293
+ });
294
+
295
+ if (results.length === 0) {
296
+ return {
297
+ content: [{ type: "text", text: "No matching memories found." }],
298
+ details: { found: 0, query },
299
+ };
300
+ }
301
+
302
+ if (results.length === 1 && results[0].score > 0.9) {
303
+ const deleted = await context.store.delete(results[0].entry.id, scopeFilter);
304
+ if (deleted) {
305
+ return {
306
+ content: [{ type: "text", text: `Forgotten: "${results[0].entry.text}"` }],
307
+ details: { action: "deleted", id: results[0].entry.id },
308
+ };
309
+ }
310
+ }
311
+
312
+ const list = results
313
+ .map(r => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}${r.entry.text.length > 60 ? '...' : ''}`)
314
+ .join("\n");
315
+
316
+ return {
317
+ content: [
318
+ {
319
+ type: "text",
320
+ text: `Found ${results.length} candidates. Specify memoryId to delete:\n${list}`,
321
+ },
322
+ ],
323
+ details: {
324
+ action: "candidates",
325
+ candidates: sanitizeMemoryForSerialization(results),
326
+ },
327
+ };
328
+ }
329
+
330
+ return {
331
+ content: [{ type: "text", text: "Provide either 'query' to search for memories or 'memoryId' to delete specific memory." }],
332
+ details: { error: "missing_param" },
333
+ };
334
+ } catch (error) {
335
+ return {
336
+ content: [{ type: "text", text: `Memory deletion failed: ${error instanceof Error ? error.message : String(error)}` }],
337
+ details: { error: "delete_failed", message: String(error) },
338
+ };
339
+ }
340
+ },
341
+ },
342
+ { name: "memory_forget" }
343
+ );
344
+ }
345
+
346
+ // ============================================================================
347
+ // Update Tool
348
+ // ============================================================================
349
+
350
+ export function registerMemoryUpdateTool(api: OpenClawPluginApi, context: ToolContext) {
351
+ api.registerTool(
352
+ {
353
+ name: "memory_update",
354
+ label: "Memory Update",
355
+ description: "Update an existing memory in-place. Preserves original timestamp. Use when correcting outdated info or adjusting importance/category without losing creation date.",
356
+ parameters: Type.Object({
357
+ memoryId: Type.String({ description: "ID of the memory to update (full UUID or 8+ char prefix)" }),
358
+ text: Type.Optional(Type.String({ description: "New text content (triggers re-embedding)" })),
359
+ importance: Type.Optional(Type.Number({ description: "New importance score 0-1" })),
360
+ category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
361
+ }),
362
+ async execute(_toolCallId, params) {
363
+ const { memoryId, text, importance, category } = params as {
364
+ memoryId: string;
365
+ text?: string;
366
+ importance?: number;
367
+ category?: string;
368
+ };
369
+
370
+ try {
371
+ if (!text && importance === undefined && !category) {
372
+ return {
373
+ content: [{ type: "text", text: "Nothing to update. Provide at least one of: text, importance, category." }],
374
+ details: { error: "no_updates" },
375
+ };
376
+ }
377
+
378
+ // Determine accessible scopes
379
+ const scopeFilter = context.scopeManager.getAccessibleScopes(context.agentId);
380
+
381
+ // Resolve memoryId: if it doesn't look like a UUID, try search
382
+ let resolvedId = memoryId;
383
+ const uuidLike = /^[0-9a-f]{8}(-[0-9a-f]{4}){0,4}/i.test(memoryId);
384
+ if (!uuidLike) {
385
+ // Treat as search query
386
+ const results = await context.retriever.retrieve({
387
+ query: memoryId,
388
+ limit: 3,
389
+ scopeFilter,
390
+ });
391
+ if (results.length === 0) {
392
+ return {
393
+ content: [{ type: "text", text: `No memory found matching "${memoryId}".` }],
394
+ details: { error: "not_found", query: memoryId },
395
+ };
396
+ }
397
+ if (results.length === 1 || results[0].score > 0.85) {
398
+ resolvedId = results[0].entry.id;
399
+ } else {
400
+ const list = results
401
+ .map(r => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}${r.entry.text.length > 60 ? '...' : ''}`)
402
+ .join("\n");
403
+ return {
404
+ content: [{ type: "text", text: `Multiple matches. Specify memoryId:\n${list}` }],
405
+ details: { action: "candidates", candidates: sanitizeMemoryForSerialization(results) },
406
+ };
407
+ }
408
+ }
409
+
410
+ // If text changed, re-embed; reject noise
411
+ let newVector: number[] | undefined;
412
+ if (text) {
413
+ if (isNoise(text)) {
414
+ return {
415
+ content: [{ type: "text", text: "Skipped: updated text detected as noise" }],
416
+ details: { action: "noise_filtered" },
417
+ };
418
+ }
419
+ newVector = await context.embedder.embedPassage(text);
420
+ }
421
+
422
+ const updates: Record<string, any> = {};
423
+ if (text) updates.text = text;
424
+ if (newVector) updates.vector = newVector;
425
+ if (importance !== undefined) updates.importance = clamp01(importance, 0.7);
426
+ if (category) updates.category = category;
427
+
428
+ const updated = await context.store.update(resolvedId, updates, scopeFilter);
429
+
430
+ if (!updated) {
431
+ return {
432
+ content: [{ type: "text", text: `Memory ${resolvedId.slice(0, 8)}... not found or access denied.` }],
433
+ details: { error: "not_found", id: resolvedId },
434
+ };
435
+ }
436
+
437
+ return {
438
+ content: [{ type: "text", text: `Updated memory ${updated.id.slice(0, 8)}...: "${updated.text.slice(0, 80)}${updated.text.length > 80 ? '...' : ''}"` }],
439
+ details: {
440
+ action: "updated",
441
+ id: updated.id,
442
+ scope: updated.scope,
443
+ category: updated.category,
444
+ importance: updated.importance,
445
+ fieldsUpdated: Object.keys(updates),
446
+ },
447
+ };
448
+ } catch (error) {
449
+ return {
450
+ content: [{ type: "text", text: `Memory update failed: ${error instanceof Error ? error.message : String(error)}` }],
451
+ details: { error: "update_failed", message: String(error) },
452
+ };
453
+ }
454
+ },
455
+ },
456
+ { name: "memory_update" }
457
+ );
458
+ }
459
+
460
+ // ============================================================================
461
+ // Management Tools (Optional)
462
+ // ============================================================================
463
+
464
+ export function registerMemoryStatsTool(api: OpenClawPluginApi, context: ToolContext) {
465
+ api.registerTool(
466
+ {
467
+ name: "memory_stats",
468
+ label: "Memory Statistics",
469
+ description: "Get statistics about memory usage, scopes, and categories.",
470
+ parameters: Type.Object({
471
+ scope: Type.Optional(Type.String({ description: "Specific scope to get stats for (optional)" })),
472
+ }),
473
+ async execute(_toolCallId, params) {
474
+ const { scope } = params as { scope?: string };
475
+
476
+ try {
477
+ // Determine accessible scopes
478
+ let scopeFilter = context.scopeManager.getAccessibleScopes(context.agentId);
479
+ if (scope) {
480
+ if (context.scopeManager.isAccessible(scope, context.agentId)) {
481
+ scopeFilter = [scope];
482
+ } else {
483
+ return {
484
+ content: [{ type: "text", text: `Access denied to scope: ${scope}` }],
485
+ details: { error: "scope_access_denied", requestedScope: scope },
486
+ };
487
+ }
488
+ }
489
+
490
+ const stats = await context.store.stats(scopeFilter);
491
+ const scopeManagerStats = context.scopeManager.getStats();
492
+ const retrievalConfig = context.retriever.getConfig();
493
+
494
+ const text = [
495
+ `Memory Statistics:`,
496
+ `• Total memories: ${stats.totalCount}`,
497
+ `• Available scopes: ${scopeManagerStats.totalScopes}`,
498
+ `• Retrieval mode: ${retrievalConfig.mode}`,
499
+ `• FTS support: ${context.store.hasFtsSupport ? 'Yes' : 'No'}`,
500
+ ``,
501
+ `Memories by scope:`,
502
+ ...Object.entries(stats.scopeCounts).map(([s, count]) => ` • ${s}: ${count}`),
503
+ ``,
504
+ `Memories by category:`,
505
+ ...Object.entries(stats.categoryCounts).map(([c, count]) => ` • ${c}: ${count}`),
506
+ ].join('\n');
507
+
508
+ return {
509
+ content: [{ type: "text", text }],
510
+ details: {
511
+ stats,
512
+ scopeManagerStats,
513
+ retrievalConfig: {
514
+ ...retrievalConfig,
515
+ rerankApiKey: retrievalConfig.rerankApiKey ? "***" : undefined,
516
+ },
517
+ hasFtsSupport: context.store.hasFtsSupport,
518
+ },
519
+ };
520
+ } catch (error) {
521
+ return {
522
+ content: [{ type: "text", text: `Failed to get memory stats: ${error instanceof Error ? error.message : String(error)}` }],
523
+ details: { error: "stats_failed", message: String(error) },
524
+ };
525
+ }
526
+ },
527
+ },
528
+ { name: "memory_stats" }
529
+ );
530
+ }
531
+
532
+ export function registerMemoryListTool(api: OpenClawPluginApi, context: ToolContext) {
533
+ api.registerTool(
534
+ {
535
+ name: "memory_list",
536
+ label: "Memory List",
537
+ description: "List recent memories with optional filtering by scope and category.",
538
+ parameters: Type.Object({
539
+ limit: Type.Optional(Type.Number({ description: "Max memories to list (default: 10, max: 50)" })),
540
+ scope: Type.Optional(Type.String({ description: "Filter by specific scope (optional)" })),
541
+ category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
542
+ offset: Type.Optional(Type.Number({ description: "Number of memories to skip (default: 0)" })),
543
+ }),
544
+ async execute(_toolCallId, params) {
545
+ const {
546
+ limit = 10,
547
+ scope,
548
+ category,
549
+ offset = 0,
550
+ } = params as {
551
+ limit?: number;
552
+ scope?: string;
553
+ category?: string;
554
+ offset?: number;
555
+ };
556
+
557
+ try {
558
+ const safeLimit = clampInt(limit, 1, 50);
559
+ const safeOffset = clampInt(offset, 0, 1000);
560
+
561
+ // Determine accessible scopes
562
+ let scopeFilter = context.scopeManager.getAccessibleScopes(context.agentId);
563
+ if (scope) {
564
+ if (context.scopeManager.isAccessible(scope, context.agentId)) {
565
+ scopeFilter = [scope];
566
+ } else {
567
+ return {
568
+ content: [{ type: "text", text: `Access denied to scope: ${scope}` }],
569
+ details: { error: "scope_access_denied", requestedScope: scope },
570
+ };
571
+ }
572
+ }
573
+
574
+ const entries = await context.store.list(scopeFilter, category, safeLimit, safeOffset);
575
+
576
+ if (entries.length === 0) {
577
+ return {
578
+ content: [{ type: "text", text: "No memories found." }],
579
+ details: { count: 0, filters: { scope, category, limit: safeLimit, offset: safeOffset } },
580
+ };
581
+ }
582
+
583
+ const text = entries
584
+ .map((entry, i) => {
585
+ const date = new Date(entry.timestamp).toISOString().split('T')[0];
586
+ return `${safeOffset + i + 1}. [${entry.category}:${entry.scope}] ${entry.text.slice(0, 100)}${entry.text.length > 100 ? '...' : ''} (${date})`;
587
+ })
588
+ .join('\n');
589
+
590
+ return {
591
+ content: [{ type: "text", text: `Recent memories (showing ${entries.length}):\n\n${text}` }],
592
+ details: {
593
+ count: entries.length,
594
+ memories: entries.map(e => ({
595
+ id: e.id,
596
+ text: e.text,
597
+ category: e.category,
598
+ scope: e.scope,
599
+ importance: e.importance,
600
+ timestamp: e.timestamp,
601
+ })),
602
+ filters: { scope, category, limit: safeLimit, offset: safeOffset },
603
+ },
604
+ };
605
+ } catch (error) {
606
+ return {
607
+ content: [{ type: "text", text: `Failed to list memories: ${error instanceof Error ? error.message : String(error)}` }],
608
+ details: { error: "list_failed", message: String(error) },
609
+ };
610
+ }
611
+ },
612
+ },
613
+ { name: "memory_list" }
614
+ );
615
+ }
616
+
617
+ // ============================================================================
618
+ // Tool Registration Helper
619
+ // ============================================================================
620
+
621
+ export function registerAllMemoryTools(
622
+ api: OpenClawPluginApi,
623
+ context: ToolContext,
624
+ options: {
625
+ enableManagementTools?: boolean;
626
+ } = {}
627
+ ) {
628
+ // Core tools (always enabled)
629
+ registerMemoryRecallTool(api, context);
630
+ registerMemoryStoreTool(api, context);
631
+ registerMemoryForgetTool(api, context);
632
+ registerMemoryUpdateTool(api, context);
633
+
634
+ // Management tools (optional)
635
+ if (options.enableManagementTools) {
636
+ registerMemoryStatsTool(api, context);
637
+ registerMemoryListTool(api, context);
638
+ }
639
+ }