@memtensor/memos-local-openclaw-plugin 0.1.4 → 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 (104) hide show
  1. package/README.md +196 -84
  2. package/dist/ingest/dedup.d.ts +8 -0
  3. package/dist/ingest/dedup.d.ts.map +1 -1
  4. package/dist/ingest/dedup.js +21 -0
  5. package/dist/ingest/dedup.js.map +1 -1
  6. package/dist/ingest/providers/anthropic.d.ts +14 -0
  7. package/dist/ingest/providers/anthropic.d.ts.map +1 -1
  8. package/dist/ingest/providers/anthropic.js +104 -0
  9. package/dist/ingest/providers/anthropic.js.map +1 -1
  10. package/dist/ingest/providers/bedrock.d.ts +14 -0
  11. package/dist/ingest/providers/bedrock.d.ts.map +1 -1
  12. package/dist/ingest/providers/bedrock.js +100 -0
  13. package/dist/ingest/providers/bedrock.js.map +1 -1
  14. package/dist/ingest/providers/gemini.d.ts +14 -0
  15. package/dist/ingest/providers/gemini.d.ts.map +1 -1
  16. package/dist/ingest/providers/gemini.js +96 -0
  17. package/dist/ingest/providers/gemini.js.map +1 -1
  18. package/dist/ingest/providers/index.d.ts +22 -0
  19. package/dist/ingest/providers/index.d.ts.map +1 -1
  20. package/dist/ingest/providers/index.js +68 -0
  21. package/dist/ingest/providers/index.js.map +1 -1
  22. package/dist/ingest/providers/openai.d.ts +22 -0
  23. package/dist/ingest/providers/openai.d.ts.map +1 -1
  24. package/dist/ingest/providers/openai.js +143 -0
  25. package/dist/ingest/providers/openai.js.map +1 -1
  26. package/dist/ingest/task-processor.d.ts +2 -0
  27. package/dist/ingest/task-processor.d.ts.map +1 -1
  28. package/dist/ingest/task-processor.js +15 -0
  29. package/dist/ingest/task-processor.js.map +1 -1
  30. package/dist/ingest/worker.d.ts +2 -0
  31. package/dist/ingest/worker.d.ts.map +1 -1
  32. package/dist/ingest/worker.js +115 -12
  33. package/dist/ingest/worker.js.map +1 -1
  34. package/dist/recall/engine.d.ts.map +1 -1
  35. package/dist/recall/engine.js +1 -0
  36. package/dist/recall/engine.js.map +1 -1
  37. package/dist/skill/bundled-memory-guide.d.ts +6 -0
  38. package/dist/skill/bundled-memory-guide.d.ts.map +1 -0
  39. package/dist/skill/bundled-memory-guide.js +95 -0
  40. package/dist/skill/bundled-memory-guide.js.map +1 -0
  41. package/dist/skill/evaluator.d.ts +31 -0
  42. package/dist/skill/evaluator.d.ts.map +1 -0
  43. package/dist/skill/evaluator.js +194 -0
  44. package/dist/skill/evaluator.js.map +1 -0
  45. package/dist/skill/evolver.d.ts +22 -0
  46. package/dist/skill/evolver.d.ts.map +1 -0
  47. package/dist/skill/evolver.js +193 -0
  48. package/dist/skill/evolver.js.map +1 -0
  49. package/dist/skill/generator.d.ts +25 -0
  50. package/dist/skill/generator.d.ts.map +1 -0
  51. package/dist/skill/generator.js +477 -0
  52. package/dist/skill/generator.js.map +1 -0
  53. package/dist/skill/installer.d.ts +16 -0
  54. package/dist/skill/installer.d.ts.map +1 -0
  55. package/dist/skill/installer.js +89 -0
  56. package/dist/skill/installer.js.map +1 -0
  57. package/dist/skill/upgrader.d.ts +19 -0
  58. package/dist/skill/upgrader.d.ts.map +1 -0
  59. package/dist/skill/upgrader.js +263 -0
  60. package/dist/skill/upgrader.js.map +1 -0
  61. package/dist/skill/validator.d.ts +29 -0
  62. package/dist/skill/validator.d.ts.map +1 -0
  63. package/dist/skill/validator.js +227 -0
  64. package/dist/skill/validator.js.map +1 -0
  65. package/dist/storage/sqlite.d.ts +75 -1
  66. package/dist/storage/sqlite.d.ts.map +1 -1
  67. package/dist/storage/sqlite.js +417 -6
  68. package/dist/storage/sqlite.js.map +1 -1
  69. package/dist/types.d.ts +78 -0
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/types.js +6 -0
  72. package/dist/types.js.map +1 -1
  73. package/dist/viewer/html.d.ts +1 -1
  74. package/dist/viewer/html.d.ts.map +1 -1
  75. package/dist/viewer/html.js +1549 -113
  76. package/dist/viewer/html.js.map +1 -1
  77. package/dist/viewer/server.d.ts +13 -0
  78. package/dist/viewer/server.d.ts.map +1 -1
  79. package/dist/viewer/server.js +289 -4
  80. package/dist/viewer/server.js.map +1 -1
  81. package/index.ts +489 -181
  82. package/package.json +1 -1
  83. package/skill/memos-memory-guide/SKILL.md +86 -0
  84. package/src/ingest/dedup.ts +29 -0
  85. package/src/ingest/providers/anthropic.ts +130 -0
  86. package/src/ingest/providers/bedrock.ts +126 -0
  87. package/src/ingest/providers/gemini.ts +124 -0
  88. package/src/ingest/providers/index.ts +86 -4
  89. package/src/ingest/providers/openai.ts +174 -0
  90. package/src/ingest/task-processor.ts +16 -0
  91. package/src/ingest/worker.ts +126 -21
  92. package/src/recall/engine.ts +1 -0
  93. package/src/skill/bundled-memory-guide.ts +91 -0
  94. package/src/skill/evaluator.ts +220 -0
  95. package/src/skill/evolver.ts +169 -0
  96. package/src/skill/generator.ts +506 -0
  97. package/src/skill/installer.ts +59 -0
  98. package/src/skill/upgrader.ts +257 -0
  99. package/src/skill/validator.ts +227 -0
  100. package/src/storage/sqlite.ts +508 -6
  101. package/src/types.ts +77 -0
  102. package/src/viewer/html.ts +1549 -113
  103. package/src/viewer/server.ts +285 -4
  104. package/skill/SKILL.md +0 -59
