@memtensor/memos-local-openclaw-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/.env.example +11 -0
  2. package/README.md +251 -0
  3. package/SKILL.md +43 -0
  4. package/dist/capture/index.d.ts +16 -0
  5. package/dist/capture/index.d.ts.map +1 -0
  6. package/dist/capture/index.js +80 -0
  7. package/dist/capture/index.js.map +1 -0
  8. package/dist/config.d.ts +4 -0
  9. package/dist/config.d.ts.map +1 -0
  10. package/dist/config.js +96 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/embedding/index.d.ts +12 -0
  13. package/dist/embedding/index.d.ts.map +1 -0
  14. package/dist/embedding/index.js +75 -0
  15. package/dist/embedding/index.js.map +1 -0
  16. package/dist/embedding/local.d.ts +3 -0
  17. package/dist/embedding/local.d.ts.map +1 -0
  18. package/dist/embedding/local.js +65 -0
  19. package/dist/embedding/local.js.map +1 -0
  20. package/dist/embedding/providers/cohere.d.ts +4 -0
  21. package/dist/embedding/providers/cohere.d.ts.map +1 -0
  22. package/dist/embedding/providers/cohere.js +57 -0
  23. package/dist/embedding/providers/cohere.js.map +1 -0
  24. package/dist/embedding/providers/gemini.d.ts +3 -0
  25. package/dist/embedding/providers/gemini.d.ts.map +1 -0
  26. package/dist/embedding/providers/gemini.js +31 -0
  27. package/dist/embedding/providers/gemini.js.map +1 -0
  28. package/dist/embedding/providers/mistral.d.ts +3 -0
  29. package/dist/embedding/providers/mistral.d.ts.map +1 -0
  30. package/dist/embedding/providers/mistral.js +25 -0
  31. package/dist/embedding/providers/mistral.js.map +1 -0
  32. package/dist/embedding/providers/openai.d.ts +3 -0
  33. package/dist/embedding/providers/openai.d.ts.map +1 -0
  34. package/dist/embedding/providers/openai.js +35 -0
  35. package/dist/embedding/providers/openai.js.map +1 -0
  36. package/dist/embedding/providers/voyage.d.ts +3 -0
  37. package/dist/embedding/providers/voyage.d.ts.map +1 -0
  38. package/dist/embedding/providers/voyage.js +25 -0
  39. package/dist/embedding/providers/voyage.js.map +1 -0
  40. package/dist/index.d.ts +44 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +75 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/ingest/chunker.d.ts +15 -0
  45. package/dist/ingest/chunker.d.ts.map +1 -0
  46. package/dist/ingest/chunker.js +193 -0
  47. package/dist/ingest/chunker.js.map +1 -0
  48. package/dist/ingest/dedup.d.ts +11 -0
  49. package/dist/ingest/dedup.d.ts.map +1 -0
  50. package/dist/ingest/dedup.js +29 -0
  51. package/dist/ingest/dedup.js.map +1 -0
  52. package/dist/ingest/providers/anthropic.d.ts +3 -0
  53. package/dist/ingest/providers/anthropic.d.ts.map +1 -0
  54. package/dist/ingest/providers/anthropic.js +33 -0
  55. package/dist/ingest/providers/anthropic.js.map +1 -0
  56. package/dist/ingest/providers/bedrock.d.ts +8 -0
  57. package/dist/ingest/providers/bedrock.d.ts.map +1 -0
  58. package/dist/ingest/providers/bedrock.js +41 -0
  59. package/dist/ingest/providers/bedrock.js.map +1 -0
  60. package/dist/ingest/providers/gemini.d.ts +3 -0
  61. package/dist/ingest/providers/gemini.d.ts.map +1 -0
  62. package/dist/ingest/providers/gemini.js +31 -0
  63. package/dist/ingest/providers/gemini.js.map +1 -0
  64. package/dist/ingest/providers/index.d.ts +9 -0
  65. package/dist/ingest/providers/index.d.ts.map +1 -0
  66. package/dist/ingest/providers/index.js +68 -0
  67. package/dist/ingest/providers/index.js.map +1 -0
  68. package/dist/ingest/providers/openai.d.ts +3 -0
  69. package/dist/ingest/providers/openai.d.ts.map +1 -0
  70. package/dist/ingest/providers/openai.js +41 -0
  71. package/dist/ingest/providers/openai.js.map +1 -0
  72. package/dist/ingest/worker.d.ts +21 -0
  73. package/dist/ingest/worker.d.ts.map +1 -0
  74. package/dist/ingest/worker.js +111 -0
  75. package/dist/ingest/worker.js.map +1 -0
  76. package/dist/recall/engine.d.ts +23 -0
  77. package/dist/recall/engine.d.ts.map +1 -0
  78. package/dist/recall/engine.js +153 -0
  79. package/dist/recall/engine.js.map +1 -0
  80. package/dist/recall/mmr.d.ts +17 -0
  81. package/dist/recall/mmr.d.ts.map +1 -0
  82. package/dist/recall/mmr.js +51 -0
  83. package/dist/recall/mmr.js.map +1 -0
  84. package/dist/recall/recency.d.ts +20 -0
  85. package/dist/recall/recency.d.ts.map +1 -0
  86. package/dist/recall/recency.js +26 -0
  87. package/dist/recall/recency.js.map +1 -0
  88. package/dist/recall/rrf.d.ts +16 -0
  89. package/dist/recall/rrf.d.ts.map +1 -0
  90. package/dist/recall/rrf.js +15 -0
  91. package/dist/recall/rrf.js.map +1 -0
  92. package/dist/storage/sqlite.d.ts +34 -0
  93. package/dist/storage/sqlite.d.ts.map +1 -0
  94. package/dist/storage/sqlite.js +274 -0
  95. package/dist/storage/sqlite.js.map +1 -0
  96. package/dist/storage/vector.d.ts +13 -0
  97. package/dist/storage/vector.d.ts.map +1 -0
  98. package/dist/storage/vector.js +33 -0
  99. package/dist/storage/vector.js.map +1 -0
  100. package/dist/tools/index.d.ts +4 -0
  101. package/dist/tools/index.d.ts.map +1 -0
  102. package/dist/tools/index.js +10 -0
  103. package/dist/tools/index.js.map +1 -0
  104. package/dist/tools/memory-get.d.ts +4 -0
  105. package/dist/tools/memory-get.d.ts.map +1 -0
  106. package/dist/tools/memory-get.js +59 -0
  107. package/dist/tools/memory-get.js.map +1 -0
  108. package/dist/tools/memory-search.d.ts +4 -0
  109. package/dist/tools/memory-search.d.ts.map +1 -0
  110. package/dist/tools/memory-search.js +36 -0
  111. package/dist/tools/memory-search.js.map +1 -0
  112. package/dist/tools/memory-timeline.d.ts +4 -0
  113. package/dist/tools/memory-timeline.d.ts.map +1 -0
  114. package/dist/tools/memory-timeline.js +64 -0
  115. package/dist/tools/memory-timeline.js.map +1 -0
  116. package/dist/types.d.ts +158 -0
  117. package/dist/types.d.ts.map +1 -0
  118. package/dist/types.js +25 -0
  119. package/dist/types.js.map +1 -0
  120. package/dist/viewer/html.d.ts +2 -0
  121. package/dist/viewer/html.d.ts.map +1 -0
  122. package/dist/viewer/html.js +686 -0
  123. package/dist/viewer/html.js.map +1 -0
  124. package/dist/viewer/server.d.ts +48 -0
  125. package/dist/viewer/server.d.ts.map +1 -0
  126. package/dist/viewer/server.js +470 -0
  127. package/dist/viewer/server.js.map +1 -0
  128. package/index.ts +357 -0
  129. package/openclaw.plugin.json +57 -0
  130. package/package.json +57 -0
  131. package/src/capture/index.ts +92 -0
  132. package/src/config.ts +67 -0
  133. package/src/embedding/index.ts +76 -0
  134. package/src/embedding/local.ts +35 -0
  135. package/src/embedding/providers/cohere.ts +69 -0
  136. package/src/embedding/providers/gemini.ts +41 -0
  137. package/src/embedding/providers/mistral.ts +32 -0
  138. package/src/embedding/providers/openai.ts +42 -0
  139. package/src/embedding/providers/voyage.ts +32 -0
  140. package/src/index.ts +106 -0
  141. package/src/ingest/chunker.ts +217 -0
  142. package/src/ingest/dedup.ts +37 -0
  143. package/src/ingest/providers/anthropic.ts +41 -0
  144. package/src/ingest/providers/bedrock.ts +50 -0
  145. package/src/ingest/providers/gemini.ts +41 -0
  146. package/src/ingest/providers/index.ts +67 -0
  147. package/src/ingest/providers/openai.ts +48 -0
  148. package/src/ingest/worker.ts +130 -0
  149. package/src/recall/engine.ts +182 -0
  150. package/src/recall/mmr.ts +60 -0
  151. package/src/recall/recency.ts +27 -0
  152. package/src/recall/rrf.ts +31 -0
  153. package/src/storage/sqlite.ts +305 -0
  154. package/src/storage/vector.ts +39 -0
  155. package/src/tools/index.ts +3 -0
  156. package/src/tools/memory-get.ts +68 -0
  157. package/src/tools/memory-search.ts +36 -0
  158. package/src/tools/memory-timeline.ts +73 -0
  159. package/src/types.ts +214 -0
  160. package/src/viewer/html.ts +682 -0
  161. package/src/viewer/server.ts +464 -0
  162. package/www/index.html +606 -0
