@memtensor/memos-local-openclaw-plugin 0.1.3 → 0.1.5

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 (117) hide show
  1. package/.env.example +13 -5
  2. package/README.md +283 -91
  3. package/dist/capture/index.d.ts +5 -7
  4. package/dist/capture/index.d.ts.map +1 -1
  5. package/dist/capture/index.js +72 -43
  6. package/dist/capture/index.js.map +1 -1
  7. package/dist/ingest/dedup.d.ts +8 -0
  8. package/dist/ingest/dedup.d.ts.map +1 -1
  9. package/dist/ingest/dedup.js +21 -0
  10. package/dist/ingest/dedup.js.map +1 -1
  11. package/dist/ingest/providers/anthropic.d.ts +16 -0
  12. package/dist/ingest/providers/anthropic.d.ts.map +1 -1
  13. package/dist/ingest/providers/anthropic.js +214 -1
  14. package/dist/ingest/providers/anthropic.js.map +1 -1
  15. package/dist/ingest/providers/bedrock.d.ts +16 -5
  16. package/dist/ingest/providers/bedrock.d.ts.map +1 -1
  17. package/dist/ingest/providers/bedrock.js +210 -6
  18. package/dist/ingest/providers/bedrock.js.map +1 -1
  19. package/dist/ingest/providers/gemini.d.ts +16 -0
  20. package/dist/ingest/providers/gemini.d.ts.map +1 -1
  21. package/dist/ingest/providers/gemini.js +202 -1
  22. package/dist/ingest/providers/gemini.js.map +1 -1
  23. package/dist/ingest/providers/index.d.ts +31 -0
  24. package/dist/ingest/providers/index.d.ts.map +1 -1
  25. package/dist/ingest/providers/index.js +134 -4
  26. package/dist/ingest/providers/index.js.map +1 -1
  27. package/dist/ingest/providers/openai.d.ts +24 -0
  28. package/dist/ingest/providers/openai.d.ts.map +1 -1
  29. package/dist/ingest/providers/openai.js +255 -1
  30. package/dist/ingest/providers/openai.js.map +1 -1
  31. package/dist/ingest/task-processor.d.ts +65 -0
  32. package/dist/ingest/task-processor.d.ts.map +1 -0
  33. package/dist/ingest/task-processor.js +354 -0
  34. package/dist/ingest/task-processor.js.map +1 -0
  35. package/dist/ingest/worker.d.ts +3 -1
  36. package/dist/ingest/worker.d.ts.map +1 -1
  37. package/dist/ingest/worker.js +131 -23
  38. package/dist/ingest/worker.js.map +1 -1
  39. package/dist/recall/engine.d.ts +1 -0
  40. package/dist/recall/engine.d.ts.map +1 -1
  41. package/dist/recall/engine.js +22 -11
  42. package/dist/recall/engine.js.map +1 -1
  43. package/dist/recall/mmr.d.ts.map +1 -1
  44. package/dist/recall/mmr.js +3 -1
  45. package/dist/recall/mmr.js.map +1 -1
  46. package/dist/skill/bundled-memory-guide.d.ts +6 -0
  47. package/dist/skill/bundled-memory-guide.d.ts.map +1 -0
  48. package/dist/skill/bundled-memory-guide.js +95 -0
  49. package/dist/skill/bundled-memory-guide.js.map +1 -0
  50. package/dist/skill/evaluator.d.ts +31 -0
  51. package/dist/skill/evaluator.d.ts.map +1 -0
  52. package/dist/skill/evaluator.js +194 -0
  53. package/dist/skill/evaluator.js.map +1 -0
  54. package/dist/skill/evolver.d.ts +22 -0
  55. package/dist/skill/evolver.d.ts.map +1 -0
  56. package/dist/skill/evolver.js +193 -0
  57. package/dist/skill/evolver.js.map +1 -0
  58. package/dist/skill/generator.d.ts +25 -0
  59. package/dist/skill/generator.d.ts.map +1 -0
  60. package/dist/skill/generator.js +477 -0
  61. package/dist/skill/generator.js.map +1 -0
  62. package/dist/skill/installer.d.ts +16 -0
  63. package/dist/skill/installer.d.ts.map +1 -0
  64. package/dist/skill/installer.js +89 -0
  65. package/dist/skill/installer.js.map +1 -0
  66. package/dist/skill/upgrader.d.ts +19 -0
  67. package/dist/skill/upgrader.d.ts.map +1 -0
  68. package/dist/skill/upgrader.js +263 -0
  69. package/dist/skill/upgrader.js.map +1 -0
  70. package/dist/skill/validator.d.ts +29 -0
  71. package/dist/skill/validator.d.ts.map +1 -0
  72. package/dist/skill/validator.js +227 -0
  73. package/dist/skill/validator.js.map +1 -0
  74. package/dist/storage/sqlite.d.ts +141 -1
  75. package/dist/storage/sqlite.d.ts.map +1 -1
  76. package/dist/storage/sqlite.js +664 -7
  77. package/dist/storage/sqlite.js.map +1 -1
  78. package/dist/types.d.ts +93 -0
  79. package/dist/types.d.ts.map +1 -1
  80. package/dist/types.js +8 -0
  81. package/dist/types.js.map +1 -1
  82. package/dist/viewer/html.d.ts +1 -1
  83. package/dist/viewer/html.d.ts.map +1 -1
  84. package/dist/viewer/html.js +2391 -159
  85. package/dist/viewer/html.js.map +1 -1
  86. package/dist/viewer/server.d.ts +16 -0
  87. package/dist/viewer/server.d.ts.map +1 -1
  88. package/dist/viewer/server.js +346 -3
  89. package/dist/viewer/server.js.map +1 -1
  90. package/index.ts +572 -89
  91. package/openclaw.plugin.json +20 -45
  92. package/package.json +3 -4
  93. package/skill/memos-memory-guide/SKILL.md +86 -0
  94. package/src/capture/index.ts +85 -45
  95. package/src/ingest/dedup.ts +29 -0
  96. package/src/ingest/providers/anthropic.ts +258 -1
  97. package/src/ingest/providers/bedrock.ts +256 -6
  98. package/src/ingest/providers/gemini.ts +252 -1
  99. package/src/ingest/providers/index.ts +156 -8
  100. package/src/ingest/providers/openai.ts +304 -1
  101. package/src/ingest/task-processor.ts +396 -0
  102. package/src/ingest/worker.ts +145 -34
  103. package/src/recall/engine.ts +23 -12
  104. package/src/recall/mmr.ts +3 -1
  105. package/src/skill/bundled-memory-guide.ts +91 -0
  106. package/src/skill/evaluator.ts +220 -0
  107. package/src/skill/evolver.ts +169 -0
  108. package/src/skill/generator.ts +506 -0
  109. package/src/skill/installer.ts +59 -0
  110. package/src/skill/upgrader.ts +257 -0
  111. package/src/skill/validator.ts +227 -0
  112. package/src/storage/sqlite.ts +802 -7
  113. package/src/types.ts +96 -0
  114. package/src/viewer/html.ts +2391 -159
  115. package/src/viewer/server.ts +346 -3
  116. package/SKILL.md +0 -43
  117. package/www/index.html +0 -632