package/index.ts CHANGED
@@ -17,88 +17,30 @@ import { RecallEngine } from "./src/recall/engine";
17
17
  import { captureMessages } from "./src/capture";
18
18
  import { DEFAULTS } from "./src/types";
19
19
  import { ViewerServer } from "./src/viewer/server";
20
-
21
- /**
22
- * Install the MemOS skill into the workspace skills directory.
23
- * The skill teaches the agent WHEN and HOW to use memory tools.
24
- */
25
- function ensureSkillInstalled(workspaceDir: string, log: { info: (s: string) => void }): void {
26
- const skillDir = path.join(workspaceDir, "skills", "memos-local");
27
- const skillMd = path.join(skillDir, "SKILL.md");
28
-
29
- // Read bundled skill content from plugin source
30
- const bundledSkill = path.join(__dirname, "skill", "SKILL.md");
31
- let skillContent: string;
32
- try {
33
- skillContent = fs.readFileSync(bundledSkill, "utf-8");
34
- } catch {
35
- log.info("memos-local: bundled SKILL.md not found, using inline fallback");
36
- skillContent = [
37
- "---",
38
- "name: memos-local",
39
- 'description: "Long-term conversation memory (MemOS). Use when: user asks about their identity, name, preferences, past conversations, or references something from a previous session."',
40
- 'metadata: { "openclaw": { "emoji": "🧠" } }',
41
- "---",
42
- "",
43
- "# MemOS Local Memory Skill",
44
- "",
45
- "You have memory tools: memory_search, memory_timeline, memory_get.",
46
- "Call memory_search BEFORE saying 'I don't know' about user identity/preferences.",
47
- "For identity questions: memory_search(query=\"名字 身份\", role=\"user\")",
48
- "If insufficient: call memory_timeline with hit ref, or try different query.",
49
- "Budget: max 2 search + 2 timeline. Only then say 'insufficient evidence'.",
50
- "NEVER ignore results. If USER said '我是唐波', that IS their name.",
51
- ].join("\n");
52
- }
53
-
54
- try {
55
- let needsWrite = true;
56
- if (fs.existsSync(skillMd)) {
57
- const existing = fs.readFileSync(skillMd, "utf-8");
58
- if (existing === skillContent) needsWrite = false;
59
- }
60
-
61
- if (needsWrite) {
62
- fs.mkdirSync(skillDir, { recursive: true });
63
- fs.writeFileSync(skillMd, skillContent, "utf-8");
64
- log.info("memos-local: installed/updated MemOS skill in workspace");
65
- }
66
- } catch {
67
- // Non-fatal
68
- }
69
-
70
- // Always clean up old AGENTS.md protocol block if present
71
- cleanupAgentsMdProtocol(workspaceDir);
72
- }
73
-
74
- function cleanupAgentsMdProtocol(workspaceDir: string): void {
75
- const agentsPath = path.join(workspaceDir, "AGENTS.md");
76
- try {
77
- if (!fs.existsSync(agentsPath)) return;
78
- let content = fs.readFileSync(agentsPath, "utf-8");
79
-
80
- const markers = ["## MemOS Memory Protocol", "### MemOS Memory Rules"];
81
- let changed = false;
82
- for (const marker of markers) {
83
- const idx = content.indexOf(marker);
84
- if (idx === -1) continue;
85
- const nextH2 = content.slice(idx + marker.length).match(/\n## (?!MemOS|Red Lines)/);
86
- const blockEnd = nextH2 ? idx + marker.length + nextH2.index! : content.length;
87
-
88
- // If marker is inside a "## Red Lines" section, remove the whole Red Lines block
89
- const redLinesIdx = content.lastIndexOf("## Red Lines", idx);
90
- const blockStart = (redLinesIdx !== -1 && redLinesIdx >= idx - 50) ? redLinesIdx : idx;
91
-
92
- const before = content.slice(0, blockStart).trimEnd();
93
- const after = content.slice(blockEnd).trimStart();
94
- content = [before, after].filter(Boolean).join("\n\n");
95
- if (content && !content.endsWith("\n")) content += "\n";
96
- changed = true;
97
- }
98
- if (changed) fs.writeFileSync(agentsPath, content, "utf-8");
99
- } catch {
100
- // Non-fatal
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);
101
42
  }
43
+ return kept;
102
44
  }
