@memtensor/memos-local-openclaw-plugin 1.0.2-beta.4 → 1.0.2-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/capture/index.js +52 -8
  2. package/dist/capture/index.js.map +1 -1
  3. package/dist/ingest/chunker.d.ts +3 -4
  4. package/dist/ingest/chunker.d.ts.map +1 -1
  5. package/dist/ingest/chunker.js +19 -24
  6. package/dist/ingest/chunker.js.map +1 -1
  7. package/dist/ingest/providers/anthropic.d.ts +3 -1
  8. package/dist/ingest/providers/anthropic.d.ts.map +1 -1
  9. package/dist/ingest/providers/anthropic.js +90 -51
  10. package/dist/ingest/providers/anthropic.js.map +1 -1
  11. package/dist/ingest/providers/bedrock.d.ts +3 -1
  12. package/dist/ingest/providers/bedrock.d.ts.map +1 -1
  13. package/dist/ingest/providers/bedrock.js +90 -51
  14. package/dist/ingest/providers/bedrock.js.map +1 -1
  15. package/dist/ingest/providers/gemini.d.ts +3 -1
  16. package/dist/ingest/providers/gemini.d.ts.map +1 -1
  17. package/dist/ingest/providers/gemini.js +88 -51
  18. package/dist/ingest/providers/gemini.js.map +1 -1
  19. package/dist/ingest/providers/index.d.ts +3 -1
  20. package/dist/ingest/providers/index.d.ts.map +1 -1
  21. package/dist/ingest/providers/index.js +70 -30
  22. package/dist/ingest/providers/index.js.map +1 -1
  23. package/dist/ingest/providers/openai.d.ts +3 -1
  24. package/dist/ingest/providers/openai.d.ts.map +1 -1
  25. package/dist/ingest/providers/openai.js +91 -51
  26. package/dist/ingest/providers/openai.js.map +1 -1
  27. package/dist/ingest/task-processor.d.ts +1 -0
  28. package/dist/ingest/task-processor.d.ts.map +1 -1
  29. package/dist/ingest/task-processor.js +33 -9
  30. package/dist/ingest/task-processor.js.map +1 -1
  31. package/dist/ingest/worker.d.ts.map +1 -1
  32. package/dist/ingest/worker.js +29 -13
  33. package/dist/ingest/worker.js.map +1 -1
  34. package/dist/recall/engine.d.ts.map +1 -1
  35. package/dist/recall/engine.js +19 -14
  36. package/dist/recall/engine.js.map +1 -1
  37. package/dist/skill/bundled-memory-guide.d.ts +1 -5
  38. package/dist/skill/bundled-memory-guide.d.ts.map +1 -1
  39. package/dist/skill/bundled-memory-guide.js +38 -88
  40. package/dist/skill/bundled-memory-guide.js.map +1 -1
  41. package/dist/skill/evaluator.js +1 -1
  42. package/dist/storage/sqlite.d.ts +1 -2
  43. package/dist/storage/sqlite.d.ts.map +1 -1
  44. package/dist/storage/sqlite.js +90 -17
  45. package/dist/storage/sqlite.js.map +1 -1
  46. package/dist/tools/memory-get.d.ts.map +1 -1
  47. package/dist/tools/memory-get.js +1 -3
  48. package/dist/tools/memory-get.js.map +1 -1
  49. package/dist/types.d.ts +2 -2
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/types.js +1 -1
  52. package/dist/types.js.map +1 -1
  53. package/dist/update-check.d.ts +21 -0
  54. package/dist/update-check.d.ts.map +1 -0
  55. package/dist/update-check.js +111 -0
  56. package/dist/update-check.js.map +1 -0
  57. package/dist/viewer/html.d.ts +1 -1
  58. package/dist/viewer/html.d.ts.map +1 -1
  59. package/dist/viewer/html.js +608 -234
  60. package/dist/viewer/html.js.map +1 -1
  61. package/dist/viewer/server.d.ts +2 -1
  62. package/dist/viewer/server.d.ts.map +1 -1
  63. package/dist/viewer/server.js +201 -90
  64. package/dist/viewer/server.js.map +1 -1
  65. package/index.ts +206 -198
  66. package/openclaw.plugin.json +3 -0
  67. package/package.json +6 -1
  68. package/scripts/postinstall.cjs +69 -2
  69. package/skill/memos-memory-guide/SKILL.md +73 -36
  70. package/src/capture/index.ts +52 -8
  71. package/src/ingest/chunker.ts +22 -30
  72. package/src/ingest/providers/anthropic.ts +100 -53
  73. package/src/ingest/providers/bedrock.ts +101 -53
  74. package/src/ingest/providers/gemini.ts +100 -53
  75. package/src/ingest/providers/index.ts +81 -35
  76. package/src/ingest/providers/openai.ts +101 -53
  77. package/src/ingest/task-processor.ts +29 -8
  78. package/src/ingest/worker.ts +31 -13
  79. package/src/recall/engine.ts +20 -13
  80. package/src/skill/bundled-memory-guide.ts +5 -87
  81. package/src/skill/evaluator.ts +1 -1
  82. package/src/storage/sqlite.ts +93 -21
  83. package/src/tools/memory-get.ts +1 -4
  84. package/src/types.ts +2 -9
  85. package/src/update-check.ts +96 -0
  86. package/src/viewer/html.ts +607 -233
  87. package/src/viewer/server.ts +152 -82
