@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.
- package/.env.example +13 -5
- package/README.md +283 -91
- package/dist/capture/index.d.ts +5 -7
- package/dist/capture/index.d.ts.map +1 -1
- package/dist/capture/index.js +72 -43
- package/dist/capture/index.js.map +1 -1
- package/dist/ingest/dedup.d.ts +8 -0
- package/dist/ingest/dedup.d.ts.map +1 -1
- package/dist/ingest/dedup.js +21 -0
- package/dist/ingest/dedup.js.map +1 -1
- package/dist/ingest/providers/anthropic.d.ts +16 -0
- package/dist/ingest/providers/anthropic.d.ts.map +1 -1
- package/dist/ingest/providers/anthropic.js +214 -1
- package/dist/ingest/providers/anthropic.js.map +1 -1
- package/dist/ingest/providers/bedrock.d.ts +16 -5
- package/dist/ingest/providers/bedrock.d.ts.map +1 -1
- package/dist/ingest/providers/bedrock.js +210 -6
- package/dist/ingest/providers/bedrock.js.map +1 -1
- package/dist/ingest/providers/gemini.d.ts +16 -0
- package/dist/ingest/providers/gemini.d.ts.map +1 -1
- package/dist/ingest/providers/gemini.js +202 -1
- package/dist/ingest/providers/gemini.js.map +1 -1
- package/dist/ingest/providers/index.d.ts +31 -0
- package/dist/ingest/providers/index.d.ts.map +1 -1
- package/dist/ingest/providers/index.js +134 -4
- package/dist/ingest/providers/index.js.map +1 -1
- package/dist/ingest/providers/openai.d.ts +24 -0
- package/dist/ingest/providers/openai.d.ts.map +1 -1
- package/dist/ingest/providers/openai.js +255 -1
- package/dist/ingest/providers/openai.js.map +1 -1
- package/dist/ingest/task-processor.d.ts +65 -0
- package/dist/ingest/task-processor.d.ts.map +1 -0
- package/dist/ingest/task-processor.js +354 -0
- package/dist/ingest/task-processor.js.map +1 -0
- package/dist/ingest/worker.d.ts +3 -1
- package/dist/ingest/worker.d.ts.map +1 -1
- package/dist/ingest/worker.js +131 -23
- package/dist/ingest/worker.js.map +1 -1
- package/dist/recall/engine.d.ts +1 -0
- package/dist/recall/engine.d.ts.map +1 -1
- package/dist/recall/engine.js +22 -11
- package/dist/recall/engine.js.map +1 -1
- package/dist/recall/mmr.d.ts.map +1 -1
- package/dist/recall/mmr.js +3 -1
- package/dist/recall/mmr.js.map +1 -1
- package/dist/skill/bundled-memory-guide.d.ts +6 -0
- package/dist/skill/bundled-memory-guide.d.ts.map +1 -0
- package/dist/skill/bundled-memory-guide.js +95 -0
- package/dist/skill/bundled-memory-guide.js.map +1 -0
- package/dist/skill/evaluator.d.ts +31 -0
- package/dist/skill/evaluator.d.ts.map +1 -0
- package/dist/skill/evaluator.js +194 -0
- package/dist/skill/evaluator.js.map +1 -0
- package/dist/skill/evolver.d.ts +22 -0
- package/dist/skill/evolver.d.ts.map +1 -0
- package/dist/skill/evolver.js +193 -0
- package/dist/skill/evolver.js.map +1 -0
- package/dist/skill/generator.d.ts +25 -0
- package/dist/skill/generator.d.ts.map +1 -0
- package/dist/skill/generator.js +477 -0
- package/dist/skill/generator.js.map +1 -0
- package/dist/skill/installer.d.ts +16 -0
- package/dist/skill/installer.d.ts.map +1 -0
- package/dist/skill/installer.js +89 -0
- package/dist/skill/installer.js.map +1 -0
- package/dist/skill/upgrader.d.ts +19 -0
- package/dist/skill/upgrader.d.ts.map +1 -0
- package/dist/skill/upgrader.js +263 -0
- package/dist/skill/upgrader.js.map +1 -0
- package/dist/skill/validator.d.ts +29 -0
- package/dist/skill/validator.d.ts.map +1 -0
- package/dist/skill/validator.js +227 -0
- package/dist/skill/validator.js.map +1 -0
- package/dist/storage/sqlite.d.ts +141 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +664 -7
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types.d.ts +93 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -1
- package/dist/viewer/html.d.ts +1 -1
- package/dist/viewer/html.d.ts.map +1 -1
- package/dist/viewer/html.js +2391 -159
- package/dist/viewer/html.js.map +1 -1
- package/dist/viewer/server.d.ts +16 -0
- package/dist/viewer/server.d.ts.map +1 -1
- package/dist/viewer/server.js +346 -3
- package/dist/viewer/server.js.map +1 -1
- package/index.ts +572 -89
- package/openclaw.plugin.json +20 -45
- package/package.json +3 -4
- package/skill/memos-memory-guide/SKILL.md +86 -0
- package/src/capture/index.ts +85 -45
- package/src/ingest/dedup.ts +29 -0
- package/src/ingest/providers/anthropic.ts +258 -1
- package/src/ingest/providers/bedrock.ts +256 -6
- package/src/ingest/providers/gemini.ts +252 -1
- package/src/ingest/providers/index.ts +156 -8
- package/src/ingest/providers/openai.ts +304 -1
- package/src/ingest/task-processor.ts +396 -0
- package/src/ingest/worker.ts +145 -34
- package/src/recall/engine.ts +23 -12
- package/src/recall/mmr.ts +3 -1
- package/src/skill/bundled-memory-guide.ts +91 -0
- package/src/skill/evaluator.ts +220 -0
- package/src/skill/evolver.ts +169 -0
- package/src/skill/generator.ts +506 -0
- package/src/skill/installer.ts +59 -0
- package/src/skill/upgrader.ts +257 -0
- package/src/skill/validator.ts +227 -0
- package/src/storage/sqlite.ts +802 -7
- package/src/types.ts +96 -0
- package/src/viewer/html.ts +2391 -159
- package/src/viewer/server.ts +346 -3
- package/SKILL.md +0 -43
- 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
|
-
|
|
24
|
-
type: "
|
|
25
|
-
|
|
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,
|
|
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
|
|
81
|
-
"
|
|
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
|
|
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
|
|
88
|
-
const { query,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 ${
|
|
249
|
+
text: `Found ${filteredHits.length} relevant memories:\n\n${lines.join("\n\n")}${tipsText}`,
|
|
117
250
|
},
|
|
118
251
|
],
|
|
119
252
|
details: {
|
|
120
|
-
hits:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
//
|
|
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: "
|
|
197
|
-
label: "
|
|
341
|
+
name: "task_summary",
|
|
342
|
+
label: "Task Summary",
|
|
198
343
|
description:
|
|
199
|
-
"Get
|
|
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
|
-
|
|
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
|
|
207
|
-
const {
|
|
208
|
-
|
|
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
|
|
211
|
-
if (!
|
|
354
|
+
const task = store.getTask(taskId);
|
|
355
|
+
if (!task) {
|
|
212
356
|
return {
|
|
213
|
-
content: [{ type: "text", text: `
|
|
357
|
+
content: [{ type: "text", text: `Task not found: ${taskId}` }],
|
|
214
358
|
details: { error: "not_found" },
|
|
215
359
|
};
|
|
216
360
|
}
|
|
217
361
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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: [{
|
|
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
|
-
|
|
228
|
-
|
|
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: "
|
|
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
|
-
"
|
|
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
|
|
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
|
|
280
|
-
|
|
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
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|