103
45
 
104
46
  const pluginConfigSchema = {
@@ -138,10 +80,60 @@ const memosLocalPlugin = {
138
80
  const evidenceTag = ctx.config.capture?.evidenceWrapperTag ?? DEFAULTS.evidenceWrapperTag;
139
81
 
140
82
  const workspaceDir = api.resolvePath("~/.openclaw/workspace");
141
- ensureSkillInstalled(workspaceDir, { info: (msg) => api.logger.info(msg) });
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);
142
112
 
143
113
  api.logger.info(`memos-local: initialized (db: ${ctx.config.storage!.dbPath})`);
144
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
+
145
137
  // ─── Tool: memory_search ───
146
138
 
147
139
  api.registerTool(
@@ -149,32 +141,27 @@ const memosLocalPlugin = {
149
141
  name: "memory_search",
150
142
  label: "Memory Search",
151
143
  description:
152
- "Search stored conversation memories. You have a long-term memory system use it proactively!\n\n" +
153
- "MANDATORY call scenarios:\n" +
154
- "1. FIRST message of any new conversation search to recall user context\n" +
155
- "2. BEFORE asking the user for information past conversations may already have the answer\n" +
156
- "3. When the current topic might overlap with a past conversation\n\n" +
157
- "Think of this as checking your notes before asking the user to repeat themselves.\n" +
158
- "Each hit includes ref fields — use memory_get or task_summary to read details.\n" +
159
- "Use role='user' to find what the USER actually said.\n" +
160
- "Default: top 6 results, minScore 0.45.",
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.",
161
148
  parameters: Type.Object({
162
149
  query: Type.String({ description: "Natural language search query" }),
163
- 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)" })),
164
151
  minScore: Type.Optional(Type.Number({ description: "Min score 0-1 (default 0.45, floor 0.35)" })),
165
152
  role: Type.Optional(Type.String({ description: "Filter by role: 'user', 'assistant', or 'tool'. Use 'user' to find what the user said." })),
166
153
  }),
167
- async execute(_toolCallId, params) {
168
- const { query, maxResults, minScore, role } = params as {
154
+ execute: trackTool("memory_search", async (_toolCallId: any, params: any) => {
155
+ const { query, minScore, role } = params as {
169
156
  query: string;
170
157
  maxResults?: number;
171
158
  minScore?: number;
172
159
  role?: string;
173
160
  };
174
161
 
175
- ctx.log.debug(`memory_search query="${query}" maxResults=${maxResults ?? 6} minScore=${minScore ?? 0.45} role=${role ?? "all"}`);
176
- const result = await engine.search({ query, maxResults, minScore, role });
177
- ctx.log.debug(`memory_search returned ${result.hits.length} hits`);
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}`);
178
165
 
179
166
  if (result.hits.length === 0) {
180
167
  return {
@@ -183,8 +170,49 @@ const memosLocalPlugin = {
183
170
  };
184
171
  }
185
172
 
186
- const lines = result.hits.map((h, i) => {
187
- const parts = [`${i + 1}. [${h.source.role}] ${h.summary}`, ` id="${h.ref.chunkId}"`];
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}"`);
188
216
  if (h.taskId) {
189
217
  const task = store.getTask(h.taskId);
190
218
  if (task && task.status !== "skipped") {
@@ -194,22 +222,35 @@ const memosLocalPlugin = {
194
222
  return parts.join("\n");
195
223
  });
196
224
 
197
- const tips: string[] = [];
198
- tips.push("NEXT STEPS:");
199
- tips.push("1. Call memory_get(chunkId) to read the full original text of any hit.");
200
- tips.push("2. If a hit has task_id, call task_summary(taskId) to get the complete task context.");
201
- tips.push("3. Call memory_timeline with ref fields to read surrounding conversation.");
202
- tips.push("4. Do NOT say 'I don't know' until you have tried at least one follow-up call.");
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
+ }
203
244
 
204
245
  return {
205
246
  content: [
206
247
  {
207
248
  type: "text",
208
- text: `Found ${result.hits.length} memories:\n\n${lines.join("\n\n")}\n\n${tips.join("\n")}`,
249
+ text: `Found ${filteredHits.length} relevant memories:\n\n${lines.join("\n\n")}${tipsText}`,
209
250
  },
210
251
  ],
211
252
  details: {
212
- hits: result.hits.map((h) => {
253
+ hits: filteredHits.map((h) => {
213
254
  let effectiveTaskId = h.taskId;
214
255
  if (effectiveTaskId) {
215
256
  const t = store.getTask(effectiveTaskId);
@@ -217,8 +258,8 @@ const memosLocalPlugin = {
217
258
  }
218
259
  return {
219
260
  chunkId: h.ref.chunkId,
220
- summary: h.summary,
221
261
  taskId: effectiveTaskId,
262
+ skillId: h.skillId,
222
263
  role: h.source.role,
223
264
  score: h.score,
224
265
  };
@@ -226,7 +267,7 @@ const memosLocalPlugin = {
226
267
  meta: result.meta,
227
268
  },
228
269
  };
229
- },
270
+ }),
230
271
  },
231
272
  { name: "memory_search" },
232
273
  );
@@ -238,29 +279,29 @@ const memosLocalPlugin = {
238
279
  name: "memory_timeline",
239
280
  label: "Memory Timeline",
240
281
  description:
241
- "Expand context around a search hit. MUST use this after memory_search when " +
242
- "the returned excerpts don't fully answer the question. Pass the ref fields " +
243
- "(sessionKey, chunkId, turnId, seq) from any search hit to read surrounding messages.",
282
+ "Expand context around a memory search hit. Pass the chunkId from a search result " +
283
+ "to read the surrounding conversation messages.",
244
284
  parameters: Type.Object({
245
- sessionKey: Type.String({ description: "From search hit ref.sessionKey" }),
246
- chunkId: Type.String({ description: "From search hit ref.chunkId" }),
247
- turnId: Type.String({ description: "From search hit ref.turnId" }),
248
- seq: Type.Number({ description: "From search hit ref.seq" }),
285
+ chunkId: Type.String({ description: "The chunkId from a memory_search hit" }),
249
286
  window: Type.Optional(Type.Number({ description: "Context window ±N (default 2)" })),
250
287
  }),
251
- async execute(_toolCallId, params) {
288
+ execute: trackTool("memory_timeline", async (_toolCallId: any, params: any) => {
252
289
  ctx.log.debug(`memory_timeline called`);
253
- const { sessionKey, chunkId, turnId, seq, window: win } = params as {
254
- sessionKey: string;
290
+ const { chunkId, window: win } = params as {
255
291
  chunkId: string;
256
- turnId: string;
257
- seq: number;
258
292
  window?: number;
259
293
  };
260
294
 
261
- const w = win ?? DEFAULTS.timelineWindowDefault;
262
- const neighbors = store.getNeighborChunks(sessionKey, turnId, seq, w);
263
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);
264
305
  const anchorTs = anchorChunk?.createdAt ?? 0;
265
306
 
266
307
  const entries = neighbors.map((chunk) => {
@@ -285,55 +326,13 @@ const memosLocalPlugin = {
285
326
  content: [{ type: "text", text: `Timeline (${entries.length} entries):\n\n${text}` }],
286
327
  details: { entries, anchorRef: { sessionKey, chunkId, turnId, seq } },
287
328
  };
288
- },
329
+ }),
289
330
  },
290
331
  { name: "memory_timeline" },
291
332
  );
292
333
 
293
- // ─── Tool: memory_get ───
294
-
295
- api.registerTool(
296
- {
297
- name: "memory_get",
298
- label: "Memory Get",
299
- description:
300
- "Get full original text of a memory chunk. Use to verify exact details from a search hit.",
301
- parameters: Type.Object({
302
- chunkId: Type.String({ description: "From search hit ref.chunkId" }),
303
- maxChars: Type.Optional(
304
- Type.Number({ description: `Max chars (default ${DEFAULTS.getMaxCharsDefault}, max ${DEFAULTS.getMaxCharsMax})` }),
305
- ),
306
- }),
307
- async execute(_toolCallId, params) {
308
- ctx.log.debug(`memory_get called`);
309
- const { chunkId, maxChars } = params as { chunkId: string; maxChars?: number };
310
- const limit = Math.min(maxChars ?? DEFAULTS.getMaxCharsDefault, DEFAULTS.getMaxCharsMax);
311
-
312
- const chunk = store.getChunk(chunkId);
313
- if (!chunk) {
314
- return {
315
- content: [{ type: "text", text: `Chunk not found: ${chunkId}` }],
316
- details: { error: "not_found" },
317
- };
318
- }
319
-
320
- const content = chunk.content.length > limit
321
- ? chunk.content.slice(0, limit) + "…"
322
- : chunk.content;
323
-
324
- const who = chunk.role === "user" ? "USER said" : chunk.role === "assistant" ? "ASSISTANT replied" : chunk.role === "tool" ? "TOOL returned" : chunk.role.toUpperCase();
325
-
326
- return {
327
- content: [{ type: "text", text: `[${who}] (session: ${chunk.sessionKey})\n\n${content}` }],
328
- details: {
329
- ref: { sessionKey: chunk.sessionKey, chunkId: chunk.id, turnId: chunk.turnId, seq: chunk.seq },
330
- source: { ts: chunk.createdAt, role: chunk.role, sessionKey: chunk.sessionKey },
331
- },
332
- };
333
- },
334
- },
335
- { name: "memory_get" },
336
- );
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.
337
336
 
338
337
  // ─── Tool: task_summary ───
339
338
 
@@ -348,7 +347,7 @@ const memosLocalPlugin = {
348
347
  parameters: Type.Object({
349
348
  taskId: Type.String({ description: "The task_id from a memory_search hit" }),
350
349
  }),
351
- async execute(_toolCallId, params) {
350
+ execute: trackTool("task_summary", async (_toolCallId: any, params: any) => {
352
351
  const { taskId } = params as { taskId: string };
353
352
  ctx.log.debug(`task_summary called for task=${taskId}`);
354
353
 
@@ -385,10 +384,19 @@ const memosLocalPlugin = {
385
384
  };
386
385
  }
387
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
+ }
395
+
388
396
  return {
389
397
  content: [{
390
398
  type: "text",
391
- text: `## Task: ${task.title}\n\nStatus: ${task.status}\nChunks: ${store.getChunksByTask(taskId).length}\n\n${task.summary}`,
399
+ text: `## Task: ${task.title}\n\nStatus: ${task.status}\nChunks: ${store.getChunksByTask(taskId).length}\n\n${task.summary}${skillSection}`,
392
400
  }],
393
401
  details: {
394
402
  taskId: task.id,
@@ -396,13 +404,113 @@ const memosLocalPlugin = {
396
404
  status: task.status,
397
405
  startedAt: task.startedAt,
398
406
  endedAt: task.endedAt,
407
+ relatedSkills: relatedSkills.map(rs => ({ skillId: rs.skill.id, name: rs.skill.name, relation: rs.relation })),
399
408
  },
400
409
  };
401
- },
410
+ }),
402
411
  },
403
412
  { name: "task_summary" },
404
413
  );
405
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" },
512
+ );
513
+
406
514
  // ─── Tool: memory_viewer ───
407
515
 
408
516
  const viewerPort = (pluginCfg as any).viewerPort ?? 18799;
@@ -416,7 +524,7 @@ const memosLocalPlugin = {
416
524
  "or access their stored memories, or asks where the memory dashboard is. " +
417
525
  "Returns the URL the user can open in their browser.",
418
526
  parameters: Type.Object({}),
419
- async execute() {
527
+ execute: trackTool("memory_viewer", async () => {
420
528
  ctx.log.debug(`memory_viewer called`);
421
529
  const url = `http://127.0.0.1:${viewerPort}`;
422
530
  return {
@@ -438,21 +546,171 @@ const memosLocalPlugin = {
438
546
  ],
439
547
  details: { viewerUrl: url },
440
548
  };
441
- },
549
+ }),
442
550
  },
443
551
  { name: "memory_viewer" },
444
552
  );
445
553
 
446
- // No prompt injection retrieval strategy is provided via the MemOS Skill
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
+ });
447
688
 