@@ -310,9 +310,10 @@ export class TaskProcessor {
310
310
  const skipReason = this.shouldSkipSummary(chunks);
311
311
 
312
312
  if (skipReason) {
313
- this.ctx.log.info(`Task ${task.id} skipped: ${skipReason} (chunks=${chunks.length}, title="${fallbackTitle}")`);
313
+ const skipTitle = await this.generateTitle(chunks, fallbackTitle);
314
+ this.ctx.log.info(`Task ${task.id} skipped: ${skipReason} (chunks=${chunks.length}, title="${skipTitle}")`);
314
315
  const reason = this.humanReadableSkipReason(skipReason, chunks);
315
- this.store.updateTask(task.id, { title: fallbackTitle, summary: reason, status: "skipped", endedAt: Date.now() });
316
+ this.store.updateTask(task.id, { title: skipTitle, summary: reason, status: "skipped", endedAt: Date.now() });
316
317
  return;
317
318
  }
318
319
 
@@ -326,7 +327,7 @@ export class TaskProcessor {
326
327
  }
327
328
 
328
329
  const { title: llmTitle, body } = this.parseTitleFromSummary(summary);
329
- const title = llmTitle || fallbackTitle;
330
+ const title = llmTitle || await this.generateTitle(chunks, fallbackTitle);
330
331
 
331
332
  this.store.updateTask(task.id, {
332
333
  title,
@@ -455,19 +456,39 @@ export class TaskProcessor {
455
456
  private parseTitleFromSummary(summary: string): { title: string; body: string } {
456
457
  const titleMatch = summary.match(/📌\s*(?:Title|标题)\s*\n(.+)/);
457
458
  if (titleMatch) {
458
- const title = titleMatch[1].trim().slice(0, 80);
459
+ const title = titleMatch[1].trim();
459
460
  const body = summary.replace(/📌\s*(?:Title|标题)\s*\n.+\n?/, "").trim();
460
461
  return { title, body };
461
462
  }
462
463
  return { title: "", body: summary };
463
464
  }
464
465
 
466
+ private async generateTitle(chunks: Chunk[], fallback: string): Promise<string> {
467
+ try {
468
+ const userChunks = chunks.filter((c) => c.role === "user");
469
+ const titleInput = userChunks
470
+ .slice(0, 3)
471
+ .map((c) => c.content.trim())
472
+ .join("\n\n");
473
+ if (!titleInput) return fallback || "Untitled Task";
474
+ const title = await this.summarizer.generateTaskTitle(titleInput);
475
+ return title || fallback || "Untitled Task";
476
+ } catch (err) {
477
+ this.ctx.log.warn(`generateTitle failed: ${err}`);
478
+ return fallback || "Untitled Task";
479
+ }
480
+ }
481
+
465
482
  private extractTitle(chunks: Chunk[]): string {
466
- const firstUser = chunks.find((c) => c.role === "user");
483
+ const firstUser = chunks.find((c) => {
484
+ if (c.role !== "user") return false;
485
+ const t = c.content.trim();
486
+ if (t.length > 200) return false;
487
+ if (/session.startup|Session Startup|\/new|\/reset/i.test(t)) return false;
488
+ return true;
489
+ });
467
490
  if (!firstUser) return "Untitled Task";
468
- const text = firstUser.content.trim();
469
- if (text.length <= 60) return text;
470
- return text.slice(0, 57) + "...";
491
+ return firstUser.content.trim().slice(0, 80);
471
492
  }
472
493
 
473
494
  private humanReadableSkipReason(reason: string, chunks: Chunk[]): string {
@@ -59,32 +59,32 @@ export class IngestWorker {
59
59
  let duplicated = 0;
60
60
  let errors = 0;
61
61
  const resultLines: string[] = [];
62
+ const inputDetails: Array<{ role: string; content: string }> = [];
62
63
 
63
64
  while (this.queue.length > 0) {
64
65
  const msg = this.queue.shift()!;
66
+ inputDetails.push({ role: msg.role, content: msg.content });
65
67
  try {
66
68
  const result = await this.ingestMessage(msg);
67
69
  lastSessionKey = msg.sessionKey;
68
70
  lastOwner = msg.owner ?? "agent:main";
69
71
  lastTimestamp = Math.max(lastTimestamp, msg.timestamp);
70
- const brief = (s: string) => s.length > 80 ? s.slice(0, 80) + "…" : s;
71
72
  if (result === "skipped") {
72
73
  skipped++;
73
- resultLines.push(`[${msg.role}] exact-dup ${brief(msg.content)}`);
74
+ resultLines.push(JSON.stringify({ role: msg.role, action: "exact-dup", summary: "", content: msg.content }));
74
75
  } else if (result.action === "stored") {
75
76
  stored++;
76
- resultLines.push(`[${msg.role}] stored ${brief(result.summary ?? msg.content)}`);
77
+ resultLines.push(JSON.stringify({ role: msg.role, action: "stored", summary: result.summary ?? "", content: msg.content }));
77
78
  } else if (result.action === "duplicate") {
78
79
  duplicated++;
79
- resultLines.push(`[${msg.role}] 🔁 dedup(${result.reason ?? "similar"}) ${brief(msg.content)}`);
80
+ resultLines.push(JSON.stringify({ role: msg.role, action: "dedup", reason: result.reason ?? "similar", summary: result.summary ?? "", content: msg.content }));
80
81
  } else if (result.action === "merged") {
81
82
  merged++;
82
- resultLines.push(`[${msg.role}] 🔀 merged ${brief(msg.content)}`);
83
+ resultLines.push(JSON.stringify({ role: msg.role, action: "merged", summary: result.summary ?? "", content: msg.content }));
83
84
  }
84
85
  } catch (err) {
85
86
  errors++;
86
- const brief = (s: string) => s.length > 80 ? s.slice(0, 80) + "" : s;
87
- resultLines.push(`[${msg.role}] ❌ error → ${brief(msg.content)}`);
87
+ resultLines.push(JSON.stringify({ role: msg.role, action: "error", summary: "", content: msg.content }));
88
88
  this.ctx.log.error(`Failed to ingest message turn=${msg.turnId}: ${err}`);
89
89
  }
90
90
  }
@@ -97,6 +97,7 @@ export class IngestWorker {
97
97
  const inputInfo = {
98
98
  session: lastSessionKey,
99
99
  messages: batchSize,
100
+ details: inputDetails,
100
101
  };
101
102
  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(", ");
102
103
  this.store.recordApiLog("memory_add", inputInfo, `${stats}\n${resultLines.join("\n")}`, dur, errors === 0);
@@ -122,8 +123,7 @@ export class IngestWorker {
122
123
  private async ingestMessage(msg: ConversationMessage): Promise<
123
124
  "skipped" | { action: "stored" | "duplicate" | "merged"; summary?: string; reason?: string }
124
125
  > {
125
- const kind = msg.role === "tool" ? "tool_result" : "paragraph";
126
- return await this.storeChunk(msg, msg.content, kind, 0);
126
+ return await this.storeChunk(msg, msg.content, "paragraph", 0);
127
127
  }
128
128
 
129
129
  private async storeChunk(
@@ -146,6 +146,8 @@ export class IngestWorker {
146
146
  let dedupTarget: string | null = null;
147
147
  let dedupReason: string | null = null;
148
148
  let mergedFromOld: string | null = null;
149
+ let mergeCount = 0;
150
+ let mergeHistory = "[]";
149
151
 
150
152
  // Fast path: exact content_hash match within same owner (agent dimension)
151
153
  const chunkOwner = msg.owner ?? "agent:main";
@@ -160,7 +162,7 @@ export class IngestWorker {
160
162
 
161
163
  // Smart dedup: find Top-5 similar chunks, then ask LLM to judge
162
164
  if (dedupStatus === "active" && embedding) {
163
- const similarThreshold = this.ctx.config.dedup?.similarityThreshold ?? 0.60;
165
+ const similarThreshold = this.ctx.config.dedup?.similarityThreshold ?? 0.80;
164
166
  const dedupOwnerFilter = msg.owner ? [msg.owner] : undefined;
165
167
  const topSimilar = findTopSimilar(this.store, embedding, similarThreshold, 5, this.ctx.log, dedupOwnerFilter);
166
168
 
@@ -208,7 +210,23 @@ export class IngestWorker {
208
210
 
209
211
  mergedFromOld = targetChunkId;
210
212
  dedupReason = dedupResult.reason;
211
- this.ctx.log.debug(`Smart dedup: UPDATE → old chunk=${targetChunkId} retired, new chunk=${chunkId} gets merged summary, reason: ${dedupResult.reason}`);
213
+
214
+ // Inherit merge history from the old chunk
215
+ if (oldChunk) {
216
+ const oldHistory = JSON.parse(oldChunk.mergeHistory || "[]");
217
+ oldHistory.push({
218
+ action: "merge",
219
+ at: Date.now(),
220
+ reason: dedupResult.reason,
221
+ from: oldSummary,
222
+ to: dedupResult.mergedSummary,
223
+ sourceChunkId: targetChunkId,
224
+ });
225
+ mergeHistory = JSON.stringify(oldHistory);
226
+ mergeCount = (oldChunk.mergeCount || 0) + 1;
227
+ }
228
+
229
+ this.ctx.log.debug(`Smart dedup: UPDATE → old chunk=${targetChunkId} retired, new chunk=${chunkId} gets merged summary (mergeCount=${mergeCount}), reason: ${dedupResult.reason}`);
212
230
  }
213
231
  }
214
232
 
@@ -235,9 +253,9 @@ export class IngestWorker {
235
253
  dedupStatus,
236
254
  dedupTarget,
237
255
  dedupReason,
238
- mergeCount: 0,
256
+ mergeCount: mergeCount,
239
257
  lastHitAt: null,
240
- mergeHistory: "[]",
258
+ mergeHistory: mergeHistory,
241
259
  createdAt: msg.timestamp,
242
260
  updatedAt: msg.timestamp,
243
261
  };
@@ -42,7 +42,7 @@ export class RecallEngine {
42
42
  const candidatePool = maxResults * 5;
43
43
  const ownerFilter = opts.ownerFilter;
44
44
 
45
- // Step 1: Gather candidates from both FTS and vector search
45
+ // Step 1: Gather candidates from FTS, vector search, and pattern search
46
46
  const ftsCandidates = query
47
47
  ? this.store.ftsSearch(query, candidatePool, ownerFilter)
48
48
  : [];
@@ -60,10 +60,24 @@ export class RecallEngine {
60
60
  }
61
61
  }
62
62
 
63
+ // Step 1b: Pattern search (LIKE-based) as fallback for short terms that
64
+ // trigram FTS cannot match (trigram requires >= 3 chars).
65
+ const shortTerms = query
66
+ .replace(/[."""(){}[\]*:^~!@#$%&\\/<>,;'`??。,!、:""''()【】《》]/g, " ")
67
+ .split(/\s+/)
68
+ .filter((t) => t.length === 2);
69
+ const patternHits = shortTerms.length > 0
70
+ ? this.store.patternSearch(shortTerms, { limit: candidatePool })
71
+ : [];
72
+ const patternRanked = patternHits.map((h, i) => ({
73
+ id: h.chunkId,
74
+ score: 1 / (i + 1),
75
+ }));
76
+
63
77
  // Step 2: RRF fusion
64
78
  const ftsRanked = ftsCandidates.map((c) => ({ id: c.chunkId, score: c.score }));
65
79
  const vecRanked = vecCandidates.map((c) => ({ id: c.chunkId, score: c.score }));
66
- const rrfScores = rrfFuse([ftsRanked, vecRanked], recallCfg.rrfK);
80
+ const rrfScores = rrfFuse([ftsRanked, vecRanked, patternRanked], recallCfg.rrfK);
67
81
 
68
82
  if (rrfScores.size === 0) {
69
83
  this.recordQuery(query, maxResults, minScore, 0);
@@ -118,9 +132,10 @@ export class RecallEngine {
118
132
  if (!chunk) continue;
119
133
  if (roleFilter && chunk.role !== roleFilter) continue;
120
134
 
135
+ const excerpt = (chunk.mergeCount ?? 0) > 0 ? chunk.summary : makeExcerpt(chunk.content);
121
136
  hits.push({
122
137
  summary: chunk.summary,
123
- original_excerpt: makeExcerpt(chunk.content),
138
+ original_excerpt: excerpt,
124
139
  ref: {
125
140
  sessionKey: chunk.sessionKey,
126
141
  chunkId: chunk.id,
@@ -255,7 +270,7 @@ export class RecallEngine {
255
270
  ): Promise<number[]> {
256
271
  const candidateList = candidates.map((c, i) => ({
257
272
  index: i,
258
- summary: `[${c.skill.name}] ${c.skill.description}`,
273
+ content: `[${c.skill.name}] ${c.skill.description}`,
259
274
  role: "skill" as const,
260
275
  }));
261
276
 
@@ -274,13 +289,5 @@ export class RecallEngine {
274
289
  }
275
290
 
276
291
  function makeExcerpt(content: string): string {
277
- const min = 200;
278
- const max = 500;
279
- if (content.length <= max) return content;
280
-
281
- let cut = content.lastIndexOf(".", max);
282
- if (cut < min) cut = content.lastIndexOf(" ", max);
283
- if (cut < min) cut = max;
284
-
285
- return content.slice(0, cut) + "…";
292
+ return content;
286
293
  }
@@ -1,91 +1,9 @@
1
1
  /**
2
2
  * Bundled MemOS memory-guide skill content.
3
- * Written to workspace/skills/memos-memory-guide on plugin register so OpenClaw loads it.
3
+ * Reads from skill/memos-memory-guide/SKILL.md at runtime (single source of truth).
4
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
- ---
5
+ import * as fs from "fs";
6
+ import * as path from "path";
9
7
 
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
- `;
8
+ const skillPath = path.join(__dirname, "..", "..", "skill", "memos-memory-guide", "SKILL.md");
9
+ export const MEMORY_GUIDE_SKILL_MD: string = fs.readFileSync(skillPath, "utf-8");
@@ -52,7 +52,7 @@ Task title: {TITLE}
52
52
  Task summary:
53
53
  {SUMMARY}
54
54
 
55
- LANGUAGE RULE: The "reason" field MUST use the SAME language as the task title/summary. Chinese input Chinese reason. English input English reason. "suggestedName" stays in English kebab-case.
55
+ LANGUAGE RULE (MUST FOLLOW): Detect the language of the task title/summary. If it is Chinese, the "reason" field MUST be in Chinese. If English, reason in English. Only "suggestedName" stays in English kebab-case. 如果任务标题/摘要是中文,reason 必须用中文。
56
56
 
57
57
  Reply in JSON only, no extra text:
58
58
  {
@@ -46,7 +46,7 @@ export class SqliteStore {
46
46
  content,
47
47
  content='chunks',
48
48
  content_rowid='rowid',
49
- tokenize='porter unicode61'
49
+ tokenize='trigram'
50
50
  );
51
51
 
52
52
  CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
@@ -109,6 +109,7 @@ export class SqliteStore {
109
109
  this.migrateOwnerFields();
110
110
  this.migrateSkillVisibility();
111
111
  this.migrateSkillEmbeddingsAndFts();
112
+ this.migrateFtsToTrigram();
112
113
  this.log.debug("Database schema initialized");
113
114
  }
114
115
 
@@ -159,7 +160,7 @@ export class SqliteStore {
159
160
  description,
160
161
  content='skills',
161
162
  content_rowid='rowid',
162
- tokenize='porter unicode61'
163
+ tokenize='trigram'
163
164
  );
164
165
  `);
165
166
 
@@ -195,6 +196,81 @@ export class SqliteStore {
195
196
  } catch { /* best-effort */ }
196
197
  }
197
198
 
199
+ private migrateFtsToTrigram(): void {
200
+ // Check if chunks_fts still uses the old tokenizer (porter unicode61)
201
+ try {
202
+ const row = this.db.prepare(
203
+ "SELECT sql FROM sqlite_master WHERE name='chunks_fts'"
204
+ ).get() as { sql: string } | undefined;
205
+ if (row && row.sql && !row.sql.includes("trigram")) {
206
+ this.log.info("Migrating chunks_fts from porter/unicode61 to trigram tokenizer...");
207
+ this.db.exec("DROP TRIGGER IF EXISTS chunks_ai");
208
+ this.db.exec("DROP TRIGGER IF EXISTS chunks_ad");
209
+ this.db.exec("DROP TRIGGER IF EXISTS chunks_au");
210
+ this.db.exec("DROP TABLE IF EXISTS chunks_fts");
211
+ this.db.exec(`
212
+ CREATE VIRTUAL TABLE chunks_fts USING fts5(
213
+ summary, content, content='chunks', content_rowid='rowid',
214
+ tokenize='trigram'
215
+ )
216
+ `);
217
+ this.db.exec(`
218
+ CREATE TRIGGER chunks_ai AFTER INSERT ON chunks BEGIN
219
+ INSERT INTO chunks_fts(rowid, summary, content) VALUES (new.rowid, new.summary, new.content);
220
+ END;
221
+ CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN
222
+ INSERT INTO chunks_fts(chunks_fts, rowid, summary, content) VALUES ('delete', old.rowid, old.summary, old.content);
223
+ END;
224
+ CREATE TRIGGER chunks_au AFTER UPDATE ON chunks BEGIN
225
+ INSERT INTO chunks_fts(chunks_fts, rowid, summary, content) VALUES ('delete', old.rowid, old.summary, old.content);
226
+ INSERT INTO chunks_fts(rowid, summary, content) VALUES (new.rowid, new.summary, new.content);
227
+ END
228
+ `);
229
+ this.db.exec("INSERT INTO chunks_fts(rowid, summary, content) SELECT rowid, summary, content FROM chunks");
230
+ const count = (this.db.prepare("SELECT COUNT(*) as c FROM chunks_fts").get() as { c: number }).c;
231
+ this.log.info(`Migrated chunks_fts to trigram: ${count} rows indexed`);
232
+ }
233
+ } catch (err) {
234
+ this.log.warn(`Failed to migrate chunks_fts to trigram: ${err}`);
235
+ }
236
+
237
+ // Same for skills_fts
238
+ try {
239
+ const row = this.db.prepare(
240
+ "SELECT sql FROM sqlite_master WHERE name='skills_fts'"
241
+ ).get() as { sql: string } | undefined;
242
+ if (row && row.sql && !row.sql.includes("trigram")) {
243
+ this.log.info("Migrating skills_fts to trigram tokenizer...");
244
+ this.db.exec("DROP TRIGGER IF EXISTS skills_ai");
245
+ this.db.exec("DROP TRIGGER IF EXISTS skills_ad");
246
+ this.db.exec("DROP TRIGGER IF EXISTS skills_au");
247
+ this.db.exec("DROP TABLE IF EXISTS skills_fts");
248
+ this.db.exec(`
249
+ CREATE VIRTUAL TABLE skills_fts USING fts5(
250
+ name, description, content='skills', content_rowid='rowid',
251
+ tokenize='trigram'
252
+ )
253
+ `);
254
+ this.db.exec(`
255
+ CREATE TRIGGER skills_ai AFTER INSERT ON skills BEGIN
256
+ INSERT INTO skills_fts(rowid, name, description) VALUES (new.rowid, new.name, new.description);
257
+ END;
258
+ CREATE TRIGGER skills_ad AFTER DELETE ON skills BEGIN
259
+ INSERT INTO skills_fts(skills_fts, rowid, name, description) VALUES ('delete', old.rowid, old.name, old.description);
260
+ END;
261
+ CREATE TRIGGER skills_au AFTER UPDATE ON skills BEGIN
262
+ INSERT INTO skills_fts(skills_fts, rowid, name, description) VALUES ('delete', old.rowid, old.name, old.description);
263
+ INSERT INTO skills_fts(rowid, name, description) VALUES (new.rowid, new.name, new.description);
264
+ END
265
+ `);
266
+ this.db.exec("INSERT INTO skills_fts(rowid, name, description) SELECT rowid, name, description FROM skills");
267
+ this.log.info("Migrated skills_fts to trigram");
268
+ }
269
+ } catch (err) {
270
+ this.log.warn(`Failed to migrate skills_fts to trigram: ${err}`);
271
+ }
272
+ }
273
+
198
274
  private migrateTaskId(): void {
199
275
  const cols = this.db.prepare("PRAGMA table_info(chunks)").all() as Array<{ name: string }>;
200
276
  if (!cols.some((c) => c.name === "task_id")) {
@@ -530,8 +606,6 @@ export class SqliteStore {
530
606
  getMetrics(days: number): {
531
607
  writesPerDay: Array<{ date: string; count: number }>;
532
608
  viewerCallsPerDay: Array<{ date: string; list: number; search: number; total: number }>;
533
- roleBreakdown: Record<string, number>;
534
- kindBreakdown: Record<string, number>;
535
609
  totals: { memories: number; sessions: number; embeddings: number; todayWrites: number; todayViewerCalls: number };
536
610
  } {
537
611
  const since = Date.now() - days * 86400 * 1000;
@@ -566,11 +640,6 @@ export class SqliteStore {
566
640
  .sort((a, b) => a[0].localeCompare(b[0]))
567
641
  .map(([date, v]) => ({ date, list: v.list, search: v.search, total: v.list + v.search }));
568
642
 
569
- const roles = this.db.prepare("SELECT role, COUNT(*) as count FROM chunks GROUP BY role").all() as Array<{ role: string; count: number }>;
570
- const kinds = this.db.prepare("SELECT kind, COUNT(*) as count FROM chunks GROUP BY kind").all() as Array<{ kind: string; count: number }>;
571
- const roleBreakdown = Object.fromEntries(roles.map((r) => [r.role, r.count]));
572
- const kindBreakdown = Object.fromEntries(kinds.map((k) => [k.kind, k.count]));
573
-
574
643
  const totalChunks = (this.db.prepare("SELECT COUNT(*) as c FROM chunks").get() as { c: number }).c;
575
644
  const totalSessions = (this.db.prepare("SELECT COUNT(DISTINCT session_key) as c FROM chunks").get() as { c: number }).c;
576
645
  const totalEmbeddings = (this.db.prepare("SELECT COUNT(*) as c FROM embeddings").get() as { c: number }).c;
@@ -580,8 +649,6 @@ export class SqliteStore {
580
649
  return {
581
650
  writesPerDay,
582
651
  viewerCallsPerDay,
583
- roleBreakdown,
584
- kindBreakdown,
585
652
  totals: {
586
653
  memories: totalChunks,
587
654
  sessions: totalSessions,
@@ -870,6 +937,10 @@ export class SqliteStore {
870
937
  { sql: "content LIKE '%=== MemOS LONG-TERM MEMORY%'", reason: "MemOS legacy injection" },
871
938
  { sql: "content LIKE '%[MemOS Auto-Recall]%'", reason: "MemOS Auto-Recall injection" },
872
939
  { sql: "content LIKE '%## Memory system%No memories were automatically recalled%'", reason: "Memory system no-recall hint" },
940
+ { sql: "content LIKE '%## Retrieved memories from past conversations%CRITICAL INSTRUCTION%'", reason: "prependContext recall injection" },
941
+ { sql: "content LIKE '%VERIFIED facts the user previously shared%'", reason: "VERIFIED facts injection" },
942
+ { sql: "content LIKE '%<memos_system_instruction>%'", reason: "memos_system_instruction injection" },
943
+ { sql: "content LIKE '%📝 Related memories:%'", reason: "Related memories injection" },
873
944
  ];
874
945
  for (const { sql, reason } of patterns) {
875
946
  const rows = this.db.prepare(
@@ -1103,14 +1174,16 @@ export class SqliteStore {
1103
1174
  */
1104
1175
  findActiveChunkByHash(content: string, owner?: string): string | null {
1105
1176
  const hash = contentHash(content);
1177
+ // Check ANY existing chunk with the same hash (regardless of dedup_status)
1178
+ // to prevent re-creating duplicates when all prior copies have been marked duplicate/merged.
1106
1179
  if (owner) {
1107
1180
  const row = this.db.prepare(
1108
- "SELECT id FROM chunks WHERE content_hash = ? AND dedup_status = 'active' AND owner = ? LIMIT 1",
1181
+ "SELECT id FROM chunks WHERE content_hash = ? AND owner = ? ORDER BY CASE dedup_status WHEN 'active' THEN 0 ELSE 1 END LIMIT 1",
1109
1182
  ).get(hash, owner) as { id: string } | undefined;
1110
1183
  return row?.id ?? null;
1111
1184
  }
1112
1185
  const row = this.db.prepare(
1113
- "SELECT id FROM chunks WHERE content_hash = ? AND dedup_status = 'active' LIMIT 1",
1186
+ "SELECT id FROM chunks WHERE content_hash = ? ORDER BY CASE dedup_status WHEN 'active' THEN 0 ELSE 1 END LIMIT 1",
1114
1187
  ).get(hash) as { id: string } | undefined;
1115
1188
  return row?.id ?? null;
1116
1189
  }
@@ -1365,14 +1438,13 @@ export class SqliteStore {
1365
1438
  * with implicit AND (space-separated) for safe querying.
1366
1439
  */
1367
1440
  function sanitizeFtsQuery(raw: string): string {
1368
- const tokens = raw
1369
- .replace(/[."""(){}[\]*:^~!@#$%&\\/<>,;'`]/g, " ")
1370
- .split(/\s+/)
1371
- .map((t) => t.trim().replace(/^-+|-+$/g, ""))
1372
- .filter((t) => t.length > 1)
1373
- .filter((t) => !FTS_RESERVED.has(t.toUpperCase()));
1374
-
1375
- return tokens.join(" ");
1441
+ // With trigram tokenizer, the query string is matched as a substring.
1442
+ // Clean special chars but keep the text as-is (trigram needs >= 3 chars).
1443
+ const cleaned = raw
1444
+ .replace(/[."""(){}[\]*:^~!@#$%&\\/<>,;'`??。,!、:""''()【】《》]/g, " ")
1445
+ .trim();
1446
+ if (cleaned.length < 3) return "";
1447
+ return cleaned;
1376
1448
  }
1377
1449
 
1378
1450
  const FTS_RESERVED = new Set(["AND", "OR", "NOT", "NEAR"]);
@@ -47,10 +47,7 @@ export function createMemoryGetTool(store: SqliteStore): ToolDefinition {
47
47
  return { error: `Chunk not found: ${ref.chunkId}` };
48
48
  }
49
49
 
50
- const content =
51
- chunk.content.length > maxChars
52
- ? chunk.content.slice(0, maxChars) + "…"
53
- : chunk.content;
50
+ const content = chunk.content;
54
51
 
55
52
  const result: GetResult = {
56
53
  content,
package/src/types.ts CHANGED
@@ -55,14 +55,7 @@ export interface Task {
55
55
  updatedAt: number;
56
56
  }
57
57
 
58
- export type ChunkKind =
59
- | "paragraph"
60
- | "code_block"
61
- | "error_stack"
62
- | "command"
63
- | "list"
64
- | "mixed"
65
- | "tool_result";
58
+ export type ChunkKind = "paragraph";
66
59
 
67
60
  export interface ChunkRef {
68
61
  sessionKey: string;
@@ -294,7 +287,7 @@ export const DEFAULTS = {
294
287
  mmrLambda: 0.7,
295
288
  recencyHalfLifeDays: 14,
296
289
  vectorSearchMaxChunks: 0,
297
- dedupSimilarityThreshold: 0.60,
290
+ dedupSimilarityThreshold: 0.80,
298
291
  evidenceWrapperTag: "STORED_MEMORY",
299
292
  excerptMinChars: 200,
300
293
  excerptMaxChars: 500,