@mnemoai/core 1.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.
Files changed (49) hide show
  1. package/index.ts +3395 -0
  2. package/openclaw.plugin.json +815 -0
  3. package/package.json +59 -0
  4. package/src/access-tracker.ts +341 -0
  5. package/src/adapters/README.md +78 -0
  6. package/src/adapters/chroma.ts +206 -0
  7. package/src/adapters/lancedb.ts +237 -0
  8. package/src/adapters/pgvector.ts +218 -0
  9. package/src/adapters/qdrant.ts +191 -0
  10. package/src/adaptive-retrieval.ts +90 -0
  11. package/src/audit-log.ts +238 -0
  12. package/src/chunker.ts +254 -0
  13. package/src/config.ts +271 -0
  14. package/src/decay-engine.ts +238 -0
  15. package/src/embedder.ts +735 -0
  16. package/src/extraction-prompts.ts +339 -0
  17. package/src/license.ts +258 -0
  18. package/src/llm-client.ts +125 -0
  19. package/src/mcp-server.ts +415 -0
  20. package/src/memory-categories.ts +71 -0
  21. package/src/memory-upgrader.ts +388 -0
  22. package/src/migrate.ts +364 -0
  23. package/src/mnemo.ts +142 -0
  24. package/src/noise-filter.ts +97 -0
  25. package/src/noise-prototypes.ts +164 -0
  26. package/src/observability.ts +81 -0
  27. package/src/query-tracker.ts +57 -0
  28. package/src/reflection-event-store.ts +98 -0
  29. package/src/reflection-item-store.ts +112 -0
  30. package/src/reflection-mapped-metadata.ts +84 -0
  31. package/src/reflection-metadata.ts +23 -0
  32. package/src/reflection-ranking.ts +33 -0
  33. package/src/reflection-retry.ts +181 -0
  34. package/src/reflection-slices.ts +265 -0
  35. package/src/reflection-store.ts +602 -0
  36. package/src/resonance-state.ts +85 -0
  37. package/src/retriever.ts +1510 -0
  38. package/src/scopes.ts +375 -0
  39. package/src/self-improvement-files.ts +143 -0
  40. package/src/semantic-gate.ts +121 -0
  41. package/src/session-recovery.ts +138 -0
  42. package/src/smart-extractor.ts +923 -0
  43. package/src/smart-metadata.ts +561 -0
  44. package/src/storage-adapter.ts +153 -0
  45. package/src/store.ts +1330 -0
  46. package/src/tier-manager.ts +189 -0
  47. package/src/tools.ts +1292 -0
  48. package/src/wal-recovery.ts +172 -0
  49. package/test/core.test.mjs +301 -0