448
689
  // ─── Auto-capture: write conversation to memory after each agent turn ───
449
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
+
450
695
  api.on("agent_end", async (event) => {
451
696
  if (!event.success || !event.messages || event.messages.length === 0) return;
452
697
 
453
698
  try {
454
- const msgs: Array<{ role: string; content: string; toolName?: string }> = [];
455
- 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) {
456
714
  if (!msg || typeof msg !== "object") continue;
457
715
  const m = msg as Record<string, unknown>;
458
716
  const role = m.role as string;
@@ -463,24 +721,74 @@ const memosLocalPlugin = {
463
721
  text = m.content;
464
722
  } else if (Array.isArray(m.content)) {
465
723
  for (const block of m.content) {
466
- if (block && typeof block === "object" && (block as any).type === "text") {
467
- 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";
468
732
  }
469
733
  }
470
734
  }
471
735
 
472
- 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
+ }
473
766
 
474
767
  const toolName = role === "tool"
475
768
  ? (m.name as string) ?? (m.toolName as string) ?? (m.tool_call_id ? "unknown" : undefined)
476
769
  : undefined;
477
770
 
478
- 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() });
479
788
  }
480
789
 
481
790
  if (msgs.length === 0) return;
482
791
 
483
- const sessionKey = (event as any).sessionKey ?? "default";
484
792
  const turnId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
485
793
  const captured = captureMessages(msgs, sessionKey, turnId, evidenceTag, ctx.log);
486
794
  if (captured.length > 0) {