package/index.ts ADDED
@@ -0,0 +1,357 @@
1
+ /**
2
+ * OpenClaw Plugin Entry Point — memos-local
3
+ *
4
+ * Full-write local memory with hybrid retrieval (RRF + MMR + recency).
5
+ * Provides: memory_search, memory_timeline, memory_get
6
+ */
7
+
8
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
9
+ import { Type } from "@sinclair/typebox";
10
+ import { buildContext } from "./src/config";
11
+ import { SqliteStore } from "./src/storage/sqlite";
12
+ import { Embedder } from "./src/embedding";
13
+ import { IngestWorker } from "./src/ingest/worker";
14
+ import { RecallEngine } from "./src/recall/engine";
15
+ import { captureMessages } from "./src/capture";
16
+ import { DEFAULTS } from "./src/types";
17
+ import { ViewerServer } from "./src/viewer/server";
18
+
19
+ const pluginConfigSchema = {
20
+ type: "object" as const,
21
+ additionalProperties: true,
22
+ 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
+ },
31
+ },
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
+ },
44
+ };
45
+
46
+ const memosLocalPlugin = {
47
+ id: "memos-local",
48
+ name: "MemOS Local Memory",
49
+ description:
50
+ "Full-write local conversation memory with hybrid search (RRF + MMR + recency). " +
51
+ "Provides memory_search, memory_timeline, memory_get for progressive recall.",
52
+ kind: "memory" as const,
53
+ configSchema: pluginConfigSchema,
54
+
55
+ register(api: OpenClawPluginApi) {
56
+ const pluginCfg = (api.pluginConfig ?? {}) as Record<string, unknown>;
57
+ const stateDir = api.resolvePath("~/.openclaw");
58
+ const ctx = buildContext(stateDir, process.cwd(), pluginCfg as any, {
59
+ debug: (msg: string) => api.logger.info(`[debug] ${msg}`),
60
+ info: (msg: string) => api.logger.info(msg),
61
+ warn: (msg: string) => api.logger.warn(msg),
62
+ error: (msg: string) => api.logger.warn(`[error] ${msg}`),
63
+ });
64
+
65
+ const store = new SqliteStore(ctx.config.storage!.dbPath!, ctx.log);
66
+ const embedder = new Embedder(ctx.config.embedding, ctx.log);
67
+ const worker = new IngestWorker(store, embedder, ctx);
68
+ const engine = new RecallEngine(store, embedder, ctx);
69
+ const evidenceTag = ctx.config.capture?.evidenceWrapperTag ?? DEFAULTS.evidenceWrapperTag;
70
+
71
+ api.logger.info(`memos-local: initialized (db: ${ctx.config.storage!.dbPath})`);
72
+
73
+ // ─── Tool: memory_search ───
74
+
75
+ api.registerTool(
76
+ {
77
+ name: "memory_search",
78
+ label: "Memory Search",
79
+ 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.",
82
+ parameters: Type.Object({
83
+ query: Type.String({ description: "Natural language search query" }),
84
+ maxResults: Type.Optional(Type.Number({ description: "Max results (default 6, max 20)" })),
85
+ minScore: Type.Optional(Type.Number({ description: "Min score 0-1 (default 0.45, floor 0.35)" })),
86
+ }),
87
+ async execute(_toolCallId, params) {
88
+ const { query, maxResults, minScore } = params as {
89
+ query: string;
90
+ maxResults?: number;
91
+ minScore?: number;
92
+ };
93
+
94
+ const result = await engine.search({ query, maxResults, minScore });
95
+
96
+ if (result.hits.length === 0) {
97
+ return {
98
+ content: [{ type: "text", text: result.meta.note ?? "No relevant memories found." }],
99
+ details: { meta: result.meta },
100
+ };
101
+ }
102
+
103
+ const roleLabel = (r: string) => r === "user" ? "[USER said]" : r === "assistant" ? "[ASSISTANT replied]" : r === "tool" ? "[TOOL returned]" : `[${r.toUpperCase()}]`;
104
+
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");
111
+
112
+ return {
113
+ content: [
114
+ {
115
+ type: "text",
116
+ text: `Found ${result.hits.length} memories (minScore=${result.meta.usedMinScore}):\n\n${text}`,
117
+ },
118
+ ],
119
+ 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
+ })),
128
+ meta: result.meta,
129
+ },
130
+ };
131
+ },
132
+ },
133
+ { name: "memory_search" },
134
+ );
135
+
136
+ // ─── Tool: memory_timeline ───
137
+
138
+ api.registerTool(
139
+ {
140
+ name: "memory_timeline",
141
+ label: "Memory Timeline",
142
+ description:
143
+ "Get neighboring context around a memory ref. Use after memory_search to expand context.",
144
+ 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" }),
149
+ window: Type.Optional(Type.Number({ description: "Context window ±N (default 2)" })),
150
+ }),
151
+ async execute(_toolCallId, params) {
152
+ const { sessionKey, chunkId, turnId, seq, window: win } = params as {
153
+ sessionKey: string;
154
+ chunkId: string;
155
+ turnId: string;
156
+ seq: number;
157
+ window?: number;
158
+ };
159
+
160
+ const w = win ?? DEFAULTS.timelineWindowDefault;
161
+ const neighbors = store.getNeighborChunks(sessionKey, turnId, seq, w);
162
+ const anchorChunk = store.getChunk(chunkId);
163
+ const anchorTs = anchorChunk?.createdAt ?? 0;
164
+
165
+ const entries = neighbors.map((chunk) => {
166
+ let relation: "before" | "current" | "after" = "before";
167
+ if (chunk.id === chunkId) relation = "current";
168
+ else if (chunk.createdAt > anchorTs) relation = "after";
169
+
170
+ return {
171
+ relation,
172
+ role: chunk.role,
173
+ excerpt: chunk.content.slice(0, DEFAULTS.excerptMaxChars),
174
+ ts: chunk.createdAt,
175
+ };
176
+ });
177
+
178
+ const rl = (r: string) => r === "user" ? "USER" : r === "assistant" ? "ASSISTANT" : r.toUpperCase();
179
+ const text = entries
180
+ .map((e) => `[${e.relation}] ${rl(e.role)}: ${e.excerpt.slice(0, 150)}`)
181
+ .join("\n");
182
+
183
+ return {
184
+ content: [{ type: "text", text: `Timeline (${entries.length} entries):\n\n${text}` }],
185
+ details: { entries, anchorRef: { sessionKey, chunkId, turnId, seq } },
186
+ };
187
+ },
188
+ },
189
+ { name: "memory_timeline" },
190
+ );
191
+
192
+ // ─── Tool: memory_get ───
193
+
194
+ api.registerTool(
195
+ {
196
+ name: "memory_get",
197
+ label: "Memory Get",
198
+ description:
199
+ "Get full original text of a memory chunk. Use to verify exact details from a search hit.",
200
+ 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
+ ),
205
+ }),
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);
209
+
210
+ const chunk = store.getChunk(chunkId);
211
+ if (!chunk) {
212
+ return {
213
+ content: [{ type: "text", text: `Chunk not found: ${chunkId}` }],
214
+ details: { error: "not_found" },
215
+ };
216
+ }
217
+
218
+ const content = chunk.content.length > limit
219
+ ? chunk.content.slice(0, limit) + "…"
220
+ : chunk.content;
221
+
222
+ const who = chunk.role === "user" ? "USER said" : chunk.role === "assistant" ? "ASSISTANT replied" : chunk.role === "tool" ? "TOOL returned" : chunk.role.toUpperCase();
223
+
224
+ return {
225
+ content: [{ type: "text", text: `[${who}] (session: ${chunk.sessionKey})\n\n${content}` }],
226
+ 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 },
229
+ },
230
+ };
231
+ },
232
+ },
233
+ { name: "memory_get" },
234
+ );
235
+
236
+ // ─── Tool: memory_viewer ───
237
+
238
+ api.registerTool(
239
+ {
240
+ name: "memory_viewer",
241
+ label: "Open Memory Viewer",
242
+ description:
243
+ "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.",
244
+ parameters: Type.Object({}),
245
+ async execute() {
246
+ const url = `http://127.0.0.1:${viewerPort}`;
247
+ return {
248
+ content: [
249
+ {
250
+ type: "text",
251
+ text: [
252
+ `MemOS Memory Viewer: ${url}`,
253
+ "",
254
+ "Open this URL in your browser to:",
255
+ "- Browse all stored memories with a clean timeline view",
256
+ "- Semantic search (powered by your embedding model)",
257
+ "- Create, edit, and delete memories",
258
+ "- Filter by session, role, and time range",
259
+ "",
260
+ "First visit requires setting a password to protect your data.",
261
+ ].join("\n"),
262
+ },
263
+ ],
264
+ details: { viewerUrl: url },
265
+ };
266
+ },
267
+ },
268
+ { name: "memory_viewer" },
269
+ );
270
+
271
+ // ─── Auto-capture: write conversation to memory after each agent turn ───
272
+
273
+ api.on("agent_end", async (event) => {
274
+ if (!event.success || !event.messages || event.messages.length === 0) return;
275
+
276
+ try {
277
+ const msgs: Array<{ role: string; content: string; toolName?: string }> = [];
278
+ for (const msg of event.messages) {
279
+ if (!msg || typeof msg !== "object") continue;
280
+ const m = msg as Record<string, unknown>;
281
+ const role = m.role as string;
282
+ if (role !== "user" && role !== "assistant" && role !== "tool") continue;
283
+
284
+ let text = "";
285
+ if (typeof m.content === "string") {
286
+ text = m.content;
287
+ } else if (Array.isArray(m.content)) {
288
+ for (const block of m.content) {
289
+ if (block && typeof block === "object" && (block as any).type === "text") {
290
+ text += (block as any).text + "\n";
291
+ }
292
+ }
293
+ }
294
+
295
+ if (!text.trim()) continue;
296
+
297
+ const toolName = role === "tool"
298
+ ? (m.name as string) ?? (m.toolName as string) ?? (m.tool_call_id ? "unknown" : undefined)
299
+ : undefined;
300
+
301
+ msgs.push({ role, content: text.trim(), toolName });
302
+ }
303
+
304
+ if (msgs.length === 0) return;
305
+
306
+ const sessionKey = (event as any).sessionKey ?? "default";
307
+ const turnId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
308
+ const captured = captureMessages(msgs, sessionKey, turnId, evidenceTag, ctx.log);
309
+ if (captured.length > 0) {
310
+ worker.enqueue(captured);
311
+ }
312
+ } catch (err) {
313
+ api.logger.warn(`memos-local: capture failed: ${String(err)}`);
314
+ }
315
+ });
316
+
317
+ // ─── Memory Viewer (web UI) ───
318
+
319
+ const viewerPort = (pluginCfg as any).viewerPort ?? 18799;
320
+ const viewer = new ViewerServer({
321
+ store,
322
+ embedder,
323
+ port: viewerPort,
324
+ log: ctx.log,
325
+ dataDir: stateDir,
326
+ });
327
+
328
+ // ─── Service lifecycle ───
329
+
330
+ api.registerService({
331
+ id: "memos-local",
332
+ start: async () => {
333
+ try {
334
+ const viewerUrl = await viewer.start();
335
+ api.logger.info(`memos-local: started (embedding: ${embedder.provider})`);
336
+ api.logger.info(`╔══════════════════════════════════════════╗`);
337
+ api.logger.info(`║ MemOS Memory Viewer ║`);
338
+ api.logger.info(`║ → ${viewerUrl.padEnd(37)}║`);
339
+ api.logger.info(`║ Open in browser to manage memories ║`);
340
+ api.logger.info(`╚══════════════════════════════════════════╝`);
341
+ api.logger.info(`memos-local: password reset token: ${viewer.getResetToken()}`);
342
+ api.logger.info(`memos-local: forgot password? Use the reset token on the login page.`);
343
+ } catch (err) {
344
+ api.logger.warn(`memos-local: viewer failed to start: ${err}`);
345
+ api.logger.info(`memos-local: started (embedding: ${embedder.provider})`);
346
+ }
347
+ },
348
+ stop: () => {
349
+ viewer.stop();
350
+ store.close();
351
+ api.logger.info("memos-local: stopped");
352
+ },
353
+ });
354
+ },
355
+ };
356
+
357
+ export default memosLocalPlugin;
@@ -0,0 +1,57 @@
1
+ {
2
+ "id": "memos-local",
3
+ "kind": "memory",
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": true,
7
+ "properties": {
8
+ "embedding": {
9
+ "type": "object",
10
+ "properties": {
11
+ "provider": { "type": "string" },
12
+ "endpoint": { "type": "string" },
13
+ "apiKey": { "type": "string" },
14
+ "model": { "type": "string" }
15
+ }
16
+ },
17
+ "summarizer": {
18
+ "type": "object",
19
+ "properties": {
20
+ "provider": { "type": "string" },
21
+ "endpoint": { "type": "string" },
22
+ "apiKey": { "type": "string" },
23
+ "model": { "type": "string" },
24
+ "temperature": { "type": "number" }
25
+ }
26
+ }
27
+ }
28
+ },
29
+ "uiHints": {
30
+ "embedding.endpoint": {
31
+ "label": "Embedding Endpoint",
32
+ "placeholder": "https://api.openai.com/v1",
33
+ "help": "OpenAI-compatible embedding API base URL"
34
+ },
35
+ "embedding.apiKey": {
36
+ "label": "Embedding API Key",
37
+ "sensitive": true,
38
+ "help": "API key for embedding service (or use ${ENV_VAR})"
39
+ },
40
+ "embedding.model": {
41
+ "label": "Embedding Model",
42
+ "placeholder": "bge-m3"
43
+ },
44
+ "summarizer.endpoint": {
45
+ "label": "Summarizer Endpoint",
46
+ "placeholder": "https://api.openai.com/v1"
47
+ },
48
+ "summarizer.apiKey": {
49
+ "label": "Summarizer API Key",
50
+ "sensitive": true
51
+ },
52
+ "summarizer.model": {
53
+ "label": "Summarizer Model",
54
+ "placeholder": "gpt-4o-mini"
55
+ }
56
+ }
57
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@memtensor/memos-local-openclaw-plugin",
3
+ "version": "0.1.0",
4
+ "description": "MemOS Local memory plugin for OpenClaw — full-write, hybrid-recall, progressive retrieval",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "index.ts",
10
+ "src",
11
+ "dist",
12
+ "openclaw.plugin.json",
13
+ "SKILL.md",
14
+ "README.md",
15
+ ".env.example",
16
+ "www"
17
+ ],
18
+ "openclaw": {
19
+ "extensions": [
20
+ "./index.ts"
21
+ ],
22
+ "installDependencies": true
23
+ },
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "dev": "tsc --watch",
27
+ "lint": "eslint src --ext .ts",
28
+ "test": "vitest run",
29
+ "test:watch": "vitest",
30
+ "prepublishOnly": "npm run build"
31
+ },
32
+ "keywords": [
33
+ "openclaw",
34
+ "plugin",
35
+ "memory",
36
+ "memos",
37
+ "rag"
38
+ ],
39
+ "license": "MIT",
40
+ "engines": {
41
+ "node": ">=18.0.0"
42
+ },
43
+ "dependencies": {
44
+ "@sinclair/typebox": "^0.34.48",
45
+ "@xenova/transformers": "^2.17.0",
46
+ "better-sqlite3": "^11.7.0",
47
+ "uuid": "^10.0.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/better-sqlite3": "^7.6.12",
51
+ "@types/node": "^22.10.0",
52
+ "@types/uuid": "^10.0.0",
53
+ "tsx": "^4.21.0",
54
+ "typescript": "^5.7.0",
55
+ "vitest": "^2.1.0"
56
+ }
57
+ }
@@ -0,0 +1,92 @@
1
+ import type { ConversationMessage, Role, Logger } from "../types";
2
+ import { DEFAULTS } from "../types";
3
+
4
+ const SKIP_ROLES: Set<Role> = new Set(["system"]);
5
+
6
+ const SELF_TOOLS = new Set([
7
+ "memory_search",
8
+ "memory_timeline",
9
+ "memory_get",
10
+ "memory_viewer",
11
+ ]);
12
+
13
+ /**
14
+ * Filter and extract writable messages from a conversation turn.
15
+ *
16
+ * - Keep user, assistant, and tool messages
17
+ * - Skip system prompts
18
+ * - Skip tool results from our own memory tools (prevents memory loop)
19
+ * - Truncate long tool results to avoid storage bloat
20
+ * - Strip injected evidence blocks wrapped in [STORED_MEMORY]...[/STORED_MEMORY]
21
+ */
22
+ export function captureMessages(
23
+ messages: Array<{ role: string; content: string; toolName?: string }>,
24
+ sessionKey: string,
25
+ turnId: string,
26
+ evidenceTag: string,
27
+ log: Logger,
28
+ ): ConversationMessage[] {
29
+ const now = Date.now();
30
+ const result: ConversationMessage[] = [];
31
+
32
+ for (const msg of messages) {
33
+ const role = msg.role as Role;
34
+ if (SKIP_ROLES.has(role)) continue;
35
+ if (!msg.content || msg.content.trim().length === 0) continue;
36
+
37
+ if (role === "tool") {
38
+ if (msg.toolName && SELF_TOOLS.has(msg.toolName)) {
39
+ log.debug(`Skipping self-tool result: ${msg.toolName}`);
40
+ continue;
41
+ }
42
+
43
+ let content = msg.content.trim();
44
+ const maxChars = DEFAULTS.toolResultMaxChars;
45
+ if (content.length > maxChars) {
46
+ content = content.slice(0, maxChars) + `\n\n[truncated — original ${content.length} chars]`;
47
+ }
48
+
49
+ const toolLabel = msg.toolName ? `[tool:${msg.toolName}] ` : "[tool] ";
50
+ result.push({
51
+ role: "tool",
52
+ content: toolLabel + content,
53
+ timestamp: now,
54
+ turnId,
55
+ sessionKey,
56
+ toolName: msg.toolName,
57
+ });
58
+ continue;
59
+ }
60
+
61
+ const cleaned = stripEvidenceBlocks(msg.content, evidenceTag);
62
+ if (cleaned.trim().length === 0) continue;
63
+
64
+ result.push({
65
+ role,
66
+ content: cleaned,
67
+ timestamp: now,
68
+ turnId,
69
+ sessionKey,
70
+ });
71
+ }
72
+
73
+ log.debug(`Captured ${result.length}/${messages.length} messages for session=${sessionKey} turn=${turnId}`);
74
+ return result;
75
+ }
76
+
77
+ function stripEvidenceBlocks(text: string, tag: string): string {
78
+ const openTag = `[${tag}]`;
79
+ const closeTag = `[/${tag}]`;
80
+ let result = text;
81
+ let safety = 0;
82
+
83
+ while (result.includes(openTag) && result.includes(closeTag) && safety < 50) {
84
+ const start = result.indexOf(openTag);
85
+ const end = result.indexOf(closeTag, start);
86
+ if (end === -1) break;
87
+ result = result.slice(0, start) + result.slice(end + closeTag.length);
88
+ safety++;
89
+ }
90
+
91
+ return result;
92
+ }
package/src/config.ts ADDED
@@ -0,0 +1,67 @@
1
+ import * as path from "path";
2
+ import { DEFAULTS, type MemosLocalConfig, type PluginContext, type Logger } from "./types";
3
+
4
+ const ENV_RE = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
5
+
6
+ function resolveEnvVars(value: string): string {
7
+ return value.replace(ENV_RE, (_, name) => process.env[name] ?? "");
8
+ }
9
+
10
+ function deepResolveEnv<T>(obj: T): T {
11
+ if (typeof obj === "string") return resolveEnvVars(obj) as unknown as T;
12
+ if (Array.isArray(obj)) return obj.map(deepResolveEnv) as unknown as T;
13
+ if (obj && typeof obj === "object") {
14
+ const out: Record<string, unknown> = {};
15
+ for (const [k, v] of Object.entries(obj)) {
16
+ out[k] = deepResolveEnv(v);
17
+ }
18
+ return out as T;
19
+ }
20
+ return obj;
21
+ }
22
+
23
+ export function resolveConfig(raw: Partial<MemosLocalConfig> | undefined, stateDir: string): MemosLocalConfig {
24
+ const cfg = deepResolveEnv(raw ?? {});
25
+ return {
26
+ ...cfg,
27
+ storage: {
28
+ dbPath: cfg.storage?.dbPath ?? path.join(stateDir, "memos-local", "memos.db"),
29
+ },
30
+ recall: {
31
+ maxResultsDefault: cfg.recall?.maxResultsDefault ?? DEFAULTS.maxResultsDefault,
32
+ maxResultsMax: cfg.recall?.maxResultsMax ?? DEFAULTS.maxResultsMax,
33
+ minScoreDefault: cfg.recall?.minScoreDefault ?? DEFAULTS.minScoreDefault,
34
+ minScoreFloor: cfg.recall?.minScoreFloor ?? DEFAULTS.minScoreFloor,
35
+ rrfK: cfg.recall?.rrfK ?? DEFAULTS.rrfK,
36
+ mmrLambda: cfg.recall?.mmrLambda ?? DEFAULTS.mmrLambda,
37
+ recencyHalfLifeDays: cfg.recall?.recencyHalfLifeDays ?? DEFAULTS.recencyHalfLifeDays,
38
+ },
39
+ dedup: {
40
+ similarityThreshold: cfg.dedup?.similarityThreshold ?? DEFAULTS.dedupSimilarityThreshold,
41
+ },
42
+ capture: {
43
+ evidenceWrapperTag: cfg.capture?.evidenceWrapperTag ?? DEFAULTS.evidenceWrapperTag,
44
+ },
45
+ };
46
+ }
47
+
48
+ export function buildContext(
49
+ stateDir: string,
50
+ workspaceDir: string,
51
+ rawConfig: Partial<MemosLocalConfig> | undefined,
52
+ log?: Logger,
53
+ ): PluginContext {
54
+ const defaultLog: Logger = {
55
+ debug: (...args) => console.debug("[memos-local]", ...args),
56
+ info: (...args) => console.info("[memos-local]", ...args),
57
+ warn: (...args) => console.warn("[memos-local]", ...args),
58
+ error: (...args) => console.error("[memos-local]", ...args),
59
+ };
60
+
61
+ return {
62
+ stateDir,
63
+ workspaceDir,
64
+ config: resolveConfig(rawConfig, stateDir),
65
+ log: log ?? defaultLog,
66
+ };
67
+ }