package/index.ts CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
9
9
  import { Type } from "@sinclair/typebox";
10
+ import * as fs from "fs";
11
+ import * as path from "path";
10
12
  import { buildContext } from "./src/config";
11
13
  import { SqliteStore } from "./src/storage/sqlite";
12
14
  import { Embedder } from "./src/embedding";
@@ -15,31 +17,40 @@ import { RecallEngine } from "./src/recall/engine";
15
17
  import { captureMessages } from "./src/capture";
16
18
  import { DEFAULTS } from "./src/types";
17
19
  import { ViewerServer } from "./src/viewer/server";
20
+ import { SkillEvolver } from "./src/skill/evolver";
21
+ import { SkillInstaller } from "./src/skill/installer";
22
+ import { Summarizer } from "./src/ingest/providers";
23
+ import { MEMORY_GUIDE_SKILL_MD } from "./src/skill/bundled-memory-guide";
24
+
25
+
26
+ /** Remove near-duplicate hits based on summary word overlap (>70%). Keeps first (highest-scored) hit. */
27
+ function deduplicateHits<T extends { summary: string }>(hits: T[]): T[] {
28
+ const kept: T[] = [];
29
+ for (const hit of hits) {
30
+ const dominated = kept.some((k) => {
31
+ const a = k.summary.toLowerCase();
32
+ const b = hit.summary.toLowerCase();
33
+ if (a === b) return true;
34
+ const wordsA = new Set(a.split(/\s+/).filter(w => w.length > 1));
35
+ const wordsB = new Set(b.split(/\s+/).filter(w => w.length > 1));
36
+ if (wordsA.size === 0 || wordsB.size === 0) return false;
37
+ let overlap = 0;
38
+ for (const w of wordsB) { if (wordsA.has(w)) overlap++; }
39
+ return overlap / Math.min(wordsA.size, wordsB.size) > 0.7;
40
+ });
41
+ if (!dominated) kept.push(hit);
42
+ }
43
+ return kept;
44
+ }
18
45
 
