openclaw-memory-alibaba-local 1.0.4 → 1.0.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 (4) hide show
  1. package/db.ts +1 -1
  2. package/index.ts +228 -33
  3. package/package.json +1 -1
  4. package/prompts.ts +57 -0
package/db.ts CHANGED
@@ -1400,7 +1400,7 @@ export class MemoryDB {
1400
1400
  try {
1401
1401
  const eid = sqlEscapeLiteral(id);
1402
1402
  await (this.table! as any).update(
1403
- { createdAt: now },
1403
+ { createdAt: String(now) },
1404
1404
  { where: `id = '${eid}' AND agentId = '${a}' AND category = '${wf}'` },
1405
1405
  );
1406
1406
  } catch (err) {
package/index.ts CHANGED
@@ -63,6 +63,7 @@ import type { MemoryEntry, MemorySearchResult } from "./db.js";
63
63
  import {
64
64
  buildMemoryExtractionPrompt,
65
65
  buildUserImageExtractionPrompt,
66
+ buildWorldImageExtractionPrompt,
66
67
  SELF_IMPROVING_EXTRACTION_INSTRUCTIONS,
67
68
  } from "./prompts.js";
68
69
  import { extractUserQueryForRecall, stripForLogicalMemoryExtraction } from "./prompt-strip.js";
@@ -782,6 +783,119 @@ async function extractUserImageWithLLM(
782
783
  }
783
784
  }
784
785
 
786
+ // ---------------------------------------------------------------------------
787
+ // World image extraction: recall + LLM CRUD for world facts
788
+ // ---------------------------------------------------------------------------
789
+
790
+ type WorldImageAction =
791
+ | { action: "insert"; text: string; importance: number }
792
+ | { action: "update"; memoryId: string; text: string; importance: number }
793
+ | { action: "delete"; memoryId: string }
794
+ | { action: "skip" };
795
+
796
+ /**
797
+ * World image extraction: given N new world-fact extractions + M existing
798
+ * similar world-fact memories, ask LLM to decide insert / update / skip / delete for each.
799
+ */
800
+ async function extractWorldImageWithLLM(
801
+ llmConfig: LLMConfig,
802
+ newItems: LLMExtractionItem[],
803
+ existingCandidates: MemorySearchResult[],
804
+ ): Promise<WorldImageAction[]> {
805
+ if (newItems.length === 0) return [];
806
+
807
+ const newForPrompt = newItems.map((item, i) => ({
808
+ index: i,
809
+ text: item.text,
810
+ importance: item.importance,
811
+ }));
812
+ const existingForPrompt = existingCandidates.map((r) => ({
813
+ id: r.entry.id,
814
+ text: r.entry.text,
815
+ }));
816
+
817
+ const prompt = buildWorldImageExtractionPrompt(newForPrompt, existingForPrompt);
818
+ logLlmCall("worldImageExtraction", prompt.length);
819
+
820
+ const openai = new OpenAI({
821
+ apiKey: llmConfig.apiKey,
822
+ baseURL: llmConfig.baseUrl,
823
+ });
824
+
825
+ const completion = await openai.chat.completions.create({
826
+ model: llmConfig.model,
827
+ messages: [{ role: "user", content: prompt }],
828
+ temperature: 0,
829
+ max_tokens: 8192,
830
+ });
831
+ const raw = completion.choices[0]?.message?.content?.trim() ?? "";
832
+
833
+ const existingIdSet = new Set(existingCandidates.map((r) => r.entry.id));
834
+
835
+ try {
836
+ const parsed = JSON.parse(stripMarkdownJsonFence(raw)) as {
837
+ actions?: Array<{
838
+ index?: number;
839
+ action?: string;
840
+ text?: string;
841
+ importance?: unknown;
842
+ memoryId?: string;
843
+ }>;
844
+ };
845
+ const list = Array.isArray(parsed.actions) ? parsed.actions : [];
846
+ const resultMap = new Map<number, WorldImageAction[]>();
847
+ // Default: insert for each index
848
+ for (let i = 0; i < newItems.length; i++) {
849
+ resultMap.set(i, [{
850
+ action: "insert" as const,
851
+ text: newItems[i]!.text,
852
+ importance: newItems[i]!.importance,
853
+ }]);
854
+ }
855
+
856
+ for (const a of list) {
857
+ const idx = typeof a.index === "number" ? a.index : -1;
858
+ if (idx < 0 || idx >= newItems.length) continue;
859
+
860
+ if (a.action === "skip") {
861
+ resultMap.set(idx, [{ action: "skip" }]);
862
+ } else if (a.action === "delete" && typeof a.memoryId === "string" && existingIdSet.has(a.memoryId)) {
863
+ const existing = resultMap.get(idx) ?? [];
864
+ resultMap.set(idx, [{ action: "delete", memoryId: a.memoryId }, ...existing.filter(x => x.action !== "skip")]);
865
+ } else if (a.action === "update" && typeof a.memoryId === "string" && existingIdSet.has(a.memoryId)) {
866
+ const text = typeof a.text === "string" ? a.text.trim() : "";
867
+ if (text.length < 10) continue;
868
+ resultMap.set(idx, [{
869
+ action: "update",
870
+ memoryId: a.memoryId,
871
+ text,
872
+ importance: clampImportance(a.importance ?? newItems[idx]!.importance),
873
+ }]);
874
+ } else if (a.action === "insert") {
875
+ const text = typeof a.text === "string" ? a.text.trim() : "";
876
+ const finalText = text.length >= 10 ? text : newItems[idx]!.text;
877
+ resultMap.set(idx, [{
878
+ action: "insert",
879
+ text: finalText,
880
+ importance: clampImportance(a.importance ?? newItems[idx]!.importance),
881
+ }]);
882
+ }
883
+ }
884
+ const result: WorldImageAction[] = [];
885
+ for (let i = 0; i < newItems.length; i++) {
886
+ result.push(...(resultMap.get(i) ?? []));
887
+ }
888
+ return result;
889
+ } catch (err: unknown) {
890
+ console.warn(`[openclaw-memory-alibaba-local] worldImageExtraction JSON parse failed, fallback insert all: ${err}`);
891
+ return newItems.map((item) => ({
892
+ action: "insert" as const,
893
+ text: item.text,
894
+ importance: item.importance,
895
+ }));
896
+ }
897
+ }
898
+
785
899
  function shouldCapture(text: string, options?: { maxChars?: number }): boolean {
786
900
  const maxChars = options?.maxChars ?? DEFAULT_CAPTURE_MAX_CHARS;
787
901
  if (text.length < 10 || text.length > maxChars) return false;
@@ -947,6 +1061,11 @@ type DeltaFullContextRow = {
947
1061
  /**
948
1062
  * agent_end: per-role cursors → delta rows by source → LanceDB for full_context_* (shared batchId, no embed / no dedup);
949
1063
  * then Promise.all(user-memory pipeline on user deltas, self-improving on user+assistant deltas).
1064
+ *
1065
+ * When `cursorOnly` is true the function advances the per-role cursor and persists it
1066
+ * but skips all memory extraction / storage. This is used for non-user triggers
1067
+ * (heartbeat, cron, memory, …) so the cursor stays in sync with the growing
1068
+ * session messages list without accidentally capturing heartbeat content.
950
1069
  */
951
1070
  async function runAgentEndCapture(
952
1071
  cfg: MemoryConfig,
@@ -957,6 +1076,7 @@ async function runAgentEndCapture(
957
1076
  userId: string | null,
958
1077
  messages: unknown[],
959
1078
  lancedbDir: string,
1079
+ cursorOnly = false,
960
1080
  ): Promise<void> {
961
1081
  if (messages.length === 0) {
962
1082
  return;
@@ -973,6 +1093,25 @@ async function runAgentEndCapture(
973
1093
  }
974
1094
 
975
1095
  const running: Record<string, number> = { ...saved };
1096
+
1097
+ // --- cursor-only fast path: count roles then persist without extraction ---
1098
+ if (cursorOnly) {
1099
+ for (const msg of messages) {
1100
+ if (!msg || typeof msg !== "object") continue;
1101
+ const m = msg as Record<string, unknown>;
1102
+ const roleRaw = typeof m.role === "string" ? m.role : "unknown";
1103
+ const roleKey = normalizeRoleForCursor(roleRaw);
1104
+ running[roleKey] = (running[roleKey] ?? 0) + 1;
1105
+ }
1106
+ map[key] = { version: 2, roleCounts: { ...running }, lastMessagesLength: messages.length };
1107
+ saveAgentEndCursorMap(lancedbDir, map);
1108
+ console.log(
1109
+ `[openclaw-memory-alibaba-local] agent_end cursor-only advance (non-user trigger) messages=${messages.length}`,
1110
+ );
1111
+ return;
1112
+ }
1113
+
1114
+ // --- full capture path (trigger === "user") ---
976
1115
  const fullRows: DeltaFullContextRow[] = [];
977
1116
  const userRawTexts: string[] = [];
978
1117
  const uaLines: string[] = [];
@@ -1133,50 +1272,103 @@ async function captureUserMemoryFromInboundTexts(
1133
1272
  // ---- Parallel: event-item pipeline & user-item pipeline ----
1134
1273
  const eventPipeline = async () => {
1135
1274
  if (eventItems.length === 0) return;
1136
- if (cfg.memory_duplication_conflict_process) {
1137
- type EmbeddedItem = { item: LLMExtractionItem; vectors: number[][]; primary: number[] };
1138
- const embedded: EmbeddedItem[] = [];
1139
- for (const item of eventItems) {
1140
- const truncated = truncateForCapture(item.text, cfg.captureMaxChars);
1141
- const { vectors } = await backend.encodeForStorage(truncated);
1142
- embedded.push({ item: { ...item, text: truncated }, vectors, primary: vectors[0] });
1275
+
1276
+ // --- No LLM fallback: simple dedup ---
1277
+ if (!cfg.llm) {
1278
+ for (const e of eventItems) {
1279
+ const text = truncateForCapture(e.text, cfg.captureMaxChars);
1280
+ if (await db.existsSemanticDuplicate(agentId, sessionKey, e.category, text)) {
1281
+ continue;
1282
+ }
1283
+ await storeOneCaptureItem(agentId, { category: e.category, text, importance: e.importance }, cfg, db, backend, {
1284
+ userId,
1285
+ sessionId: sessionKey,
1286
+ });
1143
1287
  }
1144
-
1145
- const clusters = greedyCluster(embedded, (e) => e.primary, (e) => extractDatePrefix(e.item.text), 0.85, 3);
1146
-
1147
- for (const cluster of clusters) {
1148
- const text = concatDedupeDate(cluster.map((e) => e.item.text));
1149
- const importance = Math.max(...cluster.map((e) => e.item.importance));
1150
- const { vectors } = await backend.encodeForStorage(text);
1288
+ return;
1289
+ }
1290
+
1291
+ // --- LLM path: recall + CRUD ---
1292
+
1293
+ // 1. Batch embed all event items
1294
+ const embeddingResults: { item: LLMExtractionItem; vectors: number[][] }[] = [];
1295
+ for (const item of eventItems) {
1296
+ const truncated = truncateForCapture(item.text, cfg.captureMaxChars);
1297
+ const { vectors } = await backend.encodeForStorage(truncated);
1298
+ embeddingResults.push({ item: { ...item, text: truncated }, vectors });
1299
+ }
1300
+
1301
+ // 2. Recall top-3 similar existing world_facts per item, merge & dedup (cap ~10)
1302
+ const allVectors = embeddingResults.flatMap((r) => r.vectors);
1303
+ const recallMinScore = Math.max(0.5, cfg.similarityThresholdUserMemory - 0.35);
1304
+ const existingCandidates = allVectors.length > 0
1305
+ ? await db.searchMerged(agentId, allVectors, 3, recallMinScore, [WORLD_FACT])
1306
+ : [];
1307
+
1308
+ // 3. LLM CRUD decision
1309
+ console.log(`[openclaw-memory-alibaba-local] worldImageExtraction input: ${eventItems.length} event items, ${existingCandidates.length} existing candidates`);
1310
+ const worldActions = await extractWorldImageWithLLM(
1311
+ cfg.llm,
1312
+ embeddingResults.map((r) => r.item),
1313
+ existingCandidates,
1314
+ ).catch((err: unknown) => {
1315
+ console.warn(`[openclaw-memory-alibaba-local] worldImageExtraction LLM failed, fallback insert all: ${err}`);
1316
+ return embeddingResults.map((r): WorldImageAction => ({
1317
+ action: "insert" as const,
1318
+ text: r.item.text,
1319
+ importance: r.item.importance,
1320
+ }));
1321
+ });
1322
+
1323
+ // 4. Execute actions
1324
+ let insertCount = 0;
1325
+ for (const action of worldActions) {
1326
+ if (action.action === "skip") continue;
1327
+
1328
+ if (action.action === "delete") {
1329
+ const hit = existingCandidates.find((c) => c.entry.id === action.memoryId);
1330
+ if (hit) {
1331
+ await deleteSimilarLogicalMemory(db, agentId, sessionKey, hit);
1332
+ }
1333
+ continue;
1334
+ }
1335
+
1336
+ if (action.action === "update") {
1337
+ const hit = existingCandidates.find((c) => c.entry.id === action.memoryId);
1338
+ if (hit) {
1339
+ await deleteSimilarLogicalMemory(db, agentId, sessionKey, hit);
1340
+ }
1341
+ const { vectors } = await backend.encodeForStorage(action.text);
1151
1342
  const rows = buildChunkRows(
1152
- { category: WORLD_FACT as MemoryCategory, text, importance },
1343
+ { category: WORLD_FACT as MemoryCategory, text: action.text, importance: action.importance },
1153
1344
  vectors,
1154
1345
  { userId, sessionId: sessionKey },
1155
1346
  );
1156
1347
  await db.storeMany(agentId, rows);
1348
+ insertCount++;
1349
+ } else {
1350
+ // insert
1351
+ const { vectors } = await backend.encodeForStorage(action.text);
1352
+ const rows = buildChunkRows(
1353
+ { category: WORLD_FACT as MemoryCategory, text: action.text, importance: action.importance },
1354
+ vectors,
1355
+ { userId, sessionId: sessionKey },
1356
+ );
1357
+ await db.storeMany(agentId, rows);
1358
+ insertCount++;
1157
1359
  }
1158
- console.log(`[openclaw-memory-alibaba-local] clustered ${eventItems.length} event items \u2192 ${clusters.length} entries (max 3 per cluster, threshold 0.85)`);
1159
-
1160
- // World fact LRU GC: every 10 insertions
1161
- worldFactGcCounter++;
1360
+ }
1361
+ console.log(`[openclaw-memory-alibaba-local] worldImageExtraction done: ${worldActions.length} actions, ${insertCount} stored`);
1362
+
1363
+ // 5. World fact LRU GC: every 10 insertions
1364
+ if (insertCount > 0) {
1365
+ worldFactGcCounter += insertCount;
1162
1366
  if (worldFactGcCounter >= 10) {
1163
1367
  worldFactGcCounter = 0;
1164
1368
  db.gcWorldFact(agentId, 10_000, 30 * 24 * 60 * 60 * 1000, 25_000).catch((err) =>
1165
1369
  console.warn(`[openclaw-memory-alibaba-local] gcWorldFact failed: ${err}`),
1166
1370
  );
1167
1371
  }
1168
- } else {
1169
- // Simple dedup path for event items
1170
- for (const e of eventItems) {
1171
- const text = truncateForCapture(e.text, cfg.captureMaxChars);
1172
- if (await db.existsSemanticDuplicate(agentId, sessionKey, e.category, text)) {
1173
- continue;
1174
- }
1175
- await storeOneCaptureItem(agentId, { category: e.category, text, importance: e.importance }, cfg, db, backend, {
1176
- userId,
1177
- sessionId: sessionKey,
1178
- });
1179
- }
1180
1372
  }
1181
1373
  };
1182
1374
 
@@ -1942,7 +2134,6 @@ const memoryPlugin = {
1942
2134
 
1943
2135
  if (cfg.autoCapture) {
1944
2136
  api.on("agent_end", async (event, ctx) => {
1945
- if ((ctx as { trigger?: string }).trigger !== "user") return;
1946
2137
  if (!db || !backend) {
1947
2138
  return;
1948
2139
  }
@@ -1950,6 +2141,9 @@ const memoryPlugin = {
1950
2141
  return;
1951
2142
  }
1952
2143
 
2144
+ const trigger = (ctx as { trigger?: string }).trigger;
2145
+ const isUserTrigger = trigger === "user";
2146
+
1953
2147
  try {
1954
2148
  const tCap0 = Date.now();
1955
2149
  const storageSessionKey = resolveStorageSessionKey(ctx);
@@ -1971,9 +2165,10 @@ const memoryPlugin = {
1971
2165
  userId,
1972
2166
  event.messages,
1973
2167
  resolvedDbPath,
2168
+ !isUserTrigger,
1974
2169
  );
1975
2170
  console.log(
1976
- `[openclaw-memory-alibaba-local] agent_end capture done totalHookMs=${Date.now() - tCap0} messages=${event.messages.length}`,
2171
+ `[openclaw-memory-alibaba-local] agent_end ${isUserTrigger ? "capture" : "cursor-only"} done totalHookMs=${Date.now() - tCap0} messages=${event.messages.length} trigger=${trigger ?? "unknown"}`,
1977
2172
  );
1978
2173
  } catch (err) {
1979
2174
  console.warn(`[openclaw-memory-alibaba-local] agent_end capture failed: ${String(err)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-memory-alibaba-local",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "OpenClaw memory plugin: local LanceDB + DashScope-compatible embeddings",
5
5
  "type": "module",
6
6
  "engines": {
package/prompts.ts CHANGED
@@ -244,3 +244,60 @@ export function buildUserImageExtractionPrompt(
244
244
  `\nToday is ${today}.\n\nNew extractions:\n${newSection}\n\nExisting memories:\n${existingSection}\n`
245
245
  );
246
246
  }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // 4. WORLD IMAGE EXTRACTION (deduplicate & reconcile world facts)
250
+ // ---------------------------------------------------------------------------
251
+
252
+ export const WORLD_IMAGE_EXTRACTION_INSTRUCTIONS = `You are a World-Fact Organizer that keeps a concise, non-redundant knowledge base of real-world events, facts, and context mentioned in conversations.
253
+
254
+ # Inputs
255
+ - **Batch** (indexed 0..N-1): newly extracted world facts / events.
256
+ - **Store** (with id): existing world-fact memories in the database. May be empty.
257
+
258
+ # What to Keep
259
+ Only INSERT or UPDATE information that captures a concrete, verifiable fact or event:
260
+ 1. Events — what happened, when, where, who was involved
261
+ 2. Factual statements — statistics, dates, locations, outcomes
262
+ 3. Contextual knowledge — project status, external conditions, third-party decisions
263
+
264
+ # What to SKIP
265
+ - Information already fully covered by a Store item (same meaning, same or less detail)
266
+ - Vague or speculative statements with no concrete fact
267
+ - Purely conversational filler with no informational content
268
+
269
+ # Reconciliation Principles
270
+ 1. **Prefer the richer version**: When a batch item and a Store item describe the same topic, keep whichever has the most information. If the batch adds new details, UPDATE.
271
+ 2. **Preserve temporal markers**: Keep [as of ...] or [date] prefixes — world facts are time-sensitive.
272
+ 3. **High cohesion**: Only merge entries about the exact same event or fact. Different events stay separate even if related.
273
+ 4. **Contradiction = replace**: If a batch item directly contradicts a Store item (e.g. different outcome), DELETE the old item and INSERT the new one.
274
+
275
+ # Actions (one per batch index)
276
+ - **INSERT**: New world fact not in Store.
277
+ - **UPDATE** (memoryId): Store item covers the same event/fact; merge to produce the richer version.
278
+ - **SKIP**: Already fully covered by Store, or not a concrete fact.
279
+ - **DELETE** (memoryId): Batch item contradicts a Store item. Delete old; INSERT new.
280
+
281
+ # Output
282
+ Reply with ONLY a JSON object:
283
+ {"actions":[
284
+ {"index":0,"action":"insert","text":"[2026-04-01] Project X launched v2.0","importance":0.5},
285
+ {"index":1,"action":"skip"},
286
+ {"index":2,"action":"update","memoryId":"uuid","text":"[2026-03-28] Company Y acquired Z for $2B, deal finalized","importance":0.5},
287
+ {"index":3,"action":"delete","memoryId":"uuid"}
288
+ ]}
289
+ Every batch index must appear. A single index may produce BOTH delete + insert.
290
+ `;
291
+
292
+ export function buildWorldImageExtractionPrompt(
293
+ newItems: Array<{ index: number; text: string; importance: number }>,
294
+ existingMemories: Array<{ id: string; text: string }>,
295
+ ): string {
296
+ const today = new Date().toISOString().split("T")[0];
297
+ const newSection = JSON.stringify(newItems, null, 2);
298
+ const existingSection = JSON.stringify(existingMemories, null, 2);
299
+ return (
300
+ WORLD_IMAGE_EXTRACTION_INSTRUCTIONS +
301
+ `\nToday is ${today}.\n\nNew extractions:\n${newSection}\n\nExisting memories:\n${existingSection}\n`
302
+ );
303
+ }