@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.
- package/README.md +196 -84
- 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 +14 -0
- package/dist/ingest/providers/anthropic.d.ts.map +1 -1
- package/dist/ingest/providers/anthropic.js +104 -0
- package/dist/ingest/providers/anthropic.js.map +1 -1
- package/dist/ingest/providers/bedrock.d.ts +14 -0
- package/dist/ingest/providers/bedrock.d.ts.map +1 -1
- package/dist/ingest/providers/bedrock.js +100 -0
- package/dist/ingest/providers/bedrock.js.map +1 -1
- package/dist/ingest/providers/gemini.d.ts +14 -0
- package/dist/ingest/providers/gemini.d.ts.map +1 -1
- package/dist/ingest/providers/gemini.js +96 -0
- package/dist/ingest/providers/gemini.js.map +1 -1
- package/dist/ingest/providers/index.d.ts +22 -0
- package/dist/ingest/providers/index.d.ts.map +1 -1
- package/dist/ingest/providers/index.js +68 -0
- package/dist/ingest/providers/index.js.map +1 -1
- package/dist/ingest/providers/openai.d.ts +22 -0
- package/dist/ingest/providers/openai.d.ts.map +1 -1
- package/dist/ingest/providers/openai.js +143 -0
- package/dist/ingest/providers/openai.js.map +1 -1
- package/dist/ingest/task-processor.d.ts +2 -0
- package/dist/ingest/task-processor.d.ts.map +1 -1
- package/dist/ingest/task-processor.js +15 -0
- package/dist/ingest/task-processor.js.map +1 -1
- package/dist/ingest/worker.d.ts +2 -0
- package/dist/ingest/worker.d.ts.map +1 -1
- package/dist/ingest/worker.js +115 -12
- package/dist/ingest/worker.js.map +1 -1
- package/dist/recall/engine.d.ts.map +1 -1
- package/dist/recall/engine.js +1 -0
- package/dist/recall/engine.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 +75 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +417 -6
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types.d.ts +78 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +6 -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 +1549 -113
- package/dist/viewer/html.js.map +1 -1
- package/dist/viewer/server.d.ts +13 -0
- package/dist/viewer/server.d.ts.map +1 -1
- package/dist/viewer/server.js +289 -4
- package/dist/viewer/server.js.map +1 -1
- package/index.ts +489 -181
- package/package.json +1 -1
- package/skill/memos-memory-guide/SKILL.md +86 -0
- package/src/ingest/dedup.ts +29 -0
- package/src/ingest/providers/anthropic.ts +130 -0
- package/src/ingest/providers/bedrock.ts +126 -0
- package/src/ingest/providers/gemini.ts +124 -0
- package/src/ingest/providers/index.ts +86 -4
- package/src/ingest/providers/openai.ts +174 -0
- package/src/ingest/task-processor.ts +16 -0
- package/src/ingest/worker.ts +126 -21
- package/src/recall/engine.ts +1 -0
- 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 +508 -6
- package/src/types.ts +77 -0
- package/src/viewer/html.ts +1549 -113
- package/src/viewer/server.ts +285 -4
- 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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
153
|
-
"
|
|
154
|
-
"
|
|
155
|
-
"
|
|
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
|
|
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
|
|
168
|
-
const { query,
|
|
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}"
|
|
176
|
-
const result = await engine.search({ query, maxResults, minScore, role });
|
|
177
|
-
ctx.log.debug(`memory_search
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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 ${
|
|
249
|
+
text: `Found ${filteredHits.length} relevant memories:\n\n${lines.join("\n\n")}${tipsText}`,
|
|
209
250
|
},
|
|
210
251
|
],
|
|
211
252
|
details: {
|
|
212
|
-
hits:
|
|
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.
|
|
242
|
-
"
|
|
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
|
-
|
|
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
|
|
288
|
+
execute: trackTool("memory_timeline", async (_toolCallId: any, params: any) => {
|
|
252
289
|
ctx.log.debug(`memory_timeline called`);
|
|
253
|
-
const {
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
455
|
-
|
|
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
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|