19
46
  const pluginConfigSchema = {
20
47
  type: "object" as const,
21
48
  additionalProperties: true,
22
49
  properties: {
23
- embedding: {
24
- type: "object" as const,
25
- properties: {
26
- provider: { type: "string" as const },
27
- endpoint: { type: "string" as const },
28
- apiKey: { type: "string" as const },
29
- model: { type: "string" as const },
30
- },
50
+ viewerPort: {
51
+ type: "number" as const,
52
+ description: "Memory Viewer HTTP port (default 18799)",
31
53
  },
32
- summarizer: {
33
- type: "object" as const,
34
- properties: {
35
- provider: { type: "string" as const },
36
- endpoint: { type: "string" as const },
37
- apiKey: { type: "string" as const },
38
- model: { type: "string" as const },
39
- temperature: { type: "number" as const },
40
- },
41
- },
42
- viewerPort: { type: "number" as const },
43
54
  },
44
55
  };
45
56
 
@@ -48,7 +59,7 @@ const memosLocalPlugin = {
48
59
  name: "MemOS Local Memory",
49
60
  description:
50
61
  "Full-write local conversation memory with hybrid search (RRF + MMR + recency). " +
51
- "Provides memory_search, memory_timeline, memory_get for progressive recall.",
62
+ "Provides memory_search, memory_get, task_summary, memory_timeline, memory_viewer for layered retrieval.",
52
63
  kind: "memory" as const,
53
64
  configSchema: pluginConfigSchema,
54
65
 
@@ -68,8 +79,61 @@ const memosLocalPlugin = {
68
79
  const engine = new RecallEngine(store, embedder, ctx);
69
80
  const evidenceTag = ctx.config.capture?.evidenceWrapperTag ?? DEFAULTS.evidenceWrapperTag;
70
81
 
82
+ const workspaceDir = api.resolvePath("~/.openclaw/workspace");
83
+ const skillCtx = { ...ctx, workspaceDir };
84
+ const skillEvolver = new SkillEvolver(store, engine, skillCtx);
85
+ const skillInstaller = new SkillInstaller(store, skillCtx);
86
+
87
+ // Install bundled memory-guide skill so OpenClaw loads it (write from embedded content so it works regardless of deploy layout)
88
+ const workspaceSkillsDir = path.join(workspaceDir, "skills");
89
+ const memosGuideDest = path.join(workspaceSkillsDir, "memos-memory-guide");
90
+ fs.mkdirSync(memosGuideDest, { recursive: true });
91
+ fs.writeFileSync(path.join(memosGuideDest, "SKILL.md"), MEMORY_GUIDE_SKILL_MD, "utf-8");
92
+ ctx.log.info(`memos-local: installed bundled skill memos-memory-guide → ${memosGuideDest}`);
93
+
94
+ // Also ensure managed skills dir has it so dashboard/other loaders can see it
95
+ const managedSkillsDir = path.join(stateDir, "skills");
96
+ const managedMemosGuide = path.join(managedSkillsDir, "memos-memory-guide");
97
+ try {
98
+ fs.mkdirSync(managedMemosGuide, { recursive: true });
99
+ fs.writeFileSync(path.join(managedMemosGuide, "SKILL.md"), MEMORY_GUIDE_SKILL_MD, "utf-8");
100
+ ctx.log.info(`memos-local: installed bundled skill memos-memory-guide → ${managedMemosGuide} (managed)`);
101
+ } catch (e) {
102
+ ctx.log.warn(`memos-local: could not write to managed skills dir: ${e}`);
103
+ }
104
+
105
+ worker.getTaskProcessor().onTaskCompleted((task) => {
106
+ skillEvolver.onTaskCompleted(task).catch((err) => {
107
+ ctx.log.warn(`SkillEvolver async error: ${err}`);
108
+ });
109
+ });
110
+
111
+ const summarizer = new Summarizer(ctx.config.summarizer, ctx.log);
112
+
71
113
  api.logger.info(`memos-local: initialized (db: ${ctx.config.storage!.dbPath})`);
72
114
 
115
+ const trackTool = (toolName: string, fn: (...args: any[]) => Promise<any>) =>
116
+ async (...args: any[]) => {
117
+ const t0 = performance.now();
118
+ let ok = true;
119
+ let result: any;
120
+ const inputParams = args.length > 1 ? args[1] : args[0];
121
+ try {
122
+ result = await fn(...args);
123
+ return result;
124
+ } catch (e) {
125
+ ok = false;
126
+ throw e;
127
+ } finally {
128
+ const dur = performance.now() - t0;
129
+ store.recordToolCall(toolName, dur, ok);
130
+ try {
131
+ const outputText = result?.content?.[0]?.text ?? JSON.stringify(result ?? "");
132
+ store.recordApiLog(toolName, inputParams, outputText, dur, ok);
133
+ } catch (_) { /* best-effort */ }
134
+ }
135
+ };
136
+
73
137
  // ─── Tool: memory_search ───
74
138
 
75
139
  api.registerTool(
@@ -77,21 +141,27 @@ const memosLocalPlugin = {
77
141
  name: "memory_search",
78
142
  label: "Memory Search",
79
143
  description:
80
- "Search stored conversation memories. Returns summary, original_excerpt (evidence), score, and ref. " +
81
- "Default: top 6, minScore 0.45. Increase maxResults to 12/20 or lower minScore to 0.35 if needed.",
144
+ "Search long-term conversation memory for past conversations, user preferences, decisions, and experiences. " +
145
+ "Relevant memories are automatically injected at the start of each turn, but call this tool when you need " +
146
+ "to search with a different query, narrow by role, or the auto-recalled context is insufficient.\n\n" +
147
+ "Use role='user' to find what the user actually said.",
82
148
  parameters: Type.Object({
83
149
  query: Type.String({ description: "Natural language search query" }),
84
- maxResults: Type.Optional(Type.Number({ description: "Max results (default 6, max 20)" })),
150
+ maxResults: Type.Optional(Type.Number({ description: "Max results (default 20, max 20)" })),
85
151
  minScore: Type.Optional(Type.Number({ description: "Min score 0-1 (default 0.45, floor 0.35)" })),
152
+ role: Type.Optional(Type.String({ description: "Filter by role: 'user', 'assistant', or 'tool'. Use 'user' to find what the user said." })),
86
153
  }),
87
- async execute(_toolCallId, params) {
88
- const { query, maxResults, minScore } = params as {
154
+ execute: trackTool("memory_search", async (_toolCallId: any, params: any) => {
155
+ const { query, minScore, role } = params as {
89
156
  query: string;
90
157
  maxResults?: number;
91
158
  minScore?: number;
159
+ role?: string;
92
160
  };
93
161
 
94
- const result = await engine.search({ query, maxResults, minScore });
162
+ ctx.log.debug(`memory_search query="${query}" minScore=${minScore ?? 0.45} role=${role ?? "all"}`);
163
+ const result = await engine.search({ query, maxResults: 20, minScore, role });
164
+ ctx.log.debug(`memory_search raw candidates: ${result.hits.length}`);
95
165
 
96
166
  if (result.hits.length === 0) {
97
167
  return {
@@ -100,35 +170,104 @@ const memosLocalPlugin = {
100
170
  };
101
171
  }
102
172
 
103
- const roleLabel = (r: string) => r === "user" ? "[USER said]" : r === "assistant" ? "[ASSISTANT replied]" : r === "tool" ? "[TOOL returned]" : `[${r.toUpperCase()}]`;
173
+ // LLM relevance + sufficiency filtering
174
+ let filteredHits = result.hits;
175
+ let sufficient = false;
176
+
177
+ const candidates = result.hits.map((h, i) => ({
178
+ index: i + 1,
179
+ summary: h.summary,
180
+ role: h.source.role,
181
+ }));
182
+
183
+ const filterResult = await summarizer.filterRelevant(query, candidates);
184
+ if (filterResult !== null) {
185
+ sufficient = filterResult.sufficient;
186
+ if (filterResult.relevant.length > 0) {
187
+ const indexSet = new Set(filterResult.relevant);
188
+ filteredHits = result.hits.filter((_, i) => indexSet.has(i + 1));
189
+ ctx.log.debug(`memory_search LLM filter: ${result.hits.length} → ${filteredHits.length} hits, sufficient=${sufficient}`);
190
+ } else {
191
+ return {
192
+ content: [{ type: "text", text: "No relevant memories found for this query." }],
193
+ details: { meta: result.meta },
194
+ };
195
+ }
196
+ }
197
+
198
+ if (filteredHits.length === 0) {
199
+ return {
200
+ content: [{ type: "text", text: "No relevant memories found for this query." }],
201
+ details: { meta: result.meta },
202
+ };
203
+ }
204
+
205
+ const beforeDedup = filteredHits.length;
206
+ filteredHits = deduplicateHits(filteredHits);
207
+ ctx.log.debug(`memory_search dedup: ${beforeDedup} → ${filteredHits.length}`);
208
+
209
+ const lines = filteredHits.map((h, i) => {
210
+ const excerpt = h.original_excerpt.length > 300
211
+ ? h.original_excerpt.slice(0, 297) + "..."
212
+ : h.original_excerpt;
213
+ const parts = [`${i + 1}. [${h.source.role}]`];
214
+ if (excerpt) parts.push(` ${excerpt}`);
215
+ parts.push(` chunkId="${h.ref.chunkId}"`);
216
+ if (h.taskId) {
217
+ const task = store.getTask(h.taskId);
218
+ if (task && task.status !== "skipped") {
219
+ parts.push(` task_id="${h.taskId}"`);
220
+ }
221
+ }
222
+ return parts.join("\n");
223
+ });
104
224
 
105
- const text = result.hits
106
- .map(
107
- (h, i) =>
108
- `${i + 1}. ${roleLabel(h.source.role)} [score=${h.score}] ${h.summary}\n Evidence: ${h.original_excerpt.slice(0, 200)}`,
109
- )
110
- .join("\n\n");
225
+ let tipsText = "";
226
+ if (!sufficient) {
227
+ const hasTask = filteredHits.some((h) => {
228
+ if (!h.taskId) return false;
229
+ const t = store.getTask(h.taskId);
230
+ return t && t.status !== "skipped";
231
+ });
232
+
233
+ const tips: string[] = [];
234
+ if (hasTask) {
235
+ tips.push("→ call task_summary(taskId) for full task context");
236
+ tips.push("→ call skill_get(taskId=...) if the task has a proven experience guide");
237
+ }
238
+ tips.push("→ call memory_timeline(chunkId) to expand surrounding conversation");
239
+
240
+ if (tips.length > 0) {
241
+ tipsText = "\n\nThese memories may not be enough. You can fetch more context:\n" + tips.join("\n");
242
+ }
243
+ }
111
244
 
112
245
  return {
113
246
  content: [
114
247
  {
115
248
  type: "text",
116
- text: `Found ${result.hits.length} memories (minScore=${result.meta.usedMinScore}):\n\n${text}`,
249
+ text: `Found ${filteredHits.length} relevant memories:\n\n${lines.join("\n\n")}${tipsText}`,
117
250
  },
118
251
  ],
119
252
  details: {
120
- hits: result.hits.map((h) => ({
121
- role: h.source.role,
122
- summary: h.summary,
123
- original_excerpt: h.original_excerpt,
124
- ref: h.ref,
125
- score: h.score,
126
- source: h.source,
127
- })),
253
+ hits: filteredHits.map((h) => {
254
+ let effectiveTaskId = h.taskId;
255
+ if (effectiveTaskId) {
256
+ const t = store.getTask(effectiveTaskId);
257
+ if (t && t.status === "skipped") effectiveTaskId = null;
258
+ }
259
+ return {
260
+ chunkId: h.ref.chunkId,
261
+ taskId: effectiveTaskId,
262
+ skillId: h.skillId,
263
+ role: h.source.role,
264
+ score: h.score,
265
+ };
266
+ }),
128
267
  meta: result.meta,
129
268
  },
130
269
  };
131
- },
270
+ }),
132
271
  },
133
272
  { name: "memory_search" },
134
273
  );
@@ -140,26 +279,29 @@ const memosLocalPlugin = {
140
279
  name: "memory_timeline",
141
280
  label: "Memory Timeline",
142
281
  description:
143
- "Get neighboring context around a memory ref. Use after memory_search to expand context.",
282
+ "Expand context around a memory search hit. Pass the chunkId from a search result " +
283
+ "to read the surrounding conversation messages.",
144
284
  parameters: Type.Object({
145
- sessionKey: Type.String({ description: "From search hit ref.sessionKey" }),
146
- chunkId: Type.String({ description: "From search hit ref.chunkId" }),
147
- turnId: Type.String({ description: "From search hit ref.turnId" }),
148
- seq: Type.Number({ description: "From search hit ref.seq" }),
285
+ chunkId: Type.String({ description: "The chunkId from a memory_search hit" }),
149
286
  window: Type.Optional(Type.Number({ description: "Context window ±N (default 2)" })),
150
287
  }),
151
- async execute(_toolCallId, params) {
152
- const { sessionKey, chunkId, turnId, seq, window: win } = params as {
153
- sessionKey: string;
288
+ execute: trackTool("memory_timeline", async (_toolCallId: any, params: any) => {
289
+ ctx.log.debug(`memory_timeline called`);
290
+ const { chunkId, window: win } = params as {
154
291
  chunkId: string;
155
- turnId: string;
156
- seq: number;
157
292
  window?: number;
158
293
  };
159
294
 
160
- const w = win ?? DEFAULTS.timelineWindowDefault;
161
- const neighbors = store.getNeighborChunks(sessionKey, turnId, seq, w);
162
295
  const anchorChunk = store.getChunk(chunkId);
296
+ if (!anchorChunk) {
297
+ return {
298
+ content: [{ type: "text", text: `Chunk not found: ${chunkId}` }],
299
+ details: { error: "not_found" },
300
+ };
301
+ }
302
+
303
+ const w = win ?? DEFAULTS.timelineWindowDefault;
304
+ const neighbors = store.getNeighborChunks(anchorChunk.sessionKey, anchorChunk.turnId, anchorChunk.seq, w);
163
305
  const anchorTs = anchorChunk?.createdAt ?? 0;
164
306
 
165
307
  const entries = neighbors.map((chunk) => {
@@ -184,53 +326,189 @@ const memosLocalPlugin = {
184
326
  content: [{ type: "text", text: `Timeline (${entries.length} entries):\n\n${text}` }],
185
327
  details: { entries, anchorRef: { sessionKey, chunkId, turnId, seq } },
186
328
  };
187
- },
329
+ }),
188
330
  },
189
331
  { name: "memory_timeline" },
190
332
  );
191
333
 
192
- // ─── Tool: memory_get ───
334
+ // memory_get removed search results are pre-filtered by LLM for relevance;
335
+ // agents use task_summary for broader context and skill_get for experience guides.
336
+
337
+ // ─── Tool: task_summary ───
193
338
 
194
339
  api.registerTool(
195
340
  {
196
- name: "memory_get",
197
- label: "Memory Get",
341
+ name: "task_summary",
342
+ label: "Task Summary",
198
343
  description:
199
- "Get full original text of a memory chunk. Use to verify exact details from a search hit.",
344
+ "Get the detailed summary of a complete task. Use this when memory_search returns a hit " +
345
+ "with a task_id and you need the full context of that task. The summary preserves all " +
346
+ "critical information: URLs, file paths, commands, error codes, step-by-step instructions.",
200
347
  parameters: Type.Object({
201
- chunkId: Type.String({ description: "From search hit ref.chunkId" }),
202
- maxChars: Type.Optional(
203
- Type.Number({ description: `Max chars (default ${DEFAULTS.getMaxCharsDefault}, max ${DEFAULTS.getMaxCharsMax})` }),
204
- ),
348
+ taskId: Type.String({ description: "The task_id from a memory_search hit" }),
205
349
  }),
206
- async execute(_toolCallId, params) {
207
- const { chunkId, maxChars } = params as { chunkId: string; maxChars?: number };
208
- const limit = Math.min(maxChars ?? DEFAULTS.getMaxCharsDefault, DEFAULTS.getMaxCharsMax);
350
+ execute: trackTool("task_summary", async (_toolCallId: any, params: any) => {
351
+ const { taskId } = params as { taskId: string };
352
+ ctx.log.debug(`task_summary called for task=${taskId}`);
209
353
 
210
- const chunk = store.getChunk(chunkId);
211
- if (!chunk) {
354
+ const task = store.getTask(taskId);
355
+ if (!task) {
212
356
  return {
213
- content: [{ type: "text", text: `Chunk not found: ${chunkId}` }],
357
+ content: [{ type: "text", text: `Task not found: ${taskId}` }],
214
358
  details: { error: "not_found" },
215
359
  };
216
360
  }
217
361
 
218
- const content = chunk.content.length > limit
219
- ? chunk.content.slice(0, limit) + "…"
220
- : chunk.content;
362
+ if (task.status === "skipped") {
363
+ return {
364
+ content: [{ type: "text", text: `Task "${task.title}" was too brief to generate a summary. Reason: ${task.summary || "conversation too short"}. Use memory_get to read individual chunks instead.` }],
365
+ details: { taskId, status: task.status },
366
+ };
367
+ }
221
368
 
222
- const who = chunk.role === "user" ? "USER said" : chunk.role === "assistant" ? "ASSISTANT replied" : chunk.role === "tool" ? "TOOL returned" : chunk.role.toUpperCase();
369
+ if (!task.summary) {
370
+ const chunks = store.getChunksByTask(taskId);
371
+ if (chunks.length === 0) {
372
+ return {
373
+ content: [{ type: "text", text: `Task ${taskId} has no content yet.` }],
374
+ details: { taskId, status: task.status },
375
+ };
376
+ }
377
+ return {
378
+ content: [{
379
+ type: "text",
380
+ text: `Task "${task.title}" is still active (summary not yet generated). ` +
381
+ `It contains ${chunks.length} memory chunks. Use memory_get to read individual chunks.`,
382
+ }],
383
+ details: { taskId, status: task.status, chunkCount: chunks.length },
384
+ };
385
+ }
386
+
387
+ const relatedSkills = store.getSkillsByTask(taskId);
388
+ let skillSection = "";
389
+ if (relatedSkills.length > 0) {
390
+ const skillLines = relatedSkills.map(rs =>
391
+ `- 🔧 ${rs.skill.name} (${rs.relation}, v${rs.versionAt}) — call skill_get(skillId="${rs.skill.id}") or skill_get(taskId="${taskId}") to get the full guide`
392
+ );
393
+ skillSection = `\n\n### Related Skills\n${skillLines.join("\n")}`;
394
+ }
223
395
 
224
396
  return {
225
- content: [{ type: "text", text: `[${who}] (session: ${chunk.sessionKey})\n\n${content}` }],
397
+ content: [{
398
+ type: "text",
399
+ text: `## Task: ${task.title}\n\nStatus: ${task.status}\nChunks: ${store.getChunksByTask(taskId).length}\n\n${task.summary}${skillSection}`,
400
+ }],
226
401
  details: {
227
- ref: { sessionKey: chunk.sessionKey, chunkId: chunk.id, turnId: chunk.turnId, seq: chunk.seq },
228
- source: { ts: chunk.createdAt, role: chunk.role, sessionKey: chunk.sessionKey },
402
+ taskId: task.id,
403
+ title: task.title,
404
+ status: task.status,
405
+ startedAt: task.startedAt,
406
+ endedAt: task.endedAt,
407
+ relatedSkills: relatedSkills.map(rs => ({ skillId: rs.skill.id, name: rs.skill.name, relation: rs.relation })),
229
408
  },
230
409
  };
231
- },
410
+ }),
232
411
  },
233
- { name: "memory_get" },
412
+ { name: "task_summary" },
413
+ );
414
+
415
+ // ─── Tool: skill_get ───
416
+
417
+ api.registerTool(
418
+ {
419
+ name: "skill_get",
420
+ label: "Get Skill",
421
+ description:
422
+ "Retrieve a proven skill (experience guide) by skillId or taskId. " +
423
+ "Pass either one — if you have a task_id from memory_search, pass taskId and the system " +
424
+ "will find the associated skill automatically.",
425
+ parameters: Type.Object({
426
+ skillId: Type.Optional(Type.String({ description: "Direct skill ID" })),
427
+ taskId: Type.Optional(Type.String({ description: "Task ID — will look up the skill linked to this task" })),
428
+ }),
429
+ execute: trackTool("skill_get", async (_toolCallId: any, params: any) => {
430
+ const { skillId: directSkillId, taskId } = params as { skillId?: string; taskId?: string };
431
+
432
+ let resolvedSkillId = directSkillId;
433
+ if (!resolvedSkillId && taskId) {
434
+ const linked = store.getSkillsByTask(taskId);
435
+ if (linked.length > 0) {
436
+ resolvedSkillId = linked[0].skill.id;
437
+ } else {
438
+ return {
439
+ content: [{ type: "text", text: `No skill associated with task ${taskId}.` }],
440
+ details: { error: "no_skill_for_task", taskId },
441
+ };
442
+ }
443
+ }
444
+
445
+ if (!resolvedSkillId) {
446
+ return {
447
+ content: [{ type: "text", text: "Provide either skillId or taskId." }],
448
+ details: { error: "missing_params" },
449
+ };
450
+ }
451
+
452
+ ctx.log.debug(`skill_get resolved skill=${resolvedSkillId} (from ${directSkillId ? "skillId" : "taskId=" + taskId})`);
453
+
454
+ const skill = store.getSkill(resolvedSkillId);
455
+ if (!skill) {
456
+ return {
457
+ content: [{ type: "text", text: `Skill not found: ${resolvedSkillId}` }],
458
+ details: { error: "not_found" },
459
+ };
460
+ }
461
+
462
+ const sv = store.getLatestSkillVersion(resolvedSkillId);
463
+ if (!sv) {
464
+ return {
465
+ content: [{ type: "text", text: `Skill "${skill.name}" has no content versions.` }],
466
+ details: { skillId: resolvedSkillId, name: skill.name, error: "no_version" },
467
+ };
468
+ }
469
+
470
+ return {
471
+ content: [{
472
+ type: "text",
473
+ text: `## Skill: ${skill.name} (v${skill.version})\n\n${sv.content}\n\n---\nTo install this skill for persistent use: call skill_install(skillId="${resolvedSkillId}")`,
474
+ }],
475
+ details: {
476
+ skillId: skill.id,
477
+ name: skill.name,
478
+ version: skill.version,
479
+ status: skill.status,
480
+ installed: skill.installed,
481
+ },
482
+ };
483
+ }),
484
+ },
485
+ { name: "skill_get" },
486
+ );
487
+
488
+ // ─── Tool: skill_install ───
489
+
490
+ api.registerTool(
491
+ {
492
+ name: "skill_install",
493
+ label: "Install Skill",
494
+ description:
495
+ "Install a learned skill into the agent workspace so it becomes permanently available. " +
496
+ "After installation, the skill will be loaded automatically in future sessions.",
497
+ parameters: Type.Object({
498
+ skillId: Type.String({ description: "The skill_id to install" }),
499
+ }),
500
+ execute: trackTool("skill_install", async (_toolCallId: any, params: any) => {
501
+ const { skillId } = params as { skillId: string };
502
+ ctx.log.debug(`skill_install called for skill=${skillId}`);
503
+
504
+ const result = skillInstaller.install(skillId);
505
+ return {
506
+ content: [{ type: "text", text: result.message }],
507
+ details: result,
508
+ };
509
+ }),
510
+ },
511
+ { name: "skill_install" },
234
512
  );
235
513
 
236
514
  // ─── Tool: memory_viewer ───
@@ -242,9 +520,12 @@ const memosLocalPlugin = {
242
520
  name: "memory_viewer",
243
521
  label: "Open Memory Viewer",
244
522
  description:
245
- "Open the MemOS Memory Viewer web dashboard. Returns the URL the user can open in their browser to visually browse, search, and manage all stored memories.",
523
+ "Show the MemOS Memory Viewer URL. Call this when the user asks how to view, browse, manage, " +
524
+ "or access their stored memories, or asks where the memory dashboard is. " +
525
+ "Returns the URL the user can open in their browser.",
246
526
  parameters: Type.Object({}),
247
- async execute() {
527
+ execute: trackTool("memory_viewer", async () => {
528
+ ctx.log.debug(`memory_viewer called`);
248
529
  const url = `http://127.0.0.1:${viewerPort}`;
249
530
  return {
250
531
  content: [
@@ -265,19 +546,171 @@ const memosLocalPlugin = {
265
546
  ],
266
547
  details: { viewerUrl: url },
267
548
  };
268
- },
549
+ }),
269
550
  },
270
551
  { name: "memory_viewer" },
271
552
  );
272
553
 
554
+ // ─── Auto-recall: inject relevant memories before agent starts ───
555
+
556
+ api.on("before_agent_start", async (event: { prompt?: string; messages?: unknown[] }) => {
557
+ if (!event.prompt || event.prompt.length < 3) return;
558
+
559
+ const recallT0 = performance.now();
560
+ let recallQuery = "";
561
+
562
+ try {
563
+ const rawPrompt = event.prompt;
564
+ ctx.log.debug(`auto-recall: rawPrompt="${rawPrompt.slice(0, 300)}"`);
565
+
566
+ let query = rawPrompt;
567
+ const lastDoubleNewline = rawPrompt.lastIndexOf("\n\n");
568
+ if (lastDoubleNewline > 0 && lastDoubleNewline < rawPrompt.length - 3) {
569
+ const tail = rawPrompt.slice(lastDoubleNewline + 2).trim();
570
+ if (tail.length >= 2) query = tail;
571
+ }
572
+ query = query.replace(/^\[.*?\]\s*/, "");
573
+ query = query.replace(/<[^>]+>/g, "").trim();
574
+ recallQuery = query;
575
+
576
+ if (query.length < 2) {
577
+ ctx.log.debug("auto-recall: extracted query too short, skipping");
578
+ return;
579
+ }
580
+ ctx.log.debug(`auto-recall: query="${query.slice(0, 80)}"`);
581
+
582
+ const result = await engine.search({ query, maxResults: 20, minScore: 0.45 });
583
+ if (result.hits.length === 0) {
584
+ ctx.log.debug("auto-recall: no candidates found");
585
+ const dur = performance.now() - recallT0;
586
+ store.recordToolCall("memory_search", dur, true);
587
+ store.recordApiLog("memory_search", { query }, "no hits", dur, true);
588
+ const noRecallHint =
589
+ "## Memory system\n\nNo memories were automatically recalled for this turn (e.g. the user's message was long, vague, or no matching history). " +
590
+ "You may still have relevant past context — call the **memory_search** tool with a **short, focused query** you generate yourself " +
591
+ "(e.g. key topics, names, or a rephrased question) to search the user's conversation history.";
592
+ return { systemPrompt: noRecallHint };
593
+ }
594
+
595
+ const candidates = result.hits.map((h, i) => ({
596
+ index: i + 1,
597
+ summary: h.summary,
598
+ role: h.source.role,
599
+ }));
600
+
601
+ let filteredHits = result.hits;
602
+ let sufficient = false;
603
+
604
+ const filterResult = await summarizer.filterRelevant(query, candidates);
605
+ if (filterResult !== null) {
606
+ sufficient = filterResult.sufficient;
607
+ if (filterResult.relevant.length > 0) {
608
+ const indexSet = new Set(filterResult.relevant);
609
+ filteredHits = result.hits.filter((_, i) => indexSet.has(i + 1));
610
+ } else {
611
+ ctx.log.debug("auto-recall: LLM filter returned no relevant hits");
612
+ const dur = performance.now() - recallT0;
613
+ store.recordToolCall("memory_search", dur, true);
614
+ store.recordApiLog("memory_search", { query }, `${result.hits.length} candidates → 0 relevant`, dur, true);
615
+ const noRecallHint =
616
+ "## Memory system\n\nNo memories were automatically recalled for this turn (e.g. the user's message was long, vague, or no matching history). " +
617
+ "You may still have relevant past context — call the **memory_search** tool with a **short, focused query** you generate yourself " +
618
+ "(e.g. key topics, names, or a rephrased question) to search the user's conversation history.";
619
+ return { systemPrompt: noRecallHint };
620
+ }
621
+ }
622
+
623
+ const beforeDedup = filteredHits.length;
624
+ filteredHits = deduplicateHits(filteredHits);
625
+ ctx.log.debug(`auto-recall: ${result.hits.length} → ${beforeDedup} relevant → ${filteredHits.length} after dedup, sufficient=${sufficient}`);
626
+
627
+ const lines = filteredHits.map((h, i) => {
628
+ const excerpt = h.original_excerpt.length > 300
629
+ ? h.original_excerpt.slice(0, 297) + "..."
630
+ : h.original_excerpt;
631
+ const parts: string[] = [`${i + 1}. [${h.source.role}]`];
632
+ if (excerpt) parts.push(` ${excerpt}`);
633
+ parts.push(` chunkId="${h.ref.chunkId}"`);
634
+ if (h.taskId) {
635
+ const task = store.getTask(h.taskId);
636
+ if (task && task.status !== "skipped") {
637
+ parts.push(` task_id="${h.taskId}"`);
638
+ }
639
+ }
640
+ return parts.join("\n");
641
+ });
642
+
643
+ let tipsText = "";
644
+ if (!sufficient) {
645
+ const hasTask = filteredHits.some((h) => {
646
+ if (!h.taskId) return false;
647
+ const t = store.getTask(h.taskId);
648
+ return t && t.status !== "skipped";
649
+ });
650
+ const tips: string[] = [];
651
+ if (hasTask) {
652
+ tips.push("→ call task_summary(taskId) for full task context");
653
+ tips.push("→ call skill_get(taskId=...) if the task has a proven experience guide");
654
+ }
655
+ tips.push("→ call memory_timeline(chunkId) to expand surrounding conversation");
656
+ tipsText = "\n\nIf more context is needed:\n" + tips.join("\n");
657
+ }
658
+
659
+ const contextParts = [
660
+ "## User's conversation history (from memory system)",
661
+ "",
662
+ "IMPORTANT: The following are facts from previous conversations with this user.",
663
+ "You MUST treat these as established knowledge and use them directly when answering.",
664
+ "Do NOT say you don't know or don't have information if the answer is in these memories.",
665
+ "",
666
+ lines.join("\n\n"),
667
+ ];
668
+ if (tipsText) contextParts.push(tipsText);
669
+ const context = contextParts.join("\n");
670
+
671
+ const recallDur = performance.now() - recallT0;
672
+ store.recordToolCall("memory_search", recallDur, true);
673
+ store.recordApiLog("memory_search", { query }, context, recallDur, true);
674
+
675
+ const memoryPrefix = `<memory_context>\n${context}\n</memory_context>\n\n`;
676
+
677
+ return {
678
+ systemPrompt: context,
679
+ prependContext: memoryPrefix,
680
+ };
681
+ } catch (err) {
682
+ const dur = performance.now() - recallT0;
683
+ store.recordToolCall("memory_search", dur, false);
684
+ try { store.recordApiLog("memory_search", { query: recallQuery }, `error: ${String(err)}`, dur, false); } catch (_) { /* best-effort */ }
685
+ ctx.log.warn(`auto-recall failed: ${String(err)}`);
686
+ }
687
+ });
688
+
273
689
  // ─── Auto-capture: write conversation to memory after each agent turn ───
274
690
 
691
+ // Track how many messages we've already processed per session to avoid
692
+ // re-processing the entire conversation history on every agent_end.
693
+ const sessionMsgCursor = new Map<string, number>();
694
+
275
695
  api.on("agent_end", async (event) => {
276
696
  if (!event.success || !event.messages || event.messages.length === 0) return;
277
697
 
278
698
  try {
279
- const msgs: Array<{ role: string; content: string; toolName?: string }> = [];
280
- for (const msg of event.messages) {
699
+ const sessionKey = (event as any).sessionKey ?? "default";
700
+ let cursor = sessionMsgCursor.get(sessionKey) ?? 0;
701
+ const allMessages = event.messages;
702
+
703
+ // Session was reset — cursor exceeds current message count
704
+ if (cursor > allMessages.length) cursor = 0;
705
+ if (cursor >= allMessages.length) return;
706
+
707
+ const newMessages = allMessages.slice(cursor);
708
+ sessionMsgCursor.set(sessionKey, allMessages.length);
709
+
710
+ ctx.log.debug(`agent_end: session=${sessionKey} total=${allMessages.length} cursor=${cursor} new=${newMessages.length}`);
711
+
712
+ const raw: Array<{ role: string; content: string; toolName?: string }> = [];
713
+ for (const msg of newMessages) {
281
714
  if (!msg || typeof msg !== "object") continue;
282
715
  const m = msg as Record<string, unknown>;
283
716
  const role = m.role as string;
@@ -288,24 +721,74 @@ const memosLocalPlugin = {
288
721
  text = m.content;
289
722
  } else if (Array.isArray(m.content)) {
290
723
  for (const block of m.content) {
291
- if (block && typeof block === "object" && (block as any).type === "text") {
292
- text += (block as any).text + "\n";
724
+ if (!block || typeof block !== "object") continue;
725
+ const b = block as Record<string, unknown>;
726
+ if (b.type === "text" && typeof b.text === "string") {
727
+ text += b.text + "\n";
728
+ } else if (typeof b.content === "string") {
729
+ text += b.content + "\n";
730
+ } else if (typeof b.text === "string") {
731
+ text += b.text + "\n";
293
732
  }
294
733
  }
295
734
  }
296
735
 
297
- if (!text.trim()) continue;
736
+ text = text.trim();
737
+ if (!text) continue;
738
+
739
+ // Strip injected <memory_context> prefix and OpenClaw metadata wrapper
740
+ // to store only the user's actual input
741
+ if (role === "user") {
742
+ const mcTag = "<memory_context>";
743
+ const mcEnd = "</memory_context>";
744
+ const mcIdx = text.indexOf(mcTag);
745
+ if (mcIdx !== -1) {
746
+ const endIdx = text.indexOf(mcEnd);
747
+ if (endIdx !== -1) {
748
+ text = text.slice(endIdx + mcEnd.length).trim();
749
+ }
750
+ }
751
+ // Strip OpenClaw metadata envelope:
752
+ // "Sender (untrusted metadata):\n```json\n{...}\n```\n\n[timestamp] actual message"
753
+ const senderIdx = text.indexOf("Sender (untrusted metadata):");
754
+ if (senderIdx !== -1) {
755
+ const afterSender = text.slice(senderIdx);
756
+ const lastDblNl = afterSender.lastIndexOf("\n\n");
757
+ if (lastDblNl > 0) {
758
+ const tail = afterSender.slice(lastDblNl + 2).trim();
759
+ if (tail.length >= 2) text = tail;
760
+ }
761
+ }
762
+ // Strip timestamp prefix like "[Thu 2026-03-05 15:23 GMT+8] "
763
+ text = text.replace(/^\[.*?\]\s*/, "").trim();
764
+ if (!text) continue;
765
+ }
298
766
 
299
767
  const toolName = role === "tool"
300
768
  ? (m.name as string) ?? (m.toolName as string) ?? (m.tool_call_id ? "unknown" : undefined)
301
769
  : undefined;
302
770
 
303
- msgs.push({ role, content: text.trim(), toolName });
771
+ raw.push({ role, content: text, toolName });
772
+ }
773
+
774
+ // Merge consecutive assistant messages into one (OpenClaw may send reply in multiple chunks)
775
+ const msgs: Array<{ role: string; content: string; toolName?: string }> = [];
776
+ for (let i = 0; i < raw.length; i++) {
777
+ const curr = raw[i];
778
+ if (curr.role !== "assistant") {
779
+ msgs.push(curr);
780
+ continue;
781
+ }
782
+ let merged = curr.content;
783
+ while (i + 1 < raw.length && raw[i + 1].role === "assistant") {
784
+ i++;
785
+ merged = merged + "\n\n" + raw[i].content;
786
+ }
787
+ msgs.push({ role: "assistant", content: merged.trim() });
304
788
  }
305
789
 
306
790
  if (msgs.length === 0) return;
307
791
 
308
- const sessionKey = (event as any).sessionKey ?? "default";
309
792
  const turnId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
310
793
  const captured = captureMessages(msgs, sessionKey, turnId, evidenceTag, ctx.log);
311
794
  if (captured.length > 0) {