@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
|
@@ -169,6 +169,180 @@ export async function judgeNewTopicOpenAI(
|
|
|
169
169
|
return answer.startsWith("NEW");
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
const FILTER_RELEVANT_PROMPT = `You are a memory relevance judge. Given a user's QUERY and a list of CANDIDATE memory summaries, do two things:
|
|
173
|
+
|
|
174
|
+
1. Select ALL candidates that could be useful for answering the query. When in doubt, INCLUDE the candidate.
|
|
175
|
+
- For questions about lists, history, or "what/where/who" across multiple items (e.g. "which companies did I work at"), include ALL matching items — do NOT stop at the first match.
|
|
176
|
+
- For factual lookups (e.g. "what is the SSH port"), a single direct answer is enough.
|
|
177
|
+
2. Judge whether the selected memories are SUFFICIENT to fully answer the query WITHOUT fetching additional context.
|
|
178
|
+
|
|
179
|
+
IMPORTANT for "sufficient" judgment:
|
|
180
|
+
- sufficient=true ONLY when the memories contain a concrete ANSWER, fact, decision, or actionable information that directly addresses the query.
|
|
181
|
+
- sufficient=false when:
|
|
182
|
+
- The memories only repeat the same question the user asked before (echo, not answer).
|
|
183
|
+
- The memories show related topics but lack the specific detail needed.
|
|
184
|
+
- The memories contain partial information that would benefit from full task context, timeline, or related skills.
|
|
185
|
+
|
|
186
|
+
Output a JSON object with exactly two fields:
|
|
187
|
+
{"relevant":[1,3,5],"sufficient":true}
|
|
188
|
+
|
|
189
|
+
- "relevant": array of candidate numbers that are useful. Empty array [] if none are relevant.
|
|
190
|
+
- "sufficient": true ONLY if the memories contain a direct answer; false otherwise.
|
|
191
|
+
|
|
192
|
+
Output ONLY the JSON object, nothing else.`;
|
|
193
|
+
|
|
194
|
+
export interface FilterResult {
|
|
195
|
+
relevant: number[];
|
|
196
|
+
sufficient: boolean;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function filterRelevantOpenAI(
|
|
200
|
+
query: string,
|
|
201
|
+
candidates: Array<{ index: number; summary: string; role: string }>,
|
|
202
|
+
cfg: SummarizerConfig,
|
|
203
|
+
log: Logger,
|
|
204
|
+
): Promise<FilterResult> {
|
|
205
|
+
const endpoint = normalizeChatEndpoint(cfg.endpoint ?? "https://api.openai.com/v1/chat/completions");
|
|
206
|
+
const model = cfg.model ?? "gpt-4o-mini";
|
|
207
|
+
const headers: Record<string, string> = {
|
|
208
|
+
"Content-Type": "application/json",
|
|
209
|
+
Authorization: `Bearer ${cfg.apiKey}`,
|
|
210
|
+
...cfg.headers,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const candidateText = candidates
|
|
214
|
+
.map((c) => `${c.index}. [${c.role}] ${c.summary}`)
|
|
215
|
+
.join("\n");
|
|
216
|
+
|
|
217
|
+
const resp = await fetch(endpoint, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers,
|
|
220
|
+
body: JSON.stringify({
|
|
221
|
+
model,
|
|
222
|
+
temperature: 0,
|
|
223
|
+
max_tokens: 200,
|
|
224
|
+
messages: [
|
|
225
|
+
{ role: "system", content: FILTER_RELEVANT_PROMPT },
|
|
226
|
+
{ role: "user", content: `QUERY: ${query}\n\nCANDIDATES:\n${candidateText}` },
|
|
227
|
+
],
|
|
228
|
+
}),
|
|
229
|
+
signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (!resp.ok) {
|
|
233
|
+
const body = await resp.text();
|
|
234
|
+
throw new Error(`OpenAI filter-relevant failed (${resp.status}): ${body}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const json = (await resp.json()) as { choices: Array<{ message: { content: string } }> };
|
|
238
|
+
const raw = json.choices[0]?.message?.content?.trim() ?? "{}";
|
|
239
|
+
return parseFilterResult(raw, log);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function parseFilterResult(raw: string, log: Logger): FilterResult {
|
|
243
|
+
try {
|
|
244
|
+
const match = raw.match(/\{[\s\S]*\}/);
|
|
245
|
+
if (match) {
|
|
246
|
+
const obj = JSON.parse(match[0]);
|
|
247
|
+
if (obj && Array.isArray(obj.relevant)) {
|
|
248
|
+
return {
|
|
249
|
+
relevant: obj.relevant.filter((n: any) => typeof n === "number"),
|
|
250
|
+
sufficient: obj.sufficient === true,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} catch {}
|
|
255
|
+
log.warn(`filterRelevant: failed to parse LLM output: "${raw}", fallback to all+insufficient`);
|
|
256
|
+
return { relevant: [], sufficient: false };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Smart Dedup: judge whether new memory is DUPLICATE / UPDATE / NEW ───
|
|
260
|
+
|
|
261
|
+
export const DEDUP_JUDGE_PROMPT = `You are a memory deduplication system. Given a NEW memory summary and several EXISTING memory summaries, determine the relationship.
|
|
262
|
+
|
|
263
|
+
For each EXISTING memory, the NEW memory is either:
|
|
264
|
+
- "DUPLICATE": NEW is fully covered by an EXISTING memory — no new information at all
|
|
265
|
+
- "UPDATE": NEW contains information that supplements or updates an EXISTING memory (new data, status change, additional detail)
|
|
266
|
+
- "NEW": NEW is a different topic/event despite surface similarity
|
|
267
|
+
|
|
268
|
+
Pick the BEST match among all candidates. If none match well, choose "NEW".
|
|
269
|
+
|
|
270
|
+
Output a single JSON object:
|
|
271
|
+
- If DUPLICATE: {"action":"DUPLICATE","targetIndex":2,"reason":"..."}
|
|
272
|
+
- If UPDATE: {"action":"UPDATE","targetIndex":3,"reason":"...","mergedSummary":"a combined summary preserving all info from both old and new, same language as input"}
|
|
273
|
+
- If NEW: {"action":"NEW","reason":"..."}
|
|
274
|
+
|
|
275
|
+
CRITICAL: mergedSummary must use the SAME language as the input. Output ONLY the JSON object.`;
|
|
276
|
+
|
|
277
|
+
export interface DedupResult {
|
|
278
|
+
action: "DUPLICATE" | "UPDATE" | "NEW";
|
|
279
|
+
targetIndex?: number;
|
|
280
|
+
reason: string;
|
|
281
|
+
mergedSummary?: string;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function judgeDedupOpenAI(
|
|
285
|
+
newSummary: string,
|
|
286
|
+
candidates: Array<{ index: number; summary: string; chunkId: string }>,
|
|
287
|
+
cfg: SummarizerConfig,
|
|
288
|
+
log: Logger,
|
|
289
|
+
): Promise<DedupResult> {
|
|
290
|
+
const endpoint = normalizeChatEndpoint(cfg.endpoint ?? "https://api.openai.com/v1/chat/completions");
|
|
291
|
+
const model = cfg.model ?? "gpt-4o-mini";
|
|
292
|
+
const headers: Record<string, string> = {
|
|
293
|
+
"Content-Type": "application/json",
|
|
294
|
+
Authorization: `Bearer ${cfg.apiKey}`,
|
|
295
|
+
...cfg.headers,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const candidateText = candidates
|
|
299
|
+
.map((c) => `${c.index}. ${c.summary}`)
|
|
300
|
+
.join("\n");
|
|
301
|
+
|
|
302
|
+
const resp = await fetch(endpoint, {
|
|
303
|
+
method: "POST",
|
|
304
|
+
headers,
|
|
305
|
+
body: JSON.stringify({
|
|
306
|
+
model,
|
|
307
|
+
temperature: 0,
|
|
308
|
+
max_tokens: 300,
|
|
309
|
+
messages: [
|
|
310
|
+
{ role: "system", content: DEDUP_JUDGE_PROMPT },
|
|
311
|
+
{ role: "user", content: `NEW MEMORY:\n${newSummary}\n\nEXISTING MEMORIES:\n${candidateText}` },
|
|
312
|
+
],
|
|
313
|
+
}),
|
|
314
|
+
signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
if (!resp.ok) {
|
|
318
|
+
const body = await resp.text();
|
|
319
|
+
throw new Error(`OpenAI dedup-judge failed (${resp.status}): ${body}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const json = (await resp.json()) as { choices: Array<{ message: { content: string } }> };
|
|
323
|
+
const raw = json.choices[0]?.message?.content?.trim() ?? "{}";
|
|
324
|
+
return parseDedupResult(raw, log);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function parseDedupResult(raw: string, log: Logger): DedupResult {
|
|
328
|
+
try {
|
|
329
|
+
const match = raw.match(/\{[\s\S]*\}/);
|
|
330
|
+
if (match) {
|
|
331
|
+
const obj = JSON.parse(match[0]);
|
|
332
|
+
if (obj && typeof obj.action === "string") {
|
|
333
|
+
return {
|
|
334
|
+
action: obj.action as DedupResult["action"],
|
|
335
|
+
targetIndex: typeof obj.targetIndex === "number" ? obj.targetIndex : undefined,
|
|
336
|
+
reason: obj.reason || "",
|
|
337
|
+
mergedSummary: obj.mergedSummary || undefined,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} catch {}
|
|
342
|
+
log.warn(`judgeDedup: failed to parse LLM output: "${raw}", fallback to NEW`);
|
|
343
|
+
return { action: "NEW", reason: "parse_failed" };
|
|
344
|
+
}
|
|
345
|
+
|
|
172
346
|
function normalizeChatEndpoint(url: string): string {
|
|
173
347
|
const stripped = url.replace(/\/+$/, "");
|
|
174
348
|
if (stripped.endsWith("/chat/completions")) return stripped;
|
|
@@ -30,6 +30,7 @@ const SKIP_REASONS = {
|
|
|
30
30
|
export class TaskProcessor {
|
|
31
31
|
private summarizer: Summarizer;
|
|
32
32
|
private processing = false;
|
|
33
|
+
private onTaskCompletedCallback?: (task: Task) => void;
|
|
33
34
|
|
|
34
35
|
constructor(
|
|
35
36
|
private store: SqliteStore,
|
|
@@ -38,6 +39,10 @@ export class TaskProcessor {
|
|
|
38
39
|
this.summarizer = new Summarizer(ctx.config.summarizer, ctx.log);
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
onTaskCompleted(cb: (task: Task) => void): void {
|
|
43
|
+
this.onTaskCompletedCallback = cb;
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
/**
|
|
42
47
|
* Called after new chunks are ingested.
|
|
43
48
|
* Determines if a new task boundary was crossed and handles transition.
|
|
@@ -214,6 +219,17 @@ export class TaskProcessor {
|
|
|
214
219
|
this.ctx.log.info(
|
|
215
220
|
`Finalized task=${task.id} title="${title}" chunks=${chunks.length} summaryLen=${body.length}`,
|
|
216
221
|
);
|
|
222
|
+
|
|
223
|
+
if (this.onTaskCompletedCallback) {
|
|
224
|
+
const finalized = this.store.getTask(task.id);
|
|
225
|
+
if (finalized) {
|
|
226
|
+
try {
|
|
227
|
+
this.onTaskCompletedCallback(finalized);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
this.ctx.log.warn(`TaskProcessor onTaskCompleted callback error: ${err}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
217
233
|
}
|
|
218
234
|
|
|
219
235
|
/**
|
package/src/ingest/worker.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { ConversationMessage, Chunk, PluginContext } from "../types";
|
|
|
4
4
|
import type { SqliteStore } from "../storage/sqlite";
|
|
5
5
|
import type { Embedder } from "../embedding";
|
|
6
6
|
import { Summarizer } from "./providers";
|
|
7
|
-
import { findDuplicate } from "./dedup";
|
|
7
|
+
import { findDuplicate, findTopSimilar } from "./dedup";
|
|
8
8
|
import { TaskProcessor } from "./task-processor";
|
|
9
9
|
|
|
10
10
|
export class IngestWorker {
|
|
@@ -23,6 +23,8 @@ export class IngestWorker {
|
|
|
23
23
|
this.taskProcessor = new TaskProcessor(store, ctx);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
getTaskProcessor(): TaskProcessor { return this.taskProcessor; }
|
|
27
|
+
|
|
26
28
|
enqueue(messages: ConversationMessage[]): void {
|
|
27
29
|
this.queue.push(...messages);
|
|
28
30
|
if (!this.processing) {
|
|
@@ -43,21 +45,61 @@ export class IngestWorker {
|
|
|
43
45
|
|
|
44
46
|
private async processQueue(): Promise<void> {
|
|
45
47
|
this.processing = true;
|
|
48
|
+
const t0 = performance.now();
|
|
46
49
|
|
|
47
50
|
let lastSessionKey: string | undefined;
|
|
48
51
|
let lastTimestamp = 0;
|
|
52
|
+
let stored = 0;
|
|
53
|
+
let skipped = 0;
|
|
54
|
+
let merged = 0;
|
|
55
|
+
let duplicated = 0;
|
|
56
|
+
let errors = 0;
|
|
57
|
+
const resultLines: string[] = [];
|
|
58
|
+
const inputLines: string[] = [];
|
|
59
|
+
const totalMessages = this.queue.length;
|
|
49
60
|
|
|
50
61
|
while (this.queue.length > 0) {
|
|
51
62
|
const msg = this.queue.shift()!;
|
|
63
|
+
inputLines.push(`[${msg.role}] ${msg.content}`);
|
|
52
64
|
try {
|
|
53
|
-
await this.ingestMessage(msg);
|
|
65
|
+
const result = await this.ingestMessage(msg);
|
|
54
66
|
lastSessionKey = msg.sessionKey;
|
|
55
67
|
lastTimestamp = Math.max(lastTimestamp, msg.timestamp);
|
|
68
|
+
if (result === "skipped") {
|
|
69
|
+
skipped++;
|
|
70
|
+
resultLines.push(`[${msg.role}] ⏭ exact-dup → ${msg.content}`);
|
|
71
|
+
} else if (result.action === "stored") {
|
|
72
|
+
stored++;
|
|
73
|
+
resultLines.push(`[${msg.role}] ✅ stored → ${result.summary ?? msg.content}`);
|
|
74
|
+
} else if (result.action === "duplicate") {
|
|
75
|
+
duplicated++;
|
|
76
|
+
resultLines.push(`[${msg.role}] 🔁 dedup(${result.reason ?? "similar"}) → ${msg.content}`);
|
|
77
|
+
} else if (result.action === "merged") {
|
|
78
|
+
merged++;
|
|
79
|
+
resultLines.push(`[${msg.role}] 🔀 merged → ${msg.content}`);
|
|
80
|
+
}
|
|
56
81
|
} catch (err) {
|
|
82
|
+
errors++;
|
|
83
|
+
resultLines.push(`[${msg.role}] ❌ error → ${msg.content}`);
|
|
57
84
|
this.ctx.log.error(`Failed to ingest message turn=${msg.turnId}: ${err}`);
|
|
58
85
|
}
|
|
59
86
|
}
|
|
60
87
|
|
|
88
|
+
const dur = performance.now() - t0;
|
|
89
|
+
|
|
90
|
+
if (stored + merged > 0 || skipped > 0 || duplicated > 0) {
|
|
91
|
+
this.store.recordToolCall("memory_add", dur, errors === 0);
|
|
92
|
+
try {
|
|
93
|
+
const inputInfo = {
|
|
94
|
+
session: lastSessionKey,
|
|
95
|
+
messages: totalMessages,
|
|
96
|
+
details: inputLines,
|
|
97
|
+
};
|
|
98
|
+
const stats = [`stored=${stored}`, skipped > 0 ? `skipped=${skipped}` : null, duplicated > 0 ? `dedup=${duplicated}` : null, merged > 0 ? `merged=${merged}` : null, errors > 0 ? `errors=${errors}` : null].filter(Boolean).join(", ");
|
|
99
|
+
this.store.recordApiLog("memory_add", inputInfo, `${stats}\n${resultLines.join("\n")}`, dur, errors === 0);
|
|
100
|
+
} catch (_) { /* best-effort */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
61
103
|
if (lastSessionKey) {
|
|
62
104
|
this.ctx.log.debug(`Calling TaskProcessor.onChunksIngested session=${lastSessionKey} ts=${lastTimestamp}`);
|
|
63
105
|
this.taskProcessor
|
|
@@ -70,14 +112,16 @@ export class IngestWorker {
|
|
|
70
112
|
this.flushResolvers = [];
|
|
71
113
|
}
|
|
72
114
|
|
|
73
|
-
private async ingestMessage(msg: ConversationMessage): Promise<
|
|
115
|
+
private async ingestMessage(msg: ConversationMessage): Promise<
|
|
116
|
+
"skipped" | { action: "stored" | "duplicate" | "merged"; summary?: string; reason?: string }
|
|
117
|
+
> {
|
|
74
118
|
if (this.store.chunkExistsByContent(msg.sessionKey, msg.role, msg.content)) {
|
|
75
|
-
this.ctx.log.debug(`
|
|
76
|
-
return;
|
|
119
|
+
this.ctx.log.debug(`Exact-dup (same session+role+hash), skipping: session=${msg.sessionKey} role=${msg.role} len=${msg.content.length}`);
|
|
120
|
+
return "skipped";
|
|
77
121
|
}
|
|
78
122
|
|
|
79
123
|
const kind = msg.role === "tool" ? "tool_result" : "paragraph";
|
|
80
|
-
await this.storeChunk(msg, msg.content, kind, 0);
|
|
124
|
+
return await this.storeChunk(msg, msg.content, kind, 0);
|
|
81
125
|
}
|
|
82
126
|
|
|
83
127
|
private async storeChunk(
|
|
@@ -85,7 +129,7 @@ export class IngestWorker {
|
|
|
85
129
|
content: string,
|
|
86
130
|
kind: Chunk["kind"],
|
|
87
131
|
seq: number,
|
|
88
|
-
): Promise<
|
|
132
|
+
): Promise<{ action: "stored" | "duplicate" | "merged"; chunkId?: string; summary?: string; targetChunkId?: string; reason?: string }> {
|
|
89
133
|
const chunkId = uuid();
|
|
90
134
|
const summary = await this.summarizer.summarize(content);
|
|
91
135
|
|
|
@@ -96,19 +140,65 @@ export class IngestWorker {
|
|
|
96
140
|
this.ctx.log.warn(`Embedding failed for chunk=${chunkId}, storing without vector: ${err}`);
|
|
97
141
|
}
|
|
98
142
|
|
|
143
|
+
let dedupStatus: "active" | "duplicate" | "merged" = "active";
|
|
144
|
+
let dedupTarget: string | null = null;
|
|
145
|
+
let dedupReason: string | null = null;
|
|
146
|
+
|
|
147
|
+
// Smart dedup: find Top-5 similar chunks, then ask LLM to judge
|
|
99
148
|
if (embedding) {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
149
|
+
const similarThreshold = this.ctx.config.dedup?.similarityThreshold ?? 0.75;
|
|
150
|
+
const topSimilar = findTopSimilar(this.store, embedding, similarThreshold, 5, this.ctx.log);
|
|
151
|
+
|
|
152
|
+
if (topSimilar.length > 0) {
|
|
153
|
+
const candidates = topSimilar.map((s, i) => {
|
|
154
|
+
const chunk = this.store.getChunk(s.chunkId);
|
|
155
|
+
return {
|
|
156
|
+
index: i + 1,
|
|
157
|
+
summary: chunk?.summary ?? "",
|
|
158
|
+
chunkId: s.chunkId,
|
|
159
|
+
};
|
|
160
|
+
}).filter(c => c.summary);
|
|
161
|
+
|
|
162
|
+
if (candidates.length > 0) {
|
|
163
|
+
const dedupResult = await this.summarizer.judgeDedup(summary, candidates);
|
|
164
|
+
|
|
165
|
+
if (dedupResult && dedupResult.action === "DUPLICATE" && dedupResult.targetIndex) {
|
|
166
|
+
const targetChunkId = candidates[dedupResult.targetIndex - 1]?.chunkId;
|
|
167
|
+
if (targetChunkId) {
|
|
168
|
+
this.store.recordMergeHit(targetChunkId, "DUPLICATE", dedupResult.reason);
|
|
169
|
+
dedupStatus = "duplicate";
|
|
170
|
+
dedupTarget = targetChunkId;
|
|
171
|
+
dedupReason = dedupResult.reason;
|
|
172
|
+
this.ctx.log.debug(`Smart dedup: DUPLICATE → target=${targetChunkId}, storing with status=duplicate, reason: ${dedupResult.reason}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (dedupStatus === "active" && dedupResult && dedupResult.action === "UPDATE" && dedupResult.targetIndex && dedupResult.mergedSummary) {
|
|
177
|
+
const targetChunkId = candidates[dedupResult.targetIndex - 1]?.chunkId;
|
|
178
|
+
if (targetChunkId) {
|
|
179
|
+
const oldChunk = this.store.getChunk(targetChunkId);
|
|
180
|
+
const oldSummary = oldChunk?.summary ?? "";
|
|
181
|
+
this.store.recordMergeHit(targetChunkId, "UPDATE", dedupResult.reason, oldSummary, dedupResult.mergedSummary);
|
|
182
|
+
this.store.updateChunkSummaryAndContent(targetChunkId, dedupResult.mergedSummary, content);
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const [newEmb] = await this.embedder.embed([dedupResult.mergedSummary]);
|
|
186
|
+
if (newEmb) this.store.upsertEmbedding(targetChunkId, newEmb);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
this.ctx.log.warn(`Re-embed after UPDATE failed: ${err}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
dedupStatus = "merged";
|
|
192
|
+
dedupTarget = targetChunkId;
|
|
193
|
+
dedupReason = dedupResult.reason;
|
|
194
|
+
this.ctx.log.debug(`Smart dedup: UPDATE → merged into chunk=${targetChunkId}, storing with status=merged, reason: ${dedupResult.reason}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (dedupStatus === "active") {
|
|
199
|
+
this.ctx.log.debug(`Smart dedup: NEW — creating active chunk (reason: ${dedupResult?.reason ?? "no_result"})`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
112
202
|
}
|
|
113
203
|
}
|
|
114
204
|
|
|
@@ -123,14 +213,29 @@ export class IngestWorker {
|
|
|
123
213
|
summary,
|
|
124
214
|
embedding: null,
|
|
125
215
|
taskId: null,
|
|
216
|
+
skillId: null,
|
|
217
|
+
dedupStatus,
|
|
218
|
+
dedupTarget,
|
|
219
|
+
dedupReason,
|
|
220
|
+
mergeCount: 0,
|
|
221
|
+
lastHitAt: null,
|
|
222
|
+
mergeHistory: "[]",
|
|
126
223
|
createdAt: msg.timestamp,
|
|
127
224
|
updatedAt: msg.timestamp,
|
|
128
225
|
};
|
|
129
226
|
|
|
130
227
|
this.store.insertChunk(chunk);
|
|
131
|
-
if (embedding) {
|
|
228
|
+
if (embedding && dedupStatus === "active") {
|
|
132
229
|
this.store.upsertEmbedding(chunkId, embedding);
|
|
133
230
|
}
|
|
134
|
-
this.ctx.log.debug(`Stored chunk=${chunkId} kind=${kind} role=${msg.role} len=${content.length} hasVec=${!!embedding}`);
|
|
231
|
+
this.ctx.log.debug(`Stored chunk=${chunkId} kind=${kind} role=${msg.role} dedup=${dedupStatus} len=${content.length} hasVec=${!!embedding && dedupStatus === "active"}`);
|
|
232
|
+
|
|
233
|
+
if (dedupStatus === "duplicate") {
|
|
234
|
+
return { action: "duplicate", summary, targetChunkId: dedupTarget ?? undefined, reason: dedupReason ?? undefined };
|
|
235
|
+
}
|
|
236
|
+
if (dedupStatus === "merged") {
|
|
237
|
+
return { action: "merged", summary, targetChunkId: dedupTarget ?? undefined, reason: dedupReason ?? undefined };
|
|
238
|
+
}
|
|
239
|
+
return { action: "stored", chunkId, summary };
|
|
135
240
|
}
|
|
136
241
|
}
|
package/src/recall/engine.ts
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundled MemOS memory-guide skill content.
|
|
3
|
+
* Written to workspace/skills/memos-memory-guide on plugin register so OpenClaw loads it.
|
|
4
|
+
*/
|
|
5
|
+
export const MEMORY_GUIDE_SKILL_MD = `---
|
|
6
|
+
name: memos-memory-guide
|
|
7
|
+
description: Use the MemOS local memory system to search and use the user's past conversations. Use this skill whenever the user refers to past chats, their own preferences or history, or when you need to answer from prior context. When auto-recall returns nothing (long or unclear user query), generate your own short search query and call memory_search. Use task_summary when you need full task context, skill_get for experience guides, and memory_timeline to expand around a memory hit.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# MemOS Local Memory — Agent Guide
|
|
11
|
+
|
|
12
|
+
This skill describes how to use the MemOS memory tools so you can reliably search and use the user's long-term conversation history.
|
|
13
|
+
|
|
14
|
+
## How memory is provided each turn
|
|
15
|
+
|
|
16
|
+
- **Automatic recall (hook):** At the start of each turn, the system runs a memory search using the user's current message and injects relevant past memories into your context. You do not need to call any tool for that.
|
|
17
|
+
- **When that is not enough:** If the user's message is very long, vague, or the automatic search returns **no memories**, you should **generate your own short, focused query** and call \`memory_search\` yourself. For example:
|
|
18
|
+
- User sent a long paragraph → extract 1–2 key topics or a short question and search with that.
|
|
19
|
+
- Auto-recall said "no memories" or you see no memory block → call \`memory_search\` with a query you derive (e.g. the user's name, a topic they often mention, or a rephrased question).
|
|
20
|
+
- **When you need more detail:** Search results only give excerpts and IDs. Use the tools below to fetch full task context, skill content, or surrounding messages.
|
|
21
|
+
|
|
22
|
+
## Tools — what they do and when to call
|
|
23
|
+
|
|
24
|
+
### memory_search
|
|
25
|
+
|
|
26
|
+
- **What it does:** Searches the user's stored conversation memory by a natural-language query. Returns a list of relevant excerpts with \`chunkId\` and optionally \`task_id\`.
|
|
27
|
+
- **When to call:**
|
|
28
|
+
- The automatic recall did not run or returned nothing (e.g. no \`<memory_context>\` block, or a note that no memories were found).
|
|
29
|
+
- The user's query is long or unclear — **generate a short query yourself** (keywords, rephrased question, or a clear sub-question) and call \`memory_search(query="...")\`.
|
|
30
|
+
- You need to search with a different angle (e.g. filter by \`role='user'\` to find what the user said, or use a more specific query).
|
|
31
|
+
- **Parameters:** \`query\` (required), optional \`minScore\`, \`role\` (e.g. \`"user"\`).
|
|
32
|
+
- **Output:** List of items with role, excerpt, \`chunkId\`, and sometimes \`task_id\`. Use those IDs with the tools below when you need more context.
|
|
33
|
+
|
|
34
|
+
### task_summary
|
|
35
|
+
|
|
36
|
+
- **What it does:** Returns the full task summary for a given \`task_id\`: title, status, and the complete narrative summary of that conversation task (steps, decisions, URLs, commands, etc.).
|
|
37
|
+
- **When to call:** A \`memory_search\` hit included a \`task_id\` and you need the full story of that task (e.g. what was done, what the user decided, what failed or succeeded).
|
|
38
|
+
- **Parameters:** \`taskId\` (from a search hit).
|
|
39
|
+
- **Effect:** You get one coherent summary of the whole task instead of isolated excerpts.
|
|
40
|
+
|
|
41
|
+
### skill_get
|
|
42
|
+
|
|
43
|
+
- **What it does:** Returns the content of a learned skill (experience guide) by \`skillId\` or by \`taskId\`. If you pass \`taskId\`, the system finds the skill linked to that task.
|
|
44
|
+
- **When to call:** A search hit has a \`task_id\` and the task is the kind that has a "how to do this again" guide (e.g. a workflow the user has run before). Use this to follow the same approach or reuse steps.
|
|
45
|
+
- **Parameters:** \`skillId\` (direct) or \`taskId\` (lookup).
|
|
46
|
+
- **Effect:** You receive the full SKILL.md-style guide. You can then call \`skill_install(skillId)\` if the user or you want that skill loaded for future turns.
|
|
47
|
+
|
|
48
|
+
### skill_install
|
|
49
|
+
|
|
50
|
+
- **What it does:** Installs a skill (by \`skillId\`) into the workspace so it is loaded in future sessions.
|
|
51
|
+
- **When to call:** After \`skill_get\` when the skill is useful for ongoing use (e.g. the user's recurring workflow). Optional; only when you want the skill to be permanently available.
|
|
52
|
+
- **Parameters:** \`skillId\`.
|
|
53
|
+
|
|
54
|
+
### memory_timeline
|
|
55
|
+
|
|
56
|
+
- **What it does:** Expands context around a single memory chunk: returns the surrounding conversation messages (±N turns) so you see what was said before and after that excerpt.
|
|
57
|
+
- **When to call:** A \`memory_search\` hit is relevant but you need the surrounding dialogue (e.g. who said what next, or the exact follow-up question).
|
|
58
|
+
- **Parameters:** \`chunkId\` (from a search hit), optional \`window\` (default 2).
|
|
59
|
+
- **Effect:** You get a short, linear slice of the conversation around that chunk.
|
|
60
|
+
|
|
61
|
+
### memory_viewer
|
|
62
|
+
|
|
63
|
+
- **What it does:** Returns the URL of the MemOS Memory Viewer (web UI) where the user can browse, search, and manage their memories.
|
|
64
|
+
- **When to call:** The user asks how to view their memories, open the memory dashboard, or manage stored data.
|
|
65
|
+
- **Parameters:** None.
|
|
66
|
+
- **Effect:** You can tell the user to open that URL in a browser.
|
|
67
|
+
|
|
68
|
+
## Quick decision flow
|
|
69
|
+
|
|
70
|
+
1. **No memories in context or auto-recall reported nothing**
|
|
71
|
+
→ Call \`memory_search\` with a **self-generated short query** (e.g. key topic or rephrased question).
|
|
72
|
+
|
|
73
|
+
2. **Search returned hits with \`task_id\` and you need full context**
|
|
74
|
+
→ Call \`task_summary(taskId)\`.
|
|
75
|
+
|
|
76
|
+
3. **Task has an experience guide you want to follow**
|
|
77
|
+
→ Call \`skill_get(taskId=...)\` (or \`skill_get(skillId=...)\` if you have the id). Optionally \`skill_install(skillId)\` for future use.
|
|
78
|
+
|
|
79
|
+
4. **You need the exact surrounding conversation of a hit**
|
|
80
|
+
→ Call \`memory_timeline(chunkId=...)\`.
|
|
81
|
+
|
|
82
|
+
5. **User asks where to see or manage their memories**
|
|
83
|
+
→ Call \`memory_viewer()\` and share the URL.
|
|
84
|
+
|
|
85
|
+
## Writing good search queries
|
|
86
|
+
|
|
87
|
+
- Prefer **short, focused** queries (a few words or one clear question).
|
|
88
|
+
- Use **concrete terms**: names, topics, tools, or decisions (e.g. "preferred editor", "deploy script", "API key setup").
|
|
89
|
+
- If the user's message is long, **derive one or two sub-queries** rather than pasting the whole message.
|
|
90
|
+
- Use \`role='user'\` when you specifically want to find what the user said (e.g. preferences, past questions).
|
|
91
|
+
`;
|