package/src/tools.ts ADDED
@@ -0,0 +1,1292 @@
1
+ // SPDX-License-Identifier: LicenseRef-Mnemo-Pro
2
+ /**
3
+ * Agent Tool Definitions
4
+ * Memory management tools for AI agents
5
+ */
6
+
7
+ import { Type } from "@sinclair/typebox";
8
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
9
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import type { MemoryRetriever, RetrievalResult } from "./retriever.js";
13
+ import type { MemoryStore } from "./store.js";
14
+ import { isNoise } from "./noise-filter.js";
15
+ import type { MemoryScopeManager } from "./scopes.js";
16
+ import type { Embedder } from "./embedder.js";
17
+ import {
18
+ buildSmartMetadata,
19
+ parseSmartMetadata,
20
+ stringifySmartMetadata,
21
+ } from "./smart-metadata.js";
22
+ import { appendSelfImprovementEntry, ensureSelfImprovementLearningFiles } from "./self-improvement-files.js";
23
+ import { getDisplayCategoryTag } from "./reflection-metadata.js";
24
+
25
+ // ============================================================================
26
+ // Types
27
+ // ============================================================================
28
+
29
+ export const MEMORY_CATEGORIES = [
30
+ "preference",
31
+ "fact",
32
+ "decision",
33
+ "entity",
34
+ "reflection",
35
+ "other",
36
+ ] as const;
37
+
38
+ function stringEnum<T extends readonly [string, ...string[]]>(values: T) {
39
+ return Type.Unsafe<T[number]>({
40
+ type: "string",
41
+ enum: [...values],
42
+ });
43
+ }
44
+ export type MdMirrorWriter = (
45
+ entry: { text: string; category: string; scope: string; timestamp?: number },
46
+ meta?: { source?: string; agentId?: string },
47
+ ) => Promise<void>;
48
+
49
+ interface ToolContext {
50
+ retriever: MemoryRetriever;
51
+ store: MemoryStore;
52
+ scopeManager: MemoryScopeManager;
53
+ embedder: Embedder;
54
+ agentId?: string;
55
+ workspaceDir?: string;
56
+ mdMirror?: MdMirrorWriter | null;
57
+ }
58
+
59
+ function resolveAgentId(runtimeAgentId: unknown, fallback?: string): string | undefined {
60
+ if (typeof runtimeAgentId === "string" && runtimeAgentId.trim().length > 0) return runtimeAgentId;
61
+ if (typeof fallback === "string" && fallback.trim().length > 0) return fallback;
62
+ return undefined;
63
+ }
64
+
65
+ // ============================================================================
66
+ // Utility Functions
67
+ // ============================================================================
68
+
69
+ function clampInt(value: number, min: number, max: number): number {
70
+ if (!Number.isFinite(value)) return min;
71
+ return Math.min(max, Math.max(min, Math.floor(value)));
72
+ }
73
+
74
+ function clamp01(value: number, fallback = 0.7): number {
75
+ if (!Number.isFinite(value)) return fallback;
76
+ return Math.min(1, Math.max(0, value));
77
+ }
78
+
79
+ function sanitizeMemoryForSerialization(results: RetrievalResult[]) {
80
+ return results.map((r) => ({
81
+ id: r.entry.id,
82
+ text: r.entry.text,
83
+ category: getDisplayCategoryTag(r.entry),
84
+ rawCategory: r.entry.category,
85
+ scope: r.entry.scope,
86
+ importance: r.entry.importance,
87
+ score: r.score,
88
+ sources: r.sources,
89
+ }));
90
+ }
91
+
92
+ function parseAgentIdFromSessionKey(sessionKey: string | undefined): string | undefined {
93
+ if (!sessionKey) return undefined;
94
+ const m = /^agent:([^:]+):/.exec(sessionKey);
95
+ return m?.[1];
96
+ }
97
+
98
+ function resolveRuntimeAgentId(
99
+ staticAgentId: string | undefined,
100
+ runtimeCtx: unknown,
101
+ ): string | undefined {
102
+ if (!runtimeCtx || typeof runtimeCtx !== "object") return staticAgentId;
103
+ const ctx = runtimeCtx as Record<string, unknown>;
104
+ const ctxAgentId = typeof ctx.agentId === "string" ? ctx.agentId : undefined;
105
+ const ctxSessionKey = typeof ctx.sessionKey === "string" ? ctx.sessionKey : undefined;
106
+ return ctxAgentId || parseAgentIdFromSessionKey(ctxSessionKey) || staticAgentId;
107
+ }
108
+
109
+ function resolveToolContext(
110
+ base: ToolContext,
111
+ runtimeCtx: unknown,
112
+ ): ToolContext {
113
+ return {
114
+ ...base,
115
+ agentId: resolveRuntimeAgentId(base.agentId, runtimeCtx),
116
+ };
117
+ }
118
+
119
+ async function sleep(ms: number): Promise<void> {
120
+ await new Promise(resolve => setTimeout(resolve, ms));
121
+ }
122
+
123
+ async function retrieveWithRetry(
124
+ retriever: MemoryRetriever,
125
+ params: {
126
+ query: string;
127
+ limit: number;
128
+ scopeFilter?: string[];
129
+ category?: string;
130
+ },
131
+ ): Promise<RetrievalResult[]> {
132
+ let results = await retriever.retrieve(params);
133
+ if (results.length === 0) {
134
+ await sleep(75);
135
+ results = await retriever.retrieve(params);
136
+ }
137
+ return results;
138
+ }
139
+
140
+ function resolveWorkspaceDir(toolCtx: unknown, fallback?: string): string {
141
+ const runtime = toolCtx as Record<string, unknown> | undefined;
142
+ const runtimePath = typeof runtime?.workspaceDir === "string" ? runtime.workspaceDir.trim() : "";
143
+ if (runtimePath) return runtimePath;
144
+ if (fallback && fallback.trim()) return fallback;
145
+ return join(homedir(), ".openclaw", "workspace");
146
+ }
147
+
148
+ function escapeRegExp(input: string): string {
149
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
150
+ }
151
+
152
+ export function registerSelfImprovementLogTool(api: OpenClawPluginApi, context: ToolContext) {
153
+ api.registerTool(
154
+ (toolCtx) => ({
155
+ name: "self_improvement_log",
156
+ label: "Self-Improvement Log",
157
+ description: "Log structured learning/error entries into .learnings for governance and later distillation.",
158
+ parameters: Type.Object({
159
+ type: stringEnum(["learning", "error"]),
160
+ summary: Type.String({ description: "One-line summary" }),
161
+ details: Type.Optional(Type.String({ description: "Detailed context or error output" })),
162
+ suggestedAction: Type.Optional(Type.String({ description: "Concrete action to prevent recurrence" })),
163
+ category: Type.Optional(Type.String({ description: "learning category (correction/best_practice/knowledge_gap) when type=learning" })),
164
+ area: Type.Optional(Type.String({ description: "frontend|backend|infra|tests|docs|config or custom area" })),
165
+ priority: Type.Optional(Type.String({ description: "low|medium|high|critical" })),
166
+ }),
167
+ async execute(_toolCallId, params) {
168
+ const {
169
+ type,
170
+ summary,
171
+ details = "",
172
+ suggestedAction = "",
173
+ category = "best_practice",
174
+ area = "config",
175
+ priority = "medium",
176
+ } = params as {
177
+ type: "learning" | "error";
178
+ summary: string;
179
+ details?: string;
180
+ suggestedAction?: string;
181
+ category?: string;
182
+ area?: string;
183
+ priority?: string;
184
+ };
185
+ try {
186
+ const workspaceDir = resolveWorkspaceDir(toolCtx, context.workspaceDir);
187
+ const { id: entryId, filePath } = await appendSelfImprovementEntry({
188
+ baseDir: workspaceDir,
189
+ type,
190
+ summary,
191
+ details,
192
+ suggestedAction,
193
+ category,
194
+ area,
195
+ priority,
196
+ source: "mnemo/self_improvement_log",
197
+ });
198
+ const fileName = type === "learning" ? "LEARNINGS.md" : "ERRORS.md";
199
+
200
+ return {
201
+ content: [{ type: "text", text: `Logged ${type} entry ${entryId} to .learnings/${fileName}` }],
202
+ details: { action: "logged", type, id: entryId, filePath },
203
+ };
204
+ } catch (error) {
205
+ return {
206
+ content: [{ type: "text", text: `Failed to log self-improvement entry: ${error instanceof Error ? error.message : String(error)}` }],
207
+ details: { error: "self_improvement_log_failed", message: String(error) },
208
+ };
209
+ }
210
+ },
211
+ }),
212
+ { name: "self_improvement_log" }
213
+ );
214
+ }
215
+
216
+ export function registerSelfImprovementExtractSkillTool(api: OpenClawPluginApi, context: ToolContext) {
217
+ api.registerTool(
218
+ (toolCtx) => ({
219
+ name: "self_improvement_extract_skill",
220
+ label: "Extract Skill From Learning",
221
+ description: "Create a new skill scaffold from a learning entry and mark the source learning as promoted_to_skill.",
222
+ parameters: Type.Object({
223
+ learningId: Type.String({ description: "Learning ID like LRN-YYYYMMDD-001" }),
224
+ skillName: Type.String({ description: "Skill folder name, lowercase with hyphens" }),
225
+ sourceFile: Type.Optional(stringEnum(["LEARNINGS.md", "ERRORS.md"])),
226
+ outputDir: Type.Optional(Type.String({ description: "Relative output dir under workspace (default: skills)" })),
227
+ }),
228
+ async execute(_toolCallId, params) {
229
+ const { learningId, skillName, sourceFile = "LEARNINGS.md", outputDir = "skills" } = params as {
230
+ learningId: string;
231
+ skillName: string;
232
+ sourceFile?: "LEARNINGS.md" | "ERRORS.md";
233
+ outputDir?: string;
234
+ };
235
+ try {
236
+ if (!/^(LRN|ERR)-\d{8}-\d{3}$/.test(learningId)) {
237
+ return {
238
+ content: [{ type: "text", text: "Invalid learningId format. Use LRN-YYYYMMDD-001 / ERR-..." }],
239
+ details: { error: "invalid_learning_id" },
240
+ };
241
+ }
242
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(skillName)) {
243
+ return {
244
+ content: [{ type: "text", text: "Invalid skillName. Use lowercase letters, numbers, and hyphens only." }],
245
+ details: { error: "invalid_skill_name" },
246
+ };
247
+ }
248
+
249
+ const workspaceDir = resolveWorkspaceDir(toolCtx, context.workspaceDir);
250
+ await ensureSelfImprovementLearningFiles(workspaceDir);
251
+ const learningsPath = join(workspaceDir, ".learnings", sourceFile);
252
+ const learningBody = await readFile(learningsPath, "utf-8");
253
+ const escapedLearningId = escapeRegExp(learningId.trim());
254
+ const entryRegex = new RegExp(`## \\[${escapedLearningId}\\][\\s\\S]*?(?=\\n## \\[|$)`, "m");
255
+ const match = learningBody.match(entryRegex);
256
+ if (!match) {
257
+ return {
258
+ content: [{ type: "text", text: `Learning entry ${learningId} not found in .learnings/${sourceFile}` }],
259
+ details: { error: "learning_not_found", learningId, sourceFile },
260
+ };
261
+ }
262
+
263
+ const summaryMatch = match[0].match(/### Summary\n([\s\S]*?)\n###/m);
264
+ const summary = (summaryMatch?.[1] ?? "Summarize the source learning here.").trim();
265
+ const safeOutputDir = outputDir
266
+ .replace(/\\/g, "/")
267
+ .split("/")
268
+ .filter((segment) => segment && segment !== "." && segment !== "..")
269
+ .join("/");
270
+ const skillDir = join(workspaceDir, safeOutputDir || "skills", skillName);
271
+ await mkdir(skillDir, { recursive: true });
272
+ const skillPath = join(skillDir, "SKILL.md");
273
+ const skillTitle = skillName
274
+ .split("-")
275
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
276
+ .join(" ");
277
+ const skillContent = [
278
+ "---",
279
+ `name: ${skillName}`,
280
+ `description: "Extracted from learning ${learningId}. Replace with a concise description."`,
281
+ "---",
282
+ "",
283
+ `# ${skillTitle}`,
284
+ "",
285
+ "## Why",
286
+ summary,
287
+ "",
288
+ "## When To Use",
289
+ "- [TODO] Define trigger conditions",
290
+ "",
291
+ "## Steps",
292
+ "1. [TODO] Add repeatable workflow steps",
293
+ "2. [TODO] Add verification steps",
294
+ "",
295
+ "## Source Learning",
296
+ `- Learning ID: ${learningId}`,
297
+ `- Source File: .learnings/${sourceFile}`,
298
+ "",
299
+ ].join("\n");
300
+ await writeFile(skillPath, skillContent, "utf-8");
301
+
302
+ const promotedMarker = `**Status**: promoted_to_skill`;
303
+ const skillPathMarker = `- Skill-Path: ${safeOutputDir || "skills"}/${skillName}`;
304
+ let updatedEntry = match[0];
305
+ updatedEntry = updatedEntry.includes("**Status**:")
306
+ ? updatedEntry.replace(/\*\*Status\*\*:\s*.+/m, promotedMarker)
307
+ : `${updatedEntry.trimEnd()}\n${promotedMarker}\n`;
308
+ if (!updatedEntry.includes("Skill-Path:")) {
309
+ updatedEntry = `${updatedEntry.trimEnd()}\n${skillPathMarker}\n`;
310
+ }
311
+ const updatedLearningBody = learningBody.replace(match[0], updatedEntry);
312
+ await writeFile(learningsPath, updatedLearningBody, "utf-8");
313
+
314
+ return {
315
+ content: [{ type: "text", text: `Extracted skill scaffold to ${safeOutputDir || "skills"}/${skillName}/SKILL.md and updated ${learningId}.` }],
316
+ details: {
317
+ action: "skill_extracted",
318
+ learningId,
319
+ sourceFile,
320
+ skillPath: `${safeOutputDir || "skills"}/${skillName}/SKILL.md`,
321
+ },
322
+ };
323
+ } catch (error) {
324
+ return {
325
+ content: [{ type: "text", text: `Failed to extract skill: ${error instanceof Error ? error.message : String(error)}` }],
326
+ details: { error: "self_improvement_extract_skill_failed", message: String(error) },
327
+ };
328
+ }
329
+ },
330
+ }),
331
+ { name: "self_improvement_extract_skill" }
332
+ );
333
+ }
334
+
335
+ export function registerSelfImprovementReviewTool(api: OpenClawPluginApi, context: ToolContext) {
336
+ api.registerTool(
337
+ (toolCtx) => ({
338
+ name: "self_improvement_review",
339
+ label: "Self-Improvement Review",
340
+ description: "Summarize governance backlog from .learnings files (pending/high-priority/promoted counts).",
341
+ parameters: Type.Object({}),
342
+ async execute() {
343
+ try {
344
+ const workspaceDir = resolveWorkspaceDir(toolCtx, context.workspaceDir);
345
+ await ensureSelfImprovementLearningFiles(workspaceDir);
346
+ const learningsDir = join(workspaceDir, ".learnings");
347
+ const files = ["LEARNINGS.md", "ERRORS.md"] as const;
348
+ const stats = { pending: 0, high: 0, promoted: 0, total: 0 };
349
+
350
+ for (const f of files) {
351
+ const content = await readFile(join(learningsDir, f), "utf-8").catch(() => "");
352
+ stats.total += (content.match(/^## \[/gm) || []).length;
353
+ stats.pending += (content.match(/\*\*Status\*\*:\s*pending/gi) || []).length;
354
+ stats.high += (content.match(/\*\*Priority\*\*:\s*(high|critical)/gi) || []).length;
355
+ stats.promoted += (content.match(/\*\*Status\*\*:\s*promoted(_to_skill)?/gi) || []).length;
356
+ }
357
+
358
+ const text = [
359
+ "Self-Improvement Governance Snapshot:",
360
+ `- Total entries: ${stats.total}`,
361
+ `- Pending: ${stats.pending}`,
362
+ `- High/Critical: ${stats.high}`,
363
+ `- Promoted: ${stats.promoted}`,
364
+ "",
365
+ "Recommended loop:",
366
+ "1) Resolve high-priority pending entries",
367
+ "2) Distill reusable rules into AGENTS.md / SOUL.md / TOOLS.md",
368
+ "3) Extract repeatable patterns as skills",
369
+ ].join("\n");
370
+
371
+ return {
372
+ content: [{ type: "text", text }],
373
+ details: { action: "review", stats },
374
+ };
375
+ } catch (error) {
376
+ return {
377
+ content: [{ type: "text", text: `Failed to review self-improvement backlog: ${error instanceof Error ? error.message : String(error)}` }],
378
+ details: { error: "self_improvement_review_failed", message: String(error) },
379
+ };
380
+ }
381
+ },
382
+ }),
383
+ { name: "self_improvement_review" }
384
+ );
385
+ }
386
+
387
+ // ============================================================================
388
+ // Core Tools (Backward Compatible)
389
+ // ============================================================================
390
+
391
+ export function registerMemoryRecallTool(
392
+ api: OpenClawPluginApi,
393
+ context: ToolContext,
394
+ ) {
395
+ api.registerTool(
396
+ (toolCtx) => {
397
+ const runtimeContext = resolveToolContext(context, toolCtx);
398
+ return {
399
+ name: "memory_recall",
400
+ label: "Memory Recall",
401
+ description:
402
+ "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.",
403
+ parameters: Type.Object({
404
+ query: Type.String({
405
+ description: "Search query for finding relevant memories",
406
+ }),
407
+ limit: Type.Optional(
408
+ Type.Number({
409
+ description: "Max results to return (default: 5, max: 20)",
410
+ }),
411
+ ),
412
+ scope: Type.Optional(
413
+ Type.String({
414
+ description: "Specific memory scope to search in (optional)",
415
+ }),
416
+ ),
417
+ category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
418
+ }),
419
+ async execute(_toolCallId, params) {
420
+ const {
421
+ query,
422
+ limit = 5,
423
+ scope,
424
+ category,
425
+ } = params as {
426
+ query: string;
427
+ limit?: number;
428
+ scope?: string;
429
+ category?: string;
430
+ };
431
+
432
+ try {
433
+ const safeLimit = clampInt(limit, 1, 20);
434
+ const agentId = runtimeContext.agentId;
435
+
436
+ // Determine accessible scopes
437
+ let scopeFilter = runtimeContext.scopeManager.getAccessibleScopes(agentId);
438
+ if (scope) {
439
+ if (runtimeContext.scopeManager.isAccessible(scope, agentId)) {
440
+ scopeFilter = [scope];
441
+ } else {
442
+ return {
443
+ content: [
444
+ { type: "text", text: `Access denied to scope: ${scope}` },
445
+ ],
446
+ details: {
447
+ error: "scope_access_denied",
448
+ requestedScope: scope,
449
+ },
450
+ };
451
+ }
452
+ }
453
+
454
+ const results = await retrieveWithRetry(runtimeContext.retriever, {
455
+ query,
456
+ limit: safeLimit,
457
+ scopeFilter,
458
+ category,
459
+ source: "manual",
460
+ });
461
+
462
+ if (results.length === 0) {
463
+ return {
464
+ content: [{ type: "text", text: "No relevant memories found." }],
465
+ details: { count: 0, query, scopes: scopeFilter },
466
+ };
467
+ }
468
+
469
+ const now = Date.now();
470
+ await Promise.allSettled(
471
+ results.map((result) => {
472
+ const meta = parseSmartMetadata(result.entry.metadata, result.entry);
473
+ return runtimeContext.store.patchMetadata(
474
+ result.entry.id,
475
+ {
476
+ access_count: meta.access_count + 1,
477
+ last_accessed_at: now,
478
+ },
479
+ scopeFilter,
480
+ );
481
+ }),
482
+ );
483
+
484
+ const text = results
485
+ .map((r, i) => {
486
+ const categoryTag = getDisplayCategoryTag(r.entry);
487
+ return `${i + 1}. [${r.entry.id}] [${categoryTag}] ${r.entry.text}`;
488
+ })
489
+ .join("\n");
490
+
491
+ return {
492
+ content: [
493
+ {
494
+ type: "text",
495
+ text: `Found ${results.length} memories:\n\n${text}`,
496
+ },
497
+ ],
498
+ details: {
499
+ count: results.length,
500
+ memories: sanitizeMemoryForSerialization(results),
501
+ query,
502
+ scopes: scopeFilter,
503
+ retrievalMode: runtimeContext.retriever.getConfig().mode,
504
+ },
505
+ };
506
+ } catch (error) {
507
+ return {
508
+ content: [
509
+ {
510
+ type: "text",
511
+ text: `Memory recall failed: ${error instanceof Error ? error.message : String(error)}`,
512
+ },
513
+ ],
514
+ details: { error: "recall_failed", message: String(error) },
515
+ };
516
+ }
517
+ },
518
+ };
519
+ },
520
+ { name: "memory_recall" },
521
+ );
522
+ }
523
+
524
+ export function registerMemoryStoreTool(
525
+ api: OpenClawPluginApi,
526
+ context: ToolContext,
527
+ ) {
528
+ api.registerTool(
529
+ (toolCtx) => {
530
+ const runtimeContext = resolveToolContext(context, toolCtx);
531
+ return {
532
+ name: "memory_store",
533
+ label: "Memory Store",
534
+ description:
535
+ "Save important information in long-term memory. Use for preferences, facts, decisions, and other notable information.",
536
+ parameters: Type.Object({
537
+ text: Type.String({ description: "Information to remember" }),
538
+ importance: Type.Optional(
539
+ Type.Number({ description: "Importance score 0-1 (default: 0.7)" }),
540
+ ),
541
+ category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
542
+ scope: Type.Optional(
543
+ Type.String({
544
+ description: "Memory scope (optional, defaults to agent scope)",
545
+ }),
546
+ ),
547
+ }),
548
+ async execute(_toolCallId, params) {
549
+ const {
550
+ text,
551
+ importance = 0.7,
552
+ category = "other",
553
+ scope,
554
+ } = params as {
555
+ text: string;
556
+ importance?: number;
557
+ category?: string;
558
+ scope?: string;
559
+ };
560
+
561
+ try {
562
+ const agentId = runtimeContext.agentId;
563
+ // Determine target scope
564
+ let targetScope = scope || runtimeContext.scopeManager.getDefaultScope(agentId);
565
+
566
+ // Validate scope access
567
+ if (!runtimeContext.scopeManager.isAccessible(targetScope, agentId)) {
568
+ return {
569
+ content: [
570
+ {
571
+ type: "text",
572
+ text: `Access denied to scope: ${targetScope}`,
573
+ },
574
+ ],
575
+ details: {
576
+ error: "scope_access_denied",
577
+ requestedScope: targetScope,
578
+ },
579
+ };
580
+ }
581
+
582
+ // Reject noise before wasting an embedding API call
583
+ if (isNoise(text)) {
584
+ return {
585
+ content: [
586
+ {
587
+ type: "text",
588
+ text: `Skipped: text detected as noise (greeting, boilerplate, or meta-question)`,
589
+ },
590
+ ],
591
+ details: { action: "noise_filtered", text: text.slice(0, 60) },
592
+ };
593
+ }
594
+
595
+ const safeImportance = clamp01(importance, 0.7);
596
+ const vector = await runtimeContext.embedder.embedPassage(text);
597
+
598
+ // Check for duplicates using raw vector similarity (bypasses importance/recency weighting)
599
+ // Fail-open by design: dedup must never block a legitimate memory write.
600
+ let existing: Awaited<ReturnType<MemoryStore["vectorSearch"]>> = [];
601
+ try {
602
+ existing = await runtimeContext.store.vectorSearch(vector, 1, 0.1, [
603
+ targetScope,
604
+ ]);
605
+ } catch (err) {
606
+ console.warn(
607
+ `mnemo: duplicate pre-check failed, continue store: ${String(err)}`,
608
+ );
609
+ }
610
+
611
+ if (existing.length > 0 && existing[0].score > 0.98) {
612
+ return {
613
+ content: [
614
+ {
615
+ type: "text",
616
+ text: `Similar memory already exists: "${existing[0].entry.text}"`,
617
+ },
618
+ ],
619
+ details: {
620
+ action: "duplicate",
621
+ existingId: existing[0].entry.id,
622
+ existingText: existing[0].entry.text,
623
+ existingScope: existing[0].entry.scope,
624
+ similarity: existing[0].score,
625
+ },
626
+ };
627
+ }
628
+
629
+ const entry = await runtimeContext.store.store({
630
+ text,
631
+ vector,
632
+ importance: safeImportance,
633
+ category: category as any,
634
+ scope: targetScope,
635
+ metadata: stringifySmartMetadata(
636
+ buildSmartMetadata(
637
+ {
638
+ text,
639
+ category: category as any,
640
+ importance: safeImportance,
641
+ },
642
+ {
643
+ l0_abstract: text,
644
+ l1_overview: `- ${text}`,
645
+ l2_content: text,
646
+ },
647
+ ),
648
+ ),
649
+ });
650
+
651
+ // Dual-write to Markdown mirror if enabled
652
+ if (context.mdMirror) {
653
+ await context.mdMirror(
654
+ { text, category: category as string, scope: targetScope, timestamp: entry.timestamp },
655
+ { source: "memory_store", agentId },
656
+ );
657
+ }
658
+
659
+ return {
660
+ content: [
661
+ {
662
+ type: "text",
663
+ text: `Stored: "${text.slice(0, 100)}${text.length > 100 ? "..." : ""}" in scope '${targetScope}'`,
664
+ },
665
+ ],
666
+ details: {
667
+ action: "created",
668
+ id: entry.id,
669
+ scope: entry.scope,
670
+ category: entry.category,
671
+ importance: entry.importance,
672
+ },
673
+ };
674
+ } catch (error) {
675
+ return {
676
+ content: [
677
+ {
678
+ type: "text",
679
+ text: `Memory storage failed: ${error instanceof Error ? error.message : String(error)}`,
680
+ },
681
+ ],
682
+ details: { error: "store_failed", message: String(error) },
683
+ };
684
+ }
685
+ },
686
+ };
687
+ },
688
+ { name: "memory_store" },
689
+ );
690
+ }
691
+
692
+ export function registerMemoryForgetTool(
693
+ api: OpenClawPluginApi,
694
+ context: ToolContext,
695
+ ) {
696
+ api.registerTool(
697
+ (toolCtx) => {
698
+ const agentId = resolveAgentId((toolCtx as any)?.agentId, context.agentId) ?? "main";
699
+ return {
700
+ name: "memory_forget",
701
+ label: "Memory Forget",
702
+ description:
703
+ "Delete specific memories. Supports both search-based and direct ID-based deletion.",
704
+ parameters: Type.Object({
705
+ query: Type.Optional(
706
+ Type.String({ description: "Search query to find memory to delete" }),
707
+ ),
708
+ memoryId: Type.Optional(
709
+ Type.String({ description: "Specific memory ID to delete" }),
710
+ ),
711
+ scope: Type.Optional(
712
+ Type.String({
713
+ description: "Scope to search/delete from (optional)",
714
+ }),
715
+ ),
716
+ }),
717
+ async execute(_toolCallId, params, _signal, _onUpdate, runtimeCtx) {
718
+ const { query, memoryId, scope } = params as {
719
+ query?: string;
720
+ memoryId?: string;
721
+ scope?: string;
722
+ };
723
+
724
+ try {
725
+ const agentId = resolveRuntimeAgentId(context.agentId, runtimeCtx);
726
+ // Determine accessible scopes
727
+ let scopeFilter = context.scopeManager.getAccessibleScopes(agentId);
728
+ if (scope) {
729
+ if (context.scopeManager.isAccessible(scope, agentId)) {
730
+ scopeFilter = [scope];
731
+ } else {
732
+ return {
733
+ content: [
734
+ { type: "text", text: `Access denied to scope: ${scope}` },
735
+ ],
736
+ details: {
737
+ error: "scope_access_denied",
738
+ requestedScope: scope,
739
+ },
740
+ };
741
+ }
742
+ }
743
+
744
+ if (memoryId) {
745
+ const deleted = await context.store.delete(memoryId, scopeFilter);
746
+ if (deleted) {
747
+ return {
748
+ content: [
749
+ { type: "text", text: `Memory ${memoryId} forgotten.` },
750
+ ],
751
+ details: { action: "deleted", id: memoryId },
752
+ };
753
+ } else {
754
+ return {
755
+ content: [
756
+ {
757
+ type: "text",
758
+ text: `Memory ${memoryId} not found or access denied.`,
759
+ },
760
+ ],
761
+ details: { error: "not_found", id: memoryId },
762
+ };
763
+ }
764
+ }
765
+
766
+ if (query) {
767
+ const results = await retrieveWithRetry(context.retriever, {
768
+ query,
769
+ limit: 5,
770
+ scopeFilter,
771
+ });
772
+
773
+ if (results.length === 0) {
774
+ return {
775
+ content: [
776
+ { type: "text", text: "No matching memories found." },
777
+ ],
778
+ details: { found: 0, query },
779
+ };
780
+ }
781
+
782
+ if (results.length === 1 && results[0].score > 0.9) {
783
+ const deleted = await context.store.delete(
784
+ results[0].entry.id,
785
+ scopeFilter,
786
+ );
787
+ if (deleted) {
788
+ return {
789
+ content: [
790
+ {
791
+ type: "text",
792
+ text: `Forgotten: "${results[0].entry.text}"`,
793
+ },
794
+ ],
795
+ details: { action: "deleted", id: results[0].entry.id },
796
+ };
797
+ }
798
+ }
799
+
800
+ const list = results
801
+ .map(
802
+ (r) =>
803
+ `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}${r.entry.text.length > 60 ? "..." : ""}`,
804
+ )
805
+ .join("\n");
806
+
807
+ return {
808
+ content: [
809
+ {
810
+ type: "text",
811
+ text: `Found ${results.length} candidates. Specify memoryId to delete:\n${list}`,
812
+ },
813
+ ],
814
+ details: {
815
+ action: "candidates",
816
+ candidates: sanitizeMemoryForSerialization(results),
817
+ },
818
+ };
819
+ }
820
+
821
+ return {
822
+ content: [
823
+ {
824
+ type: "text",
825
+ text: "Provide either 'query' to search for memories or 'memoryId' to delete specific memory.",
826
+ },
827
+ ],
828
+ details: { error: "missing_param" },
829
+ };
830
+ } catch (error) {
831
+ return {
832
+ content: [
833
+ {
834
+ type: "text",
835
+ text: `Memory deletion failed: ${error instanceof Error ? error.message : String(error)}`,
836
+ },
837
+ ],
838
+ details: { error: "delete_failed", message: String(error) },
839
+ };
840
+ }
841
+ },
842
+ };
843
+ },
844
+ { name: "memory_forget" },
845
+ );
846
+ }
847
+
848
+ // ============================================================================
849
+ // Update Tool
850
+ // ============================================================================
851
+
852
+ export function registerMemoryUpdateTool(
853
+ api: OpenClawPluginApi,
854
+ context: ToolContext,
855
+ ) {
856
+ api.registerTool(
857
+ (toolCtx) => {
858
+ const agentId = resolveAgentId((toolCtx as any)?.agentId, context.agentId) ?? "main";
859
+ return {
860
+ name: "memory_update",
861
+ label: "Memory Update",
862
+ description:
863
+ "Update an existing memory in-place. Preserves original timestamp. Use when correcting outdated info or adjusting importance/category without losing creation date.",
864
+ parameters: Type.Object({
865
+ memoryId: Type.String({
866
+ description:
867
+ "ID of the memory to update (full UUID or 8+ char prefix)",
868
+ }),
869
+ text: Type.Optional(
870
+ Type.String({
871
+ description: "New text content (triggers re-embedding)",
872
+ }),
873
+ ),
874
+ importance: Type.Optional(
875
+ Type.Number({ description: "New importance score 0-1" }),
876
+ ),
877
+ category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
878
+ }),
879
+ async execute(_toolCallId, params, _signal, _onUpdate, runtimeCtx) {
880
+ const { memoryId, text, importance, category } = params as {
881
+ memoryId: string;
882
+ text?: string;
883
+ importance?: number;
884
+ category?: string;
885
+ };
886
+
887
+ try {
888
+ if (!text && importance === undefined && !category) {
889
+ return {
890
+ content: [
891
+ {
892
+ type: "text",
893
+ text: "Nothing to update. Provide at least one of: text, importance, category.",
894
+ },
895
+ ],
896
+ details: { error: "no_updates" },
897
+ };
898
+ }
899
+
900
+ // Determine accessible scopes
901
+ const agentId = resolveRuntimeAgentId(context.agentId, runtimeCtx);
902
+ const scopeFilter = context.scopeManager.getAccessibleScopes(agentId);
903
+
904
+ // Resolve memoryId: if it doesn't look like a UUID, try search
905
+ let resolvedId = memoryId;
906
+ const uuidLike = /^[0-9a-f]{8}(-[0-9a-f]{4}){0,4}/i.test(memoryId);
907
+ if (!uuidLike) {
908
+ // Treat as search query
909
+ const results = await retrieveWithRetry(context.retriever, {
910
+ query: memoryId,
911
+ limit: 3,
912
+ scopeFilter,
913
+ });
914
+ if (results.length === 0) {
915
+ return {
916
+ content: [
917
+ {
918
+ type: "text",
919
+ text: `No memory found matching "${memoryId}".`,
920
+ },
921
+ ],
922
+ details: { error: "not_found", query: memoryId },
923
+ };
924
+ }
925
+ if (results.length === 1 || results[0].score > 0.85) {
926
+ resolvedId = results[0].entry.id;
927
+ } else {
928
+ const list = results
929
+ .map(
930
+ (r) =>
931
+ `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}${r.entry.text.length > 60 ? "..." : ""}`,
932
+ )
933
+ .join("\n");
934
+ return {
935
+ content: [
936
+ {
937
+ type: "text",
938
+ text: `Multiple matches. Specify memoryId:\n${list}`,
939
+ },
940
+ ],
941
+ details: {
942
+ action: "candidates",
943
+ candidates: sanitizeMemoryForSerialization(results),
944
+ },
945
+ };
946
+ }
947
+ }
948
+
949
+ // If text changed, re-embed; reject noise
950
+ let newVector: number[] | undefined;
951
+ if (text) {
952
+ if (isNoise(text)) {
953
+ return {
954
+ content: [
955
+ {
956
+ type: "text",
957
+ text: "Skipped: updated text detected as noise",
958
+ },
959
+ ],
960
+ details: { action: "noise_filtered" },
961
+ };
962
+ }
963
+ newVector = await context.embedder.embedPassage(text);
964
+ }
965
+
966
+ const updates: Record<string, any> = {};
967
+ if (text) updates.text = text;
968
+ if (newVector) updates.vector = newVector;
969
+ if (importance !== undefined)
970
+ updates.importance = clamp01(importance, 0.7);
971
+ if (category) updates.category = category;
972
+
973
+ const updated = await context.store.update(
974
+ resolvedId,
975
+ updates,
976
+ scopeFilter,
977
+ );
978
+
979
+ if (!updated) {
980
+ return {
981
+ content: [
982
+ {
983
+ type: "text",
984
+ text: `Memory ${resolvedId.slice(0, 8)}... not found or access denied.`,
985
+ },
986
+ ],
987
+ details: { error: "not_found", id: resolvedId },
988
+ };
989
+ }
990
+
991
+ return {
992
+ content: [
993
+ {
994
+ type: "text",
995
+ text: `Updated memory ${updated.id.slice(0, 8)}...: "${updated.text.slice(0, 80)}${updated.text.length > 80 ? "..." : ""}"`,
996
+ },
997
+ ],
998
+ details: {
999
+ action: "updated",
1000
+ id: updated.id,
1001
+ scope: updated.scope,
1002
+ category: updated.category,
1003
+ importance: updated.importance,
1004
+ fieldsUpdated: Object.keys(updates),
1005
+ },
1006
+ };
1007
+ } catch (error) {
1008
+ return {
1009
+ content: [
1010
+ {
1011
+ type: "text",
1012
+ text: `Memory update failed: ${error instanceof Error ? error.message : String(error)}`,
1013
+ },
1014
+ ],
1015
+ details: { error: "update_failed", message: String(error) },
1016
+ };
1017
+ }
1018
+ },
1019
+ };
1020
+ },
1021
+ { name: "memory_update" },
1022
+ );
1023
+ }
1024
+
1025
+ // ============================================================================
1026
+ // Management Tools (Optional)
1027
+ // ============================================================================
1028
+
1029
+ export function registerMemoryStatsTool(
1030
+ api: OpenClawPluginApi,
1031
+ context: ToolContext,
1032
+ ) {
1033
+ api.registerTool(
1034
+ (toolCtx) => {
1035
+ const agentId = resolveAgentId((toolCtx as any)?.agentId, context.agentId) ?? "main";
1036
+ return {
1037
+ name: "memory_stats",
1038
+ label: "Memory Statistics",
1039
+ description: "Get statistics about memory usage, scopes, and categories.",
1040
+ parameters: Type.Object({
1041
+ scope: Type.Optional(
1042
+ Type.String({
1043
+ description: "Specific scope to get stats for (optional)",
1044
+ }),
1045
+ ),
1046
+ }),
1047
+ async execute(_toolCallId, params, _signal, _onUpdate, runtimeCtx) {
1048
+ const { scope } = params as { scope?: string };
1049
+
1050
+ try {
1051
+ const agentId = resolveRuntimeAgentId(context.agentId, runtimeCtx);
1052
+ // Determine accessible scopes
1053
+ let scopeFilter = context.scopeManager.getAccessibleScopes(agentId);
1054
+ if (scope) {
1055
+ if (context.scopeManager.isAccessible(scope, agentId)) {
1056
+ scopeFilter = [scope];
1057
+ } else {
1058
+ return {
1059
+ content: [
1060
+ { type: "text", text: `Access denied to scope: ${scope}` },
1061
+ ],
1062
+ details: {
1063
+ error: "scope_access_denied",
1064
+ requestedScope: scope,
1065
+ },
1066
+ };
1067
+ }
1068
+ }
1069
+
1070
+ const stats = await context.store.stats(scopeFilter);
1071
+ const scopeManagerStats = context.scopeManager.getStats();
1072
+ const retrievalConfig = context.retriever.getConfig();
1073
+
1074
+ const text = [
1075
+ `Memory Statistics:`,
1076
+ `• Total memories: ${stats.totalCount}`,
1077
+ `• Available scopes: ${scopeManagerStats.totalScopes}`,
1078
+ `• Retrieval mode: ${retrievalConfig.mode}`,
1079
+ `• FTS support: ${context.store.hasFtsSupport ? "Yes" : "No"}`,
1080
+ ``,
1081
+ `Memories by scope:`,
1082
+ ...Object.entries(stats.scopeCounts).map(
1083
+ ([s, count]) => ` • ${s}: ${count}`,
1084
+ ),
1085
+ ``,
1086
+ `Memories by category:`,
1087
+ ...Object.entries(stats.categoryCounts).map(
1088
+ ([c, count]) => ` • ${c}: ${count}`,
1089
+ ),
1090
+ ].join("\n");
1091
+
1092
+ return {
1093
+ content: [{ type: "text", text }],
1094
+ details: {
1095
+ stats,
1096
+ scopeManagerStats,
1097
+ retrievalConfig: {
1098
+ ...retrievalConfig,
1099
+ rerankApiKey: retrievalConfig.rerankApiKey ? "***" : undefined,
1100
+ },
1101
+ hasFtsSupport: context.store.hasFtsSupport,
1102
+ },
1103
+ };
1104
+ } catch (error) {
1105
+ return {
1106
+ content: [
1107
+ {
1108
+ type: "text",
1109
+ text: `Failed to get memory stats: ${error instanceof Error ? error.message : String(error)}`,
1110
+ },
1111
+ ],
1112
+ details: { error: "stats_failed", message: String(error) },
1113
+ };
1114
+ }
1115
+ },
1116
+ };
1117
+ },
1118
+ { name: "memory_stats" },
1119
+ );
1120
+ }
1121
+
1122
+ export function registerMemoryListTool(
1123
+ api: OpenClawPluginApi,
1124
+ context: ToolContext,
1125
+ ) {
1126
+ api.registerTool(
1127
+ (toolCtx) => {
1128
+ const agentId = resolveAgentId((toolCtx as any)?.agentId, context.agentId) ?? "main";
1129
+ return {
1130
+ name: "memory_list",
1131
+ label: "Memory List",
1132
+ description:
1133
+ "List recent memories with optional filtering by scope and category.",
1134
+ parameters: Type.Object({
1135
+ limit: Type.Optional(
1136
+ Type.Number({
1137
+ description: "Max memories to list (default: 10, max: 50)",
1138
+ }),
1139
+ ),
1140
+ scope: Type.Optional(
1141
+ Type.String({ description: "Filter by specific scope (optional)" }),
1142
+ ),
1143
+ category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
1144
+ offset: Type.Optional(
1145
+ Type.Number({
1146
+ description: "Number of memories to skip (default: 0)",
1147
+ }),
1148
+ ),
1149
+ }),
1150
+ async execute(_toolCallId, params, _signal, _onUpdate, runtimeCtx) {
1151
+ const {
1152
+ limit = 10,
1153
+ scope,
1154
+ category,
1155
+ offset = 0,
1156
+ } = params as {
1157
+ limit?: number;
1158
+ scope?: string;
1159
+ category?: string;
1160
+ offset?: number;
1161
+ };
1162
+
1163
+ try {
1164
+ const safeLimit = clampInt(limit, 1, 50);
1165
+ const safeOffset = clampInt(offset, 0, 1000);
1166
+ const agentId = resolveRuntimeAgentId(context.agentId, runtimeCtx);
1167
+
1168
+ // Determine accessible scopes
1169
+ let scopeFilter = context.scopeManager.getAccessibleScopes(agentId);
1170
+ if (scope) {
1171
+ if (context.scopeManager.isAccessible(scope, agentId)) {
1172
+ scopeFilter = [scope];
1173
+ } else {
1174
+ return {
1175
+ content: [
1176
+ { type: "text", text: `Access denied to scope: ${scope}` },
1177
+ ],
1178
+ details: {
1179
+ error: "scope_access_denied",
1180
+ requestedScope: scope,
1181
+ },
1182
+ };
1183
+ }
1184
+ }
1185
+
1186
+ const entries = await context.store.list(
1187
+ scopeFilter,
1188
+ category,
1189
+ safeLimit,
1190
+ safeOffset,
1191
+ );
1192
+
1193
+ if (entries.length === 0) {
1194
+ return {
1195
+ content: [{ type: "text", text: "No memories found." }],
1196
+ details: {
1197
+ count: 0,
1198
+ filters: {
1199
+ scope,
1200
+ category,
1201
+ limit: safeLimit,
1202
+ offset: safeOffset,
1203
+ },
1204
+ },
1205
+ };
1206
+ }
1207
+
1208
+ const text = entries
1209
+ .map((entry, i) => {
1210
+ const date = new Date(entry.timestamp)
1211
+ .toISOString()
1212
+ .split("T")[0];
1213
+ const categoryTag = getDisplayCategoryTag(entry);
1214
+ return `${safeOffset + i + 1}. [${entry.id}] [${categoryTag}] ${entry.text.slice(0, 100)}${entry.text.length > 100 ? "..." : ""} (${date})`;
1215
+ })
1216
+ .join("\n");
1217
+
1218
+ return {
1219
+ content: [
1220
+ {
1221
+ type: "text",
1222
+ text: `Recent memories (showing ${entries.length}):\n\n${text}`,
1223
+ },
1224
+ ],
1225
+ details: {
1226
+ count: entries.length,
1227
+ memories: entries.map((e) => ({
1228
+ id: e.id,
1229
+ text: e.text,
1230
+ category: getDisplayCategoryTag(e),
1231
+ rawCategory: e.category,
1232
+ scope: e.scope,
1233
+ importance: e.importance,
1234
+ timestamp: e.timestamp,
1235
+ })),
1236
+ filters: {
1237
+ scope,
1238
+ category,
1239
+ limit: safeLimit,
1240
+ offset: safeOffset,
1241
+ },
1242
+ },
1243
+ };
1244
+ } catch (error) {
1245
+ return {
1246
+ content: [
1247
+ {
1248
+ type: "text",
1249
+ text: `Failed to list memories: ${error instanceof Error ? error.message : String(error)}`,
1250
+ },
1251
+ ],
1252
+ details: { error: "list_failed", message: String(error) },
1253
+ };
1254
+ }
1255
+ },
1256
+ };
1257
+ },
1258
+ { name: "memory_list" },
1259
+ );
1260
+ }
1261
+
1262
+ // ============================================================================
1263
+ // Tool Registration Helper
1264
+ // ============================================================================
1265
+
1266
+ export function registerAllMemoryTools(
1267
+ api: OpenClawPluginApi,
1268
+ context: ToolContext,
1269
+ options: {
1270
+ enableManagementTools?: boolean;
1271
+ enableSelfImprovementTools?: boolean;
1272
+ } = {},
1273
+ ) {
1274
+ // Core tools (always enabled)
1275
+ registerMemoryRecallTool(api, context);
1276
+ registerMemoryStoreTool(api, context);
1277
+ registerMemoryForgetTool(api, context);
1278
+ registerMemoryUpdateTool(api, context);
1279
+
1280
+ // Management tools (optional)
1281
+ if (options.enableManagementTools) {
1282
+ registerMemoryStatsTool(api, context);
1283
+ registerMemoryListTool(api, context);
1284
+ }
1285
+ if (options.enableSelfImprovementTools !== false) {
1286
+ registerSelfImprovementLogTool(api, context);
1287
+ if (options.enableManagementTools) {
1288
+ registerSelfImprovementExtractSkillTool(api, context);
1289
+ registerSelfImprovementReviewTool(api, context);
1290
+ }
1291
+ }
1292
+ }