openclaw-amem 1.2.2 → 1.3.0

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/dist/index.js CHANGED
@@ -127,6 +127,19 @@ var init_embedding = __esm({
127
127
  }
128
128
  });
129
129
 
130
+ // ../amem-core/src/auth.ts
131
+ function canWrite(note, callerAgentId) {
132
+ return note.owner === callerAgentId || note.writers.includes(callerAgentId) || note.writers.includes("*");
133
+ }
134
+ function canRead(note, callerAgentId) {
135
+ return note.owner === callerAgentId || note.readers.includes(callerAgentId) || note.readers.includes("*");
136
+ }
137
+ var init_auth = __esm({
138
+ "../amem-core/src/auth.ts"() {
139
+ "use strict";
140
+ }
141
+ });
142
+
130
143
  // ../amem-core/src/storage.ts
131
144
  async function qdrant(method, path6, body) {
132
145
  const res = await fetch(`${QDRANT_URL}${path6}`, {
@@ -180,6 +193,10 @@ async function ensureCollection(collectionName) {
180
193
  field_name: "topics",
181
194
  field_schema: "keyword"
182
195
  });
196
+ await qdrant("PUT", `/collections/${col}/index`, {
197
+ field_name: "subjects",
198
+ field_schema: "keyword"
199
+ });
183
200
  markReady();
184
201
  }
185
202
  function noteToPoint(note) {
@@ -212,6 +229,9 @@ function noteToPoint(note) {
212
229
  // 30
213
230
  evolution_type: note.evolution_type || "",
214
231
  conflict: note.conflict ?? false,
232
+ conflicts_with: note.conflicts_with ?? [],
233
+ conflict_reason: note.conflict_reason ?? "",
234
+ subjects: note.subjects ?? [],
215
235
  // 31
216
236
  ephemeral: note.ephemeral ?? false,
217
237
  low_quality: note.low_quality ?? false,
@@ -264,6 +284,9 @@ function pointToNote(point) {
264
284
  // 30
265
285
  evolution_type: typeof p.evolution_type === "string" && ["EVOLVE", "CONFLICT", "EXPAND", "NEW"].includes(p.evolution_type) ? p.evolution_type : void 0,
266
286
  conflict: p.conflict === true,
287
+ conflicts_with: Array.isArray(p.conflicts_with) ? p.conflicts_with.filter((v) => typeof v === "string") : [],
288
+ conflict_reason: typeof p.conflict_reason === "string" ? p.conflict_reason : "",
289
+ subjects: Array.isArray(p.subjects) ? p.subjects.filter((v) => typeof v === "string") : [],
267
290
  // 31
268
291
  ephemeral: p.ephemeral === true,
269
292
  low_quality: p.low_quality === true,
@@ -273,28 +296,41 @@ function pointToNote(point) {
273
296
  writers: Array.isArray(p.writers) ? p.writers : [p.agent_id || "main"]
274
297
  };
275
298
  }
276
- function agentFilter(agentId) {
299
+ function agentFilter(agentId, subject) {
300
+ const must = [
301
+ {
302
+ should: [
303
+ { key: "agent_id", match: { value: agentId } },
304
+ { key: "agent_id", match: { value: "shared" } }
305
+ ]
306
+ }
307
+ ];
308
+ if (subject !== void 0) {
309
+ must.push({
310
+ should: [{ key: "subjects", match: { value: subject } }, { is_empty: { key: "subjects" } }]
311
+ });
312
+ }
277
313
  return {
278
- must: [
279
- {
280
- should: [
281
- { key: "agent_id", match: { value: agentId } },
282
- { key: "agent_id", match: { value: "shared" } }
283
- ]
284
- }
285
- ],
314
+ must,
286
315
  must_not: [{ key: "is_active", match: { value: false } }]
287
316
  };
288
317
  }
289
318
  function makeCrud(collectionName, modeBIsolated = false) {
290
319
  const col = collectionName;
291
- function scopedAgentFilter(agentId) {
320
+ function scopedAgentFilter(agentId, subject) {
292
321
  if (modeBIsolated) {
322
+ const must = [];
323
+ if (subject !== void 0) {
324
+ must.push({
325
+ should: [{ key: "subjects", match: { value: subject } }, { is_empty: { key: "subjects" } }]
326
+ });
327
+ }
293
328
  return {
329
+ ...must.length > 0 && { must },
294
330
  must_not: [{ key: "is_active", match: { value: false } }]
295
331
  };
296
332
  }
297
- return agentFilter(agentId);
333
+ return agentFilter(agentId, subject);
298
334
  }
299
335
  return {
300
336
  async addNote(note) {
@@ -303,7 +339,14 @@ function makeCrud(collectionName, modeBIsolated = false) {
303
339
  points: [noteToPoint(note)]
304
340
  });
305
341
  },
306
- async getNote(id) {
342
+ /**
343
+ * Story 36: this is the one read that bypasses the agent filter — it fetches
344
+ * straight by UUID. Pass `readerAgentId` to enforce `readers`; an unreadable
345
+ * note comes back as `null` (indistinguishable from missing, so nothing leaks,
346
+ * and callers already handle null). Omitting it skips the check, preserving
347
+ * behaviour for internal callers that only ever hold their own ids.
348
+ */
349
+ async getNote(id, readerAgentId) {
307
350
  await ensureCollection(col);
308
351
  try {
309
352
  const result = await qdrant("POST", `/collections/${col}/points`, {
@@ -312,7 +355,9 @@ function makeCrud(collectionName, modeBIsolated = false) {
312
355
  with_vector: true
313
356
  });
314
357
  if (!result.length) return null;
315
- return pointToNote(result[0]);
358
+ const note = pointToNote(result[0]);
359
+ if (readerAgentId !== void 0 && !canRead(note, readerAgentId)) return null;
360
+ return note;
316
361
  } catch {
317
362
  return null;
318
363
  }
@@ -348,17 +393,48 @@ function makeCrud(collectionName, modeBIsolated = false) {
348
393
  if (!result.points.length) return null;
349
394
  return pointToNote(result.points[0]);
350
395
  },
351
- async updateNoteContent(id, content, embedding, hash) {
396
+ /**
397
+ * Story 33: pass `callerAgentId` to enforce the writers policy. Callers that
398
+ * hold the note already should prefer checking `canWrite` themselves; this
399
+ * fetch-then-check path exists for callers that only have an id (the plugin's
400
+ * CRUD hook). Returns false — without writing — when the caller may not write.
401
+ * Omitting `callerAgentId` skips the check, preserving existing behaviour for
402
+ * internal callers that are already scoped to their own notes.
403
+ */
404
+ async updateNoteContent(id, content, embedding, hash, callerAgentId) {
352
405
  await ensureCollection(col);
406
+ let existing = null;
407
+ if (callerAgentId !== void 0) {
408
+ existing = await this.getNote(id);
409
+ if (existing && !canWrite(existing, callerAgentId)) return false;
410
+ }
353
411
  await qdrant("PUT", `/collections/${col}/points/vectors?wait=true`, {
354
412
  points: [{ id, vector: embedding }]
355
413
  });
414
+ const payload = { content, hash };
415
+ if (existing) {
416
+ const history = [
417
+ ...existing.evolution_history ?? [],
418
+ {
419
+ triggeredBy: "",
420
+ triggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
421
+ oldContext: existing.context,
422
+ newContext: existing.context,
423
+ oldTags: existing.tags,
424
+ newTags: existing.tags,
425
+ action: "crud_update",
426
+ oldContent: existing.content
427
+ }
428
+ ];
429
+ payload.evolution_history = JSON.stringify(history);
430
+ }
356
431
  await qdrant("POST", `/collections/${col}/points/payload?wait=true`, {
357
- payload: { content, hash },
432
+ payload,
358
433
  points: [id]
359
434
  });
435
+ return true;
360
436
  },
361
- async queryByEmbedding(embedding, topK, agentId, scoreThreshold = 0) {
437
+ async queryByEmbedding(embedding, topK, agentId, scoreThreshold = 0, subject) {
362
438
  await ensureCollection(col);
363
439
  const result = await qdrant("POST", `/collections/${col}/points/search`, {
364
440
  vector: embedding,
@@ -366,7 +442,7 @@ function makeCrud(collectionName, modeBIsolated = false) {
366
442
  with_payload: true,
367
443
  with_vector: true,
368
444
  score_threshold: scoreThreshold,
369
- filter: scopedAgentFilter(agentId)
445
+ filter: scopedAgentFilter(agentId, subject)
370
446
  });
371
447
  const queryResults = result.map((r) => ({
372
448
  note: pointToNote(r),
@@ -400,14 +476,14 @@ function makeCrud(collectionName, modeBIsolated = false) {
400
476
  }
401
477
  return queryResults;
402
478
  },
403
- async listNotes(agentId) {
479
+ async listNotes(agentId, subject) {
404
480
  await ensureCollection(col);
405
481
  const body = {
406
482
  with_payload: true,
407
483
  with_vector: true,
408
484
  limit: 1e4
409
485
  };
410
- if (agentId) body.filter = scopedAgentFilter(agentId);
486
+ if (agentId) body.filter = scopedAgentFilter(agentId, subject);
411
487
  const result = await qdrant("POST", `/collections/${col}/points/scroll`, body);
412
488
  return result.points.map(pointToNote);
413
489
  },
@@ -417,12 +493,18 @@ function makeCrud(collectionName, modeBIsolated = false) {
417
493
  points: [id]
418
494
  });
419
495
  },
420
- async invalidateNote(id) {
496
+ /** Story 33: see `updateNoteContent` — returns false, unwritten, when denied. */
497
+ async invalidateNote(id, callerAgentId) {
421
498
  await ensureCollection(col);
499
+ if (callerAgentId !== void 0) {
500
+ const existing = await this.getNote(id);
501
+ if (existing && !canWrite(existing, callerAgentId)) return false;
502
+ }
422
503
  await qdrant("POST", `/collections/${col}/points/payload?wait=true`, {
423
504
  payload: { is_active: false },
424
505
  points: [id]
425
506
  });
507
+ return true;
426
508
  },
427
509
  async getNotesByDatePrefix(datePrefix, agentId) {
428
510
  await ensureCollection(col);
@@ -468,6 +550,7 @@ function makeCrud(collectionName, modeBIsolated = false) {
468
550
  async replaceLinkReferences(oldId, newId, agentId) {
469
551
  const notes = await this.listNotes(agentId);
470
552
  for (const note of notes) {
553
+ if (!canWrite(note, agentId)) continue;
471
554
  if (note.links.includes(oldId)) {
472
555
  const newLinks = note.links.map((linkId) => linkId === oldId ? newId : linkId);
473
556
  const filteredLinks = newLinks.filter((linkId) => linkId !== note.id);
@@ -481,20 +564,20 @@ function makeCrud(collectionName, modeBIsolated = false) {
481
564
  function createStorageContext(collectionName, modeBIsolated = false) {
482
565
  return makeCrud(collectionName || getCollection(), modeBIsolated);
483
566
  }
484
- async function getNote(id) {
485
- return makeCrud(getCollection()).getNote(id);
567
+ async function getNote(id, readerAgentId) {
568
+ return makeCrud(getCollection()).getNote(id, readerAgentId);
486
569
  }
487
570
  async function updateNote(note) {
488
571
  return makeCrud(getCollection()).updateNote(note);
489
572
  }
490
- async function listNotes(agentId) {
491
- return makeCrud(getCollection()).listNotes(agentId);
573
+ async function listNotes(agentId, subject) {
574
+ return makeCrud(getCollection()).listNotes(agentId, subject);
492
575
  }
493
576
  async function deleteNote(id) {
494
577
  return makeCrud(getCollection()).deleteNote(id);
495
578
  }
496
- async function invalidateNote(id) {
497
- return makeCrud(getCollection()).invalidateNote(id);
579
+ async function invalidateNote(id, callerAgentId) {
580
+ return makeCrud(getCollection()).invalidateNote(id, callerAgentId);
498
581
  }
499
582
  async function patchNotePayload(id, fields) {
500
583
  return makeCrud(getCollection()).patchNotePayload(id, fields);
@@ -503,6 +586,7 @@ var QDRANT_URL, getCollection, VECTOR_DIM, _collectionReady, _collectionReadyMap
503
586
  var init_storage = __esm({
504
587
  "../amem-core/src/storage.ts"() {
505
588
  "use strict";
589
+ init_auth();
506
590
  QDRANT_URL = "http://localhost:6333";
507
591
  getCollection = () => process.env.AMEM_COLLECTION || "amem_notes";
508
592
  VECTOR_DIM = 384;
@@ -600,7 +684,39 @@ Classification rules:
600
684
  - NEW: Completely unrelated information, no substantive connection to the old memory
601
685
  Return: {"type": "NEW"}
602
686
 
603
- Return only JSON, no other text.`
687
+ Return only JSON, no other text.`,
688
+ conflictScan: (numberedNotes) => `You are auditing a person's memory store for CONTRADICTIONS.
689
+
690
+ Below are numbered memories. Find pairs that CANNOT both be true of the same person at the same time.
691
+
692
+ ${numberedNotes}
693
+
694
+ What counts as a contradiction:
695
+ - The same attribute holding two incompatible values ("lives in Paris" vs "moved to Berlin")
696
+ - A stated preference or constraint that a later memory violates ("is vegetarian" vs "loved the steak")
697
+ - A fact that a later memory supersedes ("uses MySQL" vs "migrated to PostgreSQL")
698
+
699
+ What does NOT count \u2014 be strict, these are the common false positives:
700
+ - Additive facts. Two things can both be true ("has a dog named Buddy" + "adopted a second dog, Scout" is NOT a contradiction)
701
+ - Change over time that both memories already acknowledge
702
+ - Merely similar or related topics
703
+ - Different contexts (likes coffee at work, tea at home)
704
+
705
+ For each contradicting pair, also say which one is SUPERSEDED \u2014 the one that is
706
+ no longer true. Judge this from the WORDING, not from any assumed order: phrases
707
+ like "used to", "back in 2019", "moved last month", "switched to" tell you which
708
+ statement describes the past. The memories are NOT listed in chronological order,
709
+ and the number does not imply age.
710
+
711
+ If you cannot tell which one is superseded, set it to null. That is a normal and
712
+ useful answer \u2014 say null rather than guessing, because a wrong guess retires a
713
+ memory that is still true.
714
+
715
+ Return ONLY a JSON array. Empty array if nothing genuinely contradicts:
716
+ [{"a": 0, "b": 3, "superseded": 0, "reason": "one short sentence naming the incompatible attribute"}]
717
+
718
+ "superseded" must be either the value of "a", the value of "b", or null.
719
+ Use the numbers shown. Report a pair once. Prefer returning nothing over guessing.`
604
720
  };
605
721
  zh = {
606
722
  crudDecision: (userText, assistantText, memoryList) => `\u4F60\u662F\u4E00\u4E2A\u8BB0\u5FC6\u7BA1\u7406 agent\uFF0C\u8D1F\u8D23\u5206\u6790\u5BF9\u8BDD\u5185\u5BB9\u5E76\u51B3\u5B9A\u5982\u4F55\u64CD\u4F5C\u8BB0\u5FC6\u5E93\u3002
@@ -685,7 +801,36 @@ B: "\u7528\u6237\u7684 VS Code \u4F7F\u7528 One Dark Pro \u4E3B\u9898"
685
801
  - NEW\uFF1A\u5168\u65B0\u4FE1\u606F\uFF0C\u4E0E\u65E7\u8BB0\u5FC6\u65E0\u5B9E\u8D28\u5173\u8054\uFF08\u5982\u300C\u559C\u6B22 dark mode\u300Dvs\u300C\u4E0B\u5468\u8981\u53BB\u51FA\u5DEE\u300D\uFF09
686
802
  \u8FD4\u56DE\uFF1A{"type": "NEW"}
687
803
 
688
- \u53EA\u8FD4\u56DE JSON\uFF0C\u4E0D\u8981\u4EFB\u4F55\u5176\u4ED6\u6587\u5B57\u3002`
804
+ \u53EA\u8FD4\u56DE JSON\uFF0C\u4E0D\u8981\u4EFB\u4F55\u5176\u4ED6\u6587\u5B57\u3002`,
805
+ conflictScan: (numberedNotes) => `\u4F60\u5728\u5BA1\u8BA1\u4E00\u4E2A\u4EBA\u7684\u8BB0\u5FC6\u5E93\uFF0C\u627E\u51FA\u5176\u4E2D**\u4E92\u76F8\u77DB\u76FE**\u7684\u6761\u76EE\u3002
806
+
807
+ \u4E0B\u9762\u662F\u7F16\u53F7\u7684\u8BB0\u5FC6\u3002\u627E\u51FA\u90A3\u4E9B**\u4E0D\u53EF\u80FD\u540C\u65F6\u4E3A\u771F**\u7684\u914D\u5BF9\u3002
808
+
809
+ ${numberedNotes}
810
+
811
+ \u7B97\u77DB\u76FE\u7684\u60C5\u51B5\uFF1A
812
+ - \u540C\u4E00\u5C5E\u6027\u4E0A\u51FA\u73B0\u4E92\u65A5\u7684\u503C\uFF08\u300C\u4F4F\u5728\u5DF4\u9ECE\u300Dvs\u300C\u642C\u5230\u4E86\u67CF\u6797\u300D\uFF09
813
+ - \u540E\u6765\u7684\u8BB0\u5FC6\u8FDD\u53CD\u4E86\u5148\u524D\u9648\u8FF0\u7684\u504F\u597D\u6216\u7EA6\u675F\uFF08\u300C\u5403\u7D20\u300Dvs\u300C\u90A3\u5757\u725B\u6392\u5F88\u597D\u5403\u300D\uFF09
814
+ - \u540E\u6765\u7684\u4E8B\u5B9E\u53D6\u4EE3\u4E86\u5148\u524D\u7684\uFF08\u300C\u7528 MySQL\u300Dvs\u300C\u5DF2\u8FC1\u79FB\u5230 PostgreSQL\u300D\uFF09
815
+
816
+ **\u4E0D\u7B97**\u77DB\u76FE \u2014\u2014 \u8BF7\u4E25\u683C\uFF0C\u4EE5\u4E0B\u662F\u6700\u5E38\u89C1\u7684\u8BEF\u5224\uFF1A
817
+ - \u7D2F\u52A0\u7684\u4E8B\u5B9E\u3002\u4E24\u8005\u53EF\u4EE5\u540C\u65F6\u6210\u7ACB\uFF08\u300C\u517B\u4E86\u4E00\u53EA\u72D7\u53EB Buddy\u300D+\u300C\u53C8\u9886\u517B\u4E86\u7B2C\u4E8C\u53EA\u53EB Scout\u300D**\u4E0D\u662F**\u77DB\u76FE\uFF09
818
+ - \u4E24\u6761\u8BB0\u5FC6\u672C\u8EAB\u5DF2\u7ECF\u4F53\u73B0\u4E86\u968F\u65F6\u95F4\u7684\u53D8\u5316
819
+ - \u53EA\u662F\u4E3B\u9898\u76F8\u4F3C\u6216\u76F8\u5173
820
+ - \u573A\u666F\u4E0D\u540C\uFF08\u5728\u516C\u53F8\u559D\u5496\u5561\uFF0C\u5728\u5BB6\u559D\u8336\uFF09
821
+
822
+ \u5BF9\u6BCF\u4E00\u5BF9\u77DB\u76FE\uFF0C\u8FD8\u8981\u6307\u51FA\u54EA\u4E00\u6761\u662F**\u5DF2\u5931\u6548\u7684**\uFF08\u4E0D\u518D\u4E3A\u771F\u7684\u90A3\u6761\uFF09\u3002\u8BF7\u4ECE**\u63AA\u8F9E**\u5224\u65AD\uFF0C\u4E0D\u8981\u5047\u8BBE\u987A\u5E8F\uFF1A
823
+ \u300C\u4EE5\u524D\u300D\u300C2019 \u5E74\u90A3\u4F1A\u513F\u300D\u300C\u4E0A\u4E2A\u6708\u642C\u4E86\u300D\u300C\u6539\u7528\u4E86\u300D\u8FD9\u7C7B\u8BF4\u6CD5\u80FD\u544A\u8BC9\u4F60\u54EA\u6761\u63CF\u8FF0\u7684\u662F\u8FC7\u53BB\u3002
824
+ \u8FD9\u4E9B\u8BB0\u5FC6**\u4E0D\u662F\u6309\u65F6\u95F4\u987A\u5E8F\u6392\u5217\u7684**\uFF0C\u7F16\u53F7\u4E5F\u4E0D\u4EE3\u8868\u65B0\u65E7\u3002
825
+
826
+ \u5982\u679C\u65E0\u6CD5\u5224\u65AD\u54EA\u6761\u5DF2\u5931\u6548\uFF0C\u5C31\u586B null\u3002\u8FD9\u662F\u4E00\u4E2A**\u6B63\u5E38\u4E14\u6709\u7528**\u7684\u56DE\u7B54 \u2014\u2014 \u5B81\u53EF\u586B null \u4E5F\u4E0D\u8981\u731C\uFF0C
827
+ \u56E0\u4E3A\u731C\u9519\u4F1A\u8BA9\u4E00\u6761**\u4ECD\u7136\u4E3A\u771F**\u7684\u8BB0\u5FC6\u88AB\u505C\u7528\u3002
828
+
829
+ \u53EA\u8FD4\u56DE JSON \u6570\u7EC4\u3002\u6CA1\u6709\u771F\u6B63\u77DB\u76FE\u5C31\u8FD4\u56DE\u7A7A\u6570\u7EC4\uFF1A
830
+ [{"a": 0, "b": 3, "superseded": 0, "reason": "\u4E00\u53E5\u8BDD\u8BF4\u660E\u662F\u54EA\u4E2A\u5C5E\u6027\u4E92\u65A5"}]
831
+
832
+ "superseded" \u53EA\u80FD\u662F "a" \u7684\u503C\u3001"b" \u7684\u503C\uFF0C\u6216 null\u3002
833
+ \u4F7F\u7528\u4E0A\u9762\u663E\u793A\u7684\u7F16\u53F7\u3002\u540C\u4E00\u5BF9\u53EA\u62A5\u4E00\u6B21\u3002**\u5B81\u53EF\u4E0D\u62A5\uFF0C\u4E5F\u4E0D\u8981\u731C\u3002**`
689
834
  };
690
835
  templates = { en, zh };
691
836
  t = templates[LOCALE];
@@ -693,35 +838,92 @@ B: "\u7528\u6237\u7684 VS Code \u4F7F\u7528 One Dark Pro \u4E3B\u9898"
693
838
  });
694
839
 
695
840
  // ../amem-core/src/llm.ts
696
- function anthropic() {
697
- return _anthropic ??= new import_sdk.default({
698
- ...process.env.AMEM_LLM_API_KEY && { apiKey: process.env.AMEM_LLM_API_KEY },
699
- ...process.env.AMEM_LLM_BASE_URL && { baseURL: process.env.AMEM_LLM_BASE_URL }
700
- });
841
+ function configureLlm(cfg) {
842
+ _override = { ...cfg };
843
+ _anthropicClients.clear();
844
+ _openaiClients.clear();
845
+ }
846
+ function warnOnce(key, message) {
847
+ if (_warned.has(key)) return;
848
+ _warned.add(key);
849
+ console.error(message);
850
+ }
851
+ function resolveProvider(role = "fast") {
852
+ const raw = role === "strong" ? process.env.AMEM_LLM_STRONG_PROVIDER || _override.strong?.provider || void 0 : void 0;
853
+ const p = (raw || process.env.AMEM_LLM_PROVIDER || _override.provider || "anthropic").trim().toLowerCase();
854
+ if (p !== "anthropic" && p !== "openai") {
855
+ warnOnce(`provider:${p}`, `[amem] unknown LLM provider "${p}"; falling back to anthropic`);
856
+ }
857
+ return p;
701
858
  }
702
- function openai() {
703
- return _openai ??= new import_openai.default({
704
- // AMEM_LLM_API_KEY first (engine convention), then the SDK's own
705
- // OPENAI_API_KEY (the standard) — passing an explicit key blocks the SDK's
706
- // env fallback, so read it here. Placeholder last, so keyless local servers
707
- // (Ollama, vLLM) still work.
708
- apiKey: process.env.AMEM_LLM_API_KEY || process.env.OPENAI_API_KEY || "sk-no-key-required",
709
- ...process.env.AMEM_LLM_BASE_URL && { baseURL: process.env.AMEM_LLM_BASE_URL }
710
- });
859
+ function resolveModel(role = "fast") {
860
+ const strong = role === "strong" ? process.env.AMEM_LLM_STRONG_MODEL || _override.strong?.model || void 0 : void 0;
861
+ return strong || process.env.AMEM_LLM_MODEL || _override.model || (resolveProvider(role) === "openai" ? "gpt-4o-mini" : "claude-sonnet-4-6");
711
862
  }
712
- async function llmCall(prompt, maxTokens = 500) {
713
- const isThinking = MODEL.includes("gemini") || MODEL.includes("pro-agent");
863
+ function resolveBaseURL(role = "fast") {
864
+ const strong = role === "strong" ? process.env.AMEM_LLM_STRONG_BASE_URL || _override.strong?.baseURL || void 0 : void 0;
865
+ return strong || process.env.AMEM_LLM_BASE_URL || _override.baseURL || void 0;
866
+ }
867
+ function resolveCrudRole() {
868
+ const raw = (process.env.AMEM_LLM_CRUD_ROLE || _override.crudRole || "fast").trim().toLowerCase();
869
+ if (raw === "strong") return "strong";
870
+ if (raw !== "fast") {
871
+ warnOnce(`crudRole:${raw}`, `[amem] unknown AMEM_LLM_CRUD_ROLE "${raw}"; using fast`);
872
+ }
873
+ return "fast";
874
+ }
875
+ function resolveTimeoutMs() {
876
+ const envVal = Number(process.env.AMEM_LLM_TIMEOUT);
877
+ if (Number.isFinite(envVal) && envVal > 0) return envVal;
878
+ if (_override.timeoutMs && _override.timeoutMs > 0) return _override.timeoutMs;
879
+ return DEFAULT_TIMEOUT_MS;
880
+ }
881
+ function anthropic(baseURL) {
882
+ const key = baseURL ?? "";
883
+ let client = _anthropicClients.get(key);
884
+ if (!client) {
885
+ client = new import_sdk.default({
886
+ ...process.env.AMEM_LLM_API_KEY && { apiKey: process.env.AMEM_LLM_API_KEY },
887
+ ...baseURL && { baseURL },
888
+ timeout: resolveTimeoutMs()
889
+ });
890
+ _anthropicClients.set(key, client);
891
+ }
892
+ return client;
893
+ }
894
+ function openai(baseURL) {
895
+ const key = baseURL ?? "";
896
+ let client = _openaiClients.get(key);
897
+ if (!client) {
898
+ client = new import_openai.default({
899
+ // AMEM_LLM_API_KEY first (engine convention), then the SDK's own
900
+ // OPENAI_API_KEY (the standard) — passing an explicit key blocks the SDK's
901
+ // env fallback, so read it here. Placeholder last, so keyless local servers
902
+ // (Ollama, vLLM) still work.
903
+ apiKey: process.env.AMEM_LLM_API_KEY || process.env.OPENAI_API_KEY || "sk-no-key-required",
904
+ ...baseURL && { baseURL },
905
+ timeout: resolveTimeoutMs()
906
+ });
907
+ _openaiClients.set(key, client);
908
+ }
909
+ return client;
910
+ }
911
+ async function llmCall(prompt, maxTokens = 500, role = "fast") {
912
+ const provider = resolveProvider(role);
913
+ const model = resolveModel(role);
914
+ const baseURL = resolveBaseURL(role);
915
+ const isThinking = model.includes("gemini") || model.includes("pro-agent");
714
916
  const effectiveMaxTokens = isThinking ? Math.max(maxTokens * 8, 4e3) : maxTokens;
715
917
  try {
716
- return PROVIDER === "openai" ? await openaiCall(prompt, effectiveMaxTokens) : await anthropicCall(prompt, effectiveMaxTokens);
918
+ return provider === "openai" ? await openaiCall(prompt, model, effectiveMaxTokens, baseURL) : await anthropicCall(prompt, model, effectiveMaxTokens, baseURL);
717
919
  } catch (e) {
718
920
  console.error(`[amem] LLM call failed: ${e.message}`);
719
921
  return null;
720
922
  }
721
923
  }
722
- async function anthropicCall(prompt, maxTokens) {
723
- const resp = await anthropic().messages.create({
724
- model: MODEL,
924
+ async function anthropicCall(prompt, model, maxTokens, baseURL) {
925
+ const resp = await anthropic(baseURL).messages.create({
926
+ model,
725
927
  max_tokens: maxTokens,
726
928
  messages: [{ role: "user", content: prompt }]
727
929
  });
@@ -730,17 +932,20 @@ async function anthropicCall(prompt, maxTokens) {
730
932
  }
731
933
  return null;
732
934
  }
733
- async function openaiCall(prompt, maxTokens) {
734
- const isReasoning = /^o\d/.test(MODEL) || MODEL.startsWith("gpt-5");
735
- const resp = await openai().chat.completions.create({
736
- model: MODEL,
935
+ async function openaiCall(prompt, model, maxTokens, baseURL) {
936
+ const isReasoning = /^o\d/.test(model) || model.startsWith("gpt-5");
937
+ const resp = await openai(baseURL).chat.completions.create({
938
+ model,
737
939
  ...isReasoning ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens },
738
940
  messages: [{ role: "user", content: prompt }]
739
941
  });
740
942
  return resp.choices[0]?.message?.content?.trim() ?? null;
741
943
  }
944
+ function stripReasoning(raw) {
945
+ return raw.replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/<\|(?:eot_id|im_start|im_end|begin_of_text|end_of_text|endoftext)\|>/g, "").trim();
946
+ }
742
947
  function stripFences(raw) {
743
- raw = raw.trim();
948
+ raw = stripReasoning(raw);
744
949
  if (raw.startsWith("```")) {
745
950
  const lines = raw.split("\n");
746
951
  lines.shift();
@@ -755,6 +960,16 @@ function stripFences(raw) {
755
960
  }
756
961
  return raw;
757
962
  }
963
+ function parseJsonLoose(raw) {
964
+ const cleaned = stripFences(raw);
965
+ try {
966
+ return JSON.parse(cleaned);
967
+ } catch (e) {
968
+ const m = cleaned.match(/\{[\s\S]*\}/);
969
+ if (m) return JSON.parse(m[0]);
970
+ throw e;
971
+ }
972
+ }
758
973
  async function llmConstructNote(content) {
759
974
  const prompt = `Analyze the following text and respond with valid JSON only (no markdown fences, no explanation, no comments). All string values must use standard double quotes and be properly escaped:
760
975
  {
@@ -802,7 +1017,7 @@ Text: ${content}`;
802
1017
  confidence: "medium"
803
1018
  };
804
1019
  try {
805
- const data = JSON.parse(stripFences(raw));
1020
+ const data = parseJsonLoose(raw);
806
1021
  const rawCategory = typeof data.category === "string" ? data.category : "General";
807
1022
  const category = VALID_CATEGORIES.has(rawCategory) ? rawCategory : "General";
808
1023
  const note_type = data.note_type === "knowledge" ? "knowledge" : "memory";
@@ -845,9 +1060,9 @@ async function llmCrudDecision(userText, assistantText, existingMemories) {
845
1060
  const memoryList = existingMemories.length > 0 ? existingMemories.map((m) => `[${m.idx}] ${m.content}`).join("\n") : "(none)";
846
1061
  const prompt = t.crudDecision(userText.slice(0, 500), assistantText.slice(0, 500), memoryList);
847
1062
  try {
848
- const raw = await llmCall(prompt, 400);
1063
+ const raw = await llmCall(prompt, 400, resolveCrudRole());
849
1064
  if (!raw) return [];
850
- const match = raw.match(/\[.*\]/s);
1065
+ const match = stripReasoning(raw).match(/\[.*\]/s);
851
1066
  if (!match) return [];
852
1067
  const parsed = JSON.parse(match[0]);
853
1068
  if (!Array.isArray(parsed)) return [];
@@ -875,10 +1090,10 @@ async function llmCrudDecision(userText, assistantText, existingMemories) {
875
1090
  }
876
1091
  async function llmShouldMerge(contentA, contentB) {
877
1092
  const prompt = t.shouldMerge(contentA, contentB);
878
- const raw = await llmCall(prompt, 300);
1093
+ const raw = await llmCall(prompt, 300, "strong");
879
1094
  if (!raw) return { shouldMerge: false };
880
1095
  try {
881
- const data = JSON.parse(stripFences(raw));
1096
+ const data = parseJsonLoose(raw);
882
1097
  if (typeof data.shouldMerge !== "boolean") return { shouldMerge: false };
883
1098
  if (data.shouldMerge && typeof data.merged === "string") {
884
1099
  return { shouldMerge: true, merged: data.merged };
@@ -891,10 +1106,10 @@ async function llmShouldMerge(contentA, contentB) {
891
1106
  }
892
1107
  async function llmEvolutionJudge(oldContent, newContent) {
893
1108
  const prompt = t.evolutionJudge(oldContent, newContent);
894
- const raw = await llmCall(prompt, 300);
1109
+ const raw = await llmCall(prompt, 300, "strong");
895
1110
  if (!raw) return { type: "NEW" };
896
1111
  try {
897
- const data = JSON.parse(stripFences(raw));
1112
+ const data = parseJsonLoose(raw);
898
1113
  const type = VALID_EVOLUTION_TYPES.has(data.type) ? data.type : "NEW";
899
1114
  return {
900
1115
  type,
@@ -931,7 +1146,7 @@ ${linkedStr}`;
931
1146
  const raw = await llmCall(prompt, 500);
932
1147
  if (!raw) return { tags: null, context: null, shouldStrengthen: false, suggestedConnections: [], tagsToUpdate: [] };
933
1148
  try {
934
- const data = JSON.parse(stripFences(raw));
1149
+ const data = parseJsonLoose(raw);
935
1150
  return {
936
1151
  tags: Array.isArray(data.tags) ? data.tags : null,
937
1152
  context: typeof data.context === "string" ? data.context : null,
@@ -944,20 +1159,56 @@ ${linkedStr}`;
944
1159
  return { tags: null, context: null, shouldStrengthen: false, suggestedConnections: [], tagsToUpdate: [] };
945
1160
  }
946
1161
  }
947
- var import_sdk, import_openai, PROVIDER, MODEL, _anthropic, _openai, VALID_CONFIDENCE, VALID_CATEGORIES, VALID_EVOLUTION_TYPES;
1162
+ async function llmConflictScan(contents) {
1163
+ if (contents.length < 2) return [];
1164
+ const numbered = contents.map((c, i) => `[${i}] ${c}`).join("\n");
1165
+ try {
1166
+ const raw = await llmCall(t.conflictScan(numbered), 600, "strong");
1167
+ if (!raw) return [];
1168
+ const cleaned = stripReasoning(raw);
1169
+ const match = cleaned.match(/\[[\s\S]*\]/);
1170
+ if (!match) return [];
1171
+ const parsed = JSON.parse(match[0]);
1172
+ if (!Array.isArray(parsed)) return [];
1173
+ const pairs = [];
1174
+ const seen = /* @__PURE__ */ new Set();
1175
+ for (const item of parsed) {
1176
+ if (!item || typeof item !== "object") continue;
1177
+ const { a, b } = item;
1178
+ if (typeof a !== "number" || typeof b !== "number") continue;
1179
+ if (!Number.isInteger(a) || !Number.isInteger(b)) continue;
1180
+ if (a < 0 || b < 0 || a >= contents.length || b >= contents.length) continue;
1181
+ if (a === b) continue;
1182
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`;
1183
+ if (seen.has(key)) continue;
1184
+ seen.add(key);
1185
+ const rawSup = item.superseded;
1186
+ const supersededIndex = rawSup === a || rawSup === b ? rawSup : null;
1187
+ pairs.push({
1188
+ a,
1189
+ b,
1190
+ reason: typeof item.reason === "string" ? item.reason : "",
1191
+ supersededIndex
1192
+ });
1193
+ }
1194
+ return pairs;
1195
+ } catch (e) {
1196
+ console.error(`[amem] llmConflictScan failed: ${e.message}`);
1197
+ return [];
1198
+ }
1199
+ }
1200
+ var import_sdk, import_openai, _override, _warned, DEFAULT_TIMEOUT_MS, _anthropicClients, _openaiClients, VALID_CONFIDENCE, VALID_CATEGORIES, VALID_EVOLUTION_TYPES;
948
1201
  var init_llm = __esm({
949
1202
  "../amem-core/src/llm.ts"() {
950
1203
  "use strict";
951
1204
  import_sdk = __toESM(require("@anthropic-ai/sdk"), 1);
952
1205
  import_openai = __toESM(require("openai"), 1);
953
1206
  init_prompts();
954
- PROVIDER = (process.env.AMEM_LLM_PROVIDER ?? "anthropic").trim().toLowerCase();
955
- if (PROVIDER !== "anthropic" && PROVIDER !== "openai") {
956
- console.error(`[amem] unknown AMEM_LLM_PROVIDER "${PROVIDER}"; falling back to anthropic`);
957
- }
958
- MODEL = process.env.AMEM_LLM_MODEL ?? (PROVIDER === "openai" ? "gpt-4o-mini" : "claude-sonnet-4-6");
959
- _anthropic = null;
960
- _openai = null;
1207
+ _override = {};
1208
+ _warned = /* @__PURE__ */ new Set();
1209
+ DEFAULT_TIMEOUT_MS = 3e4;
1210
+ _anthropicClients = /* @__PURE__ */ new Map();
1211
+ _openaiClients = /* @__PURE__ */ new Map();
961
1212
  VALID_CONFIDENCE = /* @__PURE__ */ new Set(["high", "medium", "low"]);
962
1213
  VALID_CATEGORIES = /* @__PURE__ */ new Set([
963
1214
  "Technical",
@@ -1077,6 +1328,7 @@ function defaultCtx() {
1077
1328
  }
1078
1329
  async function addMemory(content, agentId = "main", opts) {
1079
1330
  const scope = opts?.scope ?? "private";
1331
+ const subjects = opts?.subjects ?? [];
1080
1332
  const ctx = opts?.storageCtx ?? defaultCtx();
1081
1333
  const quality = checkQuality(content);
1082
1334
  if (!quality.ok) {
@@ -1101,9 +1353,14 @@ async function addMemory(content, agentId = "main", opts) {
1101
1353
  const embedding = await encode(fieldsText);
1102
1354
  const topMatch = await ctx.queryByEmbedding(embedding, 1, agentId, 0);
1103
1355
  if (topMatch.length > 0 && topMatch[0].score >= 0.85) {
1104
- console.log(`[add] dedup: high-sim match (sim=${topMatch[0].score.toFixed(3)}), updating existing`);
1105
- await ctx.updateNoteContent(topMatch[0].note.id, content, embedding, hash);
1106
- return topMatch[0].note.id;
1356
+ if (canWrite(topMatch[0].note, agentId)) {
1357
+ console.log(`[add] dedup: high-sim match (sim=${topMatch[0].score.toFixed(3)}), updating existing`);
1358
+ await ctx.updateNoteContent(topMatch[0].note.id, content, embedding, hash);
1359
+ return topMatch[0].note.id;
1360
+ }
1361
+ console.log(
1362
+ `[add] dedup: high-sim match ${topMatch[0].note.id.slice(0, 8)} is not writable by ${logSafe(agentId)} \u2014 inserting a new note instead`
1363
+ );
1107
1364
  }
1108
1365
  const pendingMerge = topMatch.length > 0 && topMatch[0].score >= 0.72 && topMatch[0].score < 0.85;
1109
1366
  if (pendingMerge) {
@@ -1113,6 +1370,7 @@ async function addMemory(content, agentId = "main", opts) {
1113
1370
  const writers = [agentId];
1114
1371
  const note = {
1115
1372
  id: (0, import_uuid.v4)(),
1373
+ subjects,
1116
1374
  content,
1117
1375
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1118
1376
  keywords,
@@ -1171,6 +1429,10 @@ async function addMemory(content, agentId = "main", opts) {
1171
1429
  for (const lid of linkedIds) {
1172
1430
  const linked = await ctx.getNote(lid);
1173
1431
  if (linked && !linked.links.includes(note.id)) {
1432
+ if (!canWrite(linked, agentId)) {
1433
+ console.log(`[link] back-link into ${lid.slice(0, 8)} skipped \u2014 not writable by ${logSafe(agentId)}`);
1434
+ continue;
1435
+ }
1174
1436
  linked.links.push(note.id);
1175
1437
  await ctx.updateNote(linked);
1176
1438
  }
@@ -1180,10 +1442,14 @@ async function addMemory(content, agentId = "main", opts) {
1180
1442
  for (const lid of linkedIds.slice(0, 3)) {
1181
1443
  const linked = await ctx.getNote(lid);
1182
1444
  if (!linked) continue;
1445
+ if (!canWrite(linked, agentId)) {
1446
+ console.log(` [evo] skipping ${lid.slice(0, 8)} \u2014 not writable by ${logSafe(agentId)}`);
1447
+ continue;
1448
+ }
1183
1449
  const linkedNotes = [];
1184
1450
  for (const llid of linked.links.slice(0, 5)) {
1185
1451
  if (llid === note.id) continue;
1186
- const ln = await ctx.getNote(llid);
1452
+ const ln = await ctx.getNote(llid, agentId);
1187
1453
  if (ln) linkedNotes.push({ id: ln.id, content: ln.content });
1188
1454
  }
1189
1455
  linkedNotes.push({ id: note.id, content });
@@ -1220,10 +1486,14 @@ async function addMemory(content, agentId = "main", opts) {
1220
1486
  note.links.push(targetId);
1221
1487
  noteChanged = true;
1222
1488
  }
1223
- const target = await ctx.getNote(targetId);
1489
+ const target = await ctx.getNote(targetId, agentId);
1224
1490
  if (target && !target.links.includes(note.id)) {
1225
- target.links.push(note.id);
1226
- await ctx.updateNote(target);
1491
+ if (canWrite(target, agentId)) {
1492
+ target.links.push(note.id);
1493
+ await ctx.updateNote(target);
1494
+ } else {
1495
+ console.log(` [evo] strengthen back-link into ${targetId.slice(0, 8)} skipped \u2014 not writable`);
1496
+ }
1227
1497
  }
1228
1498
  }
1229
1499
  if (tagsToUpdate.length > 0) {
@@ -1272,6 +1542,7 @@ async function addMemory(content, agentId = "main", opts) {
1272
1542
  }
1273
1543
  async function addEpisodic(content, agentId = "main", opts) {
1274
1544
  const scope = opts?.scope ?? "private";
1545
+ const subjects = opts?.subjects ?? [];
1275
1546
  const ctx = opts?.storageCtx ?? defaultCtx();
1276
1547
  const quality = checkQuality(content);
1277
1548
  if (!quality.ok) {
@@ -1281,6 +1552,7 @@ async function addEpisodic(content, agentId = "main", opts) {
1281
1552
  const now = (/* @__PURE__ */ new Date()).toISOString();
1282
1553
  const note = {
1283
1554
  id: (0, import_uuid.v4)(),
1555
+ subjects,
1284
1556
  content,
1285
1557
  timestamp: now,
1286
1558
  keywords: [],
@@ -1310,14 +1582,15 @@ async function addEpisodic(content, agentId = "main", opts) {
1310
1582
  }
1311
1583
  async function searchMemory(query, topK = 5, agentId = "main", opts) {
1312
1584
  const useBfs = opts?.useBfs !== false;
1585
+ const subject = opts?.subject;
1313
1586
  const bfsSimThreshold = opts?.bfsSimThreshold ?? 0.25;
1314
1587
  const ctx = opts?.storageCtx ?? defaultCtx();
1315
1588
  const total = await ctx.countNotes(agentId);
1316
1589
  if (total === 0) return [];
1317
1590
  const queryEmbedding = await encode(query);
1318
1591
  const n = Math.min(Math.max(topK * 4, 20), total);
1319
- const embResults = await ctx.queryByEmbedding(queryEmbedding, n, agentId, 0);
1320
- const allNotes = await ctx.listNotes(agentId);
1592
+ const embResults = await ctx.queryByEmbedding(queryEmbedding, n, agentId, 0, subject);
1593
+ const allNotes = await ctx.listNotes(agentId, subject);
1321
1594
  const bm25State = buildBM25(allNotes);
1322
1595
  const queryTokens = simpleTokenize(query);
1323
1596
  const bm25Ranked = bm25Score(bm25State, queryTokens).slice(0, n);
@@ -1628,7 +1901,66 @@ async function consolidateMemories(agentId, logger, storageCtx) {
1628
1901
  log.info(`[Consolidation] Completed consolidation run. Merged ${mergedCount} pairs.`);
1629
1902
  return mergedCount;
1630
1903
  }
1631
- var import_uuid, import_crypto, fs2, path3, import_jieba, _jieba, EPHEMERAL_SIGNALS;
1904
+ function resolveConflictMode(override) {
1905
+ const raw = (process.env.AMEM_CONFLICT_MODE || override || "review").trim().toLowerCase();
1906
+ return raw === "auto" ? "auto" : "review";
1907
+ }
1908
+ async function conflictSweep(agentId, opts) {
1909
+ const ctx = opts?.storageCtx ?? defaultCtx();
1910
+ const mode = resolveConflictMode(opts?.mode);
1911
+ const log = opts?.logger?.info ?? ((m) => console.log(m));
1912
+ const raw = await ctx.listNotes(agentId);
1913
+ const notes = raw.filter((n) => n.agent_id !== "shared" && n.note_type !== "knowledge" && n.is_active !== false);
1914
+ const groups = /* @__PURE__ */ new Map();
1915
+ for (const n of notes) {
1916
+ const c = n.category || "General";
1917
+ if (!groups.has(c)) groups.set(c, []);
1918
+ groups.get(c).push(n);
1919
+ }
1920
+ let pairsFound = 0;
1921
+ let retired = 0;
1922
+ for (const [category, groupNotes] of groups.entries()) {
1923
+ for (let start = 0; start < groupNotes.length; start += CONFLICT_BATCH_SIZE) {
1924
+ const batch = groupNotes.slice(start, start + CONFLICT_BATCH_SIZE);
1925
+ if (batch.length < 2) continue;
1926
+ const pairs = await llmConflictScan(batch.map((n) => n.content));
1927
+ for (const { a, b, reason, supersededIndex } of pairs) {
1928
+ const noteA = batch[a];
1929
+ const noteB = batch[b];
1930
+ if (!noteA || !noteB) continue;
1931
+ pairsFound++;
1932
+ await ctx.patchNotePayload(noteA.id, {
1933
+ conflict: true,
1934
+ evolution_type: "CONFLICT",
1935
+ conflicts_with: Array.from(/* @__PURE__ */ new Set([...noteA.conflicts_with ?? [], noteB.id])),
1936
+ conflict_reason: reason
1937
+ });
1938
+ await ctx.patchNotePayload(noteB.id, {
1939
+ conflict: true,
1940
+ evolution_type: "CONFLICT",
1941
+ conflicts_with: Array.from(/* @__PURE__ */ new Set([...noteB.conflicts_with ?? [], noteA.id])),
1942
+ conflict_reason: reason
1943
+ });
1944
+ log(`[conflict] ${category}: ${noteA.id.slice(0, 8)} \u2194 ${noteB.id.slice(0, 8)} \u2014 ${reason}`);
1945
+ if (mode === "auto") {
1946
+ const superseded = supersededIndex === a ? noteA : supersededIndex === b ? noteB : null;
1947
+ if (!superseded) {
1948
+ log(`[conflict] auto: no superseded side identified \u2014 marked only, nothing retired`);
1949
+ } else {
1950
+ const ok = await ctx.invalidateNote(superseded.id, agentId);
1951
+ if (ok) {
1952
+ retired++;
1953
+ log(`[conflict] auto-retired the superseded note ${superseded.id.slice(0, 8)}`);
1954
+ }
1955
+ }
1956
+ }
1957
+ }
1958
+ }
1959
+ }
1960
+ log(`[conflict] scanned ${notes.length} notes, found ${pairsFound} pair(s), retired ${retired}`);
1961
+ return { scanned: notes.length, pairsFound, retired };
1962
+ }
1963
+ var import_uuid, import_crypto, fs2, path3, import_jieba, logSafe, _jieba, EPHEMERAL_SIGNALS, CONFLICT_BATCH_SIZE;
1632
1964
  var init_memory = __esm({
1633
1965
  "../amem-core/src/memory.ts"() {
1634
1966
  "use strict";
@@ -1638,12 +1970,15 @@ var init_memory = __esm({
1638
1970
  path3 = __toESM(require("path"), 1);
1639
1971
  init_embedding();
1640
1972
  init_storage();
1973
+ init_auth();
1641
1974
  init_llm();
1642
1975
  init_evo_counter();
1643
1976
  init_config();
1644
1977
  import_jieba = require("@node-rs/jieba");
1978
+ logSafe = (id) => id.replace(/[\r\n]/g, "");
1645
1979
  _jieba = null;
1646
1980
  EPHEMERAL_SIGNALS = ["\u5F85\u8DD1", "\u7B49\u786E\u8BA4", "\u6628\u65E5", "\u660E\u5929\u5B8C\u6210"];
1981
+ CONFLICT_BATCH_SIZE = 25;
1647
1982
  }
1648
1983
  });
1649
1984
 
@@ -1654,6 +1989,7 @@ async function scanLowQuality(agentId) {
1654
1989
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1e3;
1655
1990
  const results = [];
1656
1991
  for (const note of notes) {
1992
+ if (!canWrite(note, agentId)) continue;
1657
1993
  const reasons = [];
1658
1994
  if (note.content.trim().length < 10) {
1659
1995
  reasons.push("too_short");
@@ -1746,6 +2082,48 @@ async function generateReviewBatch(agentId, outputPath) {
1746
2082
  if (items.length === 0) {
1747
2083
  lines.push(LOCALE2 === "zh" ? "\u2705 \u6CA1\u6709\u53D1\u73B0\u4F4E\u8D28\u91CF\u6761\u76EE\u3002" : "\u2705 No low-quality items found.");
1748
2084
  }
2085
+ const byId = new Map(items.map((it) => [it.note.id, it.note]));
2086
+ const renderedPairs = /* @__PURE__ */ new Set();
2087
+ const pairLines = [];
2088
+ for (const { note } of items) {
2089
+ for (const otherId of note.conflicts_with ?? []) {
2090
+ const other = byId.get(otherId);
2091
+ if (!other) continue;
2092
+ const key = note.id < otherId ? `${note.id}:${otherId}` : `${otherId}:${note.id}`;
2093
+ if (renderedPairs.has(key)) continue;
2094
+ renderedPairs.add(key);
2095
+ const [newer, older] = Date.parse(note.timestamp) >= Date.parse(other.timestamp) ? [note, other] : [other, note];
2096
+ const zh2 = LOCALE2 === "zh";
2097
+ pairLines.push(`### \u{1F7E0} ${zh2 ? "\u51B2\u7A81" : "CONFLICT"} | ${newer.category || "General"}`);
2098
+ if (newer.conflict_reason) {
2099
+ pairLines.push(`**${zh2 ? "\u5224\u5B9A\u7406\u7531" : "Why"}\uFF1A** ${newer.conflict_reason}`);
2100
+ pairLines.push("");
2101
+ }
2102
+ pairLines.push(`| | ${zh2 ? "\u65F6\u95F4" : "When"} | ${zh2 ? "\u5185\u5BB9" : "Content"} |`);
2103
+ pairLines.push("| :-- | :-- | :-- |");
2104
+ pairLines.push(`| **A** | ${newer.timestamp.slice(0, 10)} | ${newer.content.replace(/\n/g, " ")} |`);
2105
+ pairLines.push(`| **B** | ${older.timestamp.slice(0, 10)} | ${older.content.replace(/\n/g, " ")} |`);
2106
+ pairLines.push("");
2107
+ pairLines.push(`\`A: ${newer.id}\``);
2108
+ pairLines.push(`\`B: ${older.id}\``);
2109
+ pairLines.push("");
2110
+ pairLines.push(
2111
+ zh2 ? `- [ ] \u2705 **A \u662F\u5F53\u524D\u72B6\u6001\uFF0C\u505C\u7528 B**\uFF08\u63A8\u8350\uFF1AA \u66F4\u65B0\uFF09` : `- [ ] \u2705 **A is current \u2014 retire B** (recommended: A is newer)`
2112
+ );
2113
+ pairLines.push(zh2 ? `- [ ] \u21A9\uFE0F B \u662F\u5F53\u524D\u72B6\u6001\uFF0C\u505C\u7528 A` : `- [ ] \u21A9\uFE0F B is current \u2014 retire A`);
2114
+ pairLines.push(zh2 ? `- [ ] \u{1F91D} \u4E24\u8005\u90FD\u6210\u7ACB\uFF08\u8BEF\u5224\uFF09` : `- [ ] \u{1F91D} Both hold \u2014 not a contradiction`);
2115
+ pairLines.push("");
2116
+ pairLines.push("---");
2117
+ pairLines.push("");
2118
+ }
2119
+ }
2120
+ if (pairLines.length > 0) {
2121
+ lines.push(LOCALE2 === "zh" ? "## \u51B2\u7A81\uFF08\u6210\u5BF9\uFF0C\u4E00\u4E2A\u51B2\u7A81\u4E00\u4E2A\u51B3\u5B9A\uFF09" : "## Conflicts (paired \u2014 one decision each)");
2122
+ lines.push("");
2123
+ lines.push(...pairLines);
2124
+ lines.push(LOCALE2 === "zh" ? "## \u5176\u4F59\u6761\u76EE" : "## Other items");
2125
+ lines.push("");
2126
+ }
1749
2127
  for (let i = 0; i < items.length; i++) {
1750
2128
  const { note, reasons } = items[i];
1751
2129
  const badge = severityBadge(reasons);
@@ -1788,18 +2166,45 @@ var init_quality = __esm({
1788
2166
  fs3 = __toESM(require("fs"), 1);
1789
2167
  path4 = __toESM(require("path"), 1);
1790
2168
  init_storage();
2169
+ init_auth();
1791
2170
  LOCALE2 = process.env.AMEM_PROMPT_LOCALE === "zh" ? "zh" : "en";
1792
2171
  DEFAULT_OUTPUT_DIR = process.env.AMEM_REVIEW_DIR || process.cwd();
1793
2172
  }
1794
2173
  });
1795
2174
 
2175
+ // ../amem-core/src/crud-guard.ts
2176
+ function resolveCrudUpdateMinSim(override) {
2177
+ const envVal = Number(process.env.AMEM_CRUD_UPDATE_MIN_SIM);
2178
+ if (Number.isFinite(envVal) && envVal >= 0) return envVal;
2179
+ if (override !== void 0 && Number.isFinite(override) && override >= 0) return override;
2180
+ return DEFAULT_CRUD_UPDATE_MIN_SIM;
2181
+ }
2182
+ function isPlausibleUpdateTarget(newEmbedding, targetEmbedding, minSimilarity) {
2183
+ if (!newEmbedding?.length || !targetEmbedding?.length) return false;
2184
+ if (newEmbedding.length !== targetEmbedding.length) return false;
2185
+ return cosineSimilarity(newEmbedding, targetEmbedding) >= resolveCrudUpdateMinSim(minSimilarity);
2186
+ }
2187
+ var DEFAULT_CRUD_UPDATE_MIN_SIM;
2188
+ var init_crud_guard = __esm({
2189
+ "../amem-core/src/crud-guard.ts"() {
2190
+ "use strict";
2191
+ init_embedding();
2192
+ DEFAULT_CRUD_UPDATE_MIN_SIM = 0.35;
2193
+ }
2194
+ });
2195
+
1796
2196
  // ../amem-core/src/index.ts
1797
2197
  var src_exports = {};
1798
2198
  __export(src_exports, {
2199
+ DEFAULT_CRUD_UPDATE_MIN_SIM: () => DEFAULT_CRUD_UPDATE_MIN_SIM,
1799
2200
  addEpisodic: () => addEpisodic,
1800
2201
  addMemory: () => addMemory,
2202
+ canRead: () => canRead,
2203
+ canWrite: () => canWrite,
1801
2204
  checkQuality: () => checkQuality,
1802
2205
  configure: () => configure,
2206
+ configureLlm: () => configureLlm,
2207
+ conflictSweep: () => conflictSweep,
1803
2208
  consolidateMemories: () => consolidateMemories,
1804
2209
  createStorageContext: () => createStorageContext,
1805
2210
  deleteNote: () => deleteNote,
@@ -1809,6 +2214,7 @@ __export(src_exports, {
1809
2214
  getNote: () => getNote,
1810
2215
  invalidateNote: () => invalidateNote,
1811
2216
  isModelLoaded: () => isModelLoaded,
2217
+ isPlausibleUpdateTarget: () => isPlausibleUpdateTarget,
1812
2218
  listMemories: () => listMemories,
1813
2219
  listNotes: () => listNotes,
1814
2220
  llmCrudDecision: () => llmCrudDecision,
@@ -1816,6 +2222,7 @@ __export(src_exports, {
1816
2222
  mergeSimilarNotes: () => mergeSimilarNotes,
1817
2223
  patchNotePayload: () => patchNotePayload,
1818
2224
  pingQdrant: () => pingQdrant,
2225
+ resolveCrudUpdateMinSim: () => resolveCrudUpdateMinSim,
1819
2226
  scanLowQuality: () => scanLowQuality,
1820
2227
  searchMemory: () => searchMemory,
1821
2228
  updateNote: () => updateNote
@@ -1828,6 +2235,9 @@ var init_src = __esm({
1828
2235
  init_memory();
1829
2236
  init_quality();
1830
2237
  init_storage();
2238
+ init_auth();
2239
+ init_memory();
2240
+ init_crud_guard();
1831
2241
  init_llm();
1832
2242
  }
1833
2243
  });
@@ -1901,6 +2311,25 @@ function register(api) {
1901
2311
  const convBlocked = isConvAccessBlocked(api.config, pluginId);
1902
2312
  if (convBlocked) logger.warn(BLOCKED_WARNING_LOG);
1903
2313
  configure({ dataDir: path5.join(os2.homedir(), ".openclaw") });
2314
+ const hasStrong = !!(pluginConfig.llmStrongProvider || pluginConfig.llmStrongModel || pluginConfig.llmStrongBaseURL);
2315
+ if (pluginConfig.llmProvider || pluginConfig.llmModel || pluginConfig.llmBaseURL || pluginConfig.llmCrudRole || hasStrong) {
2316
+ configureLlm({
2317
+ provider: pluginConfig.llmProvider,
2318
+ model: pluginConfig.llmModel,
2319
+ baseURL: pluginConfig.llmBaseURL,
2320
+ crudRole: pluginConfig.llmCrudRole,
2321
+ // Omit the whole block when unset so `strong` transparently falls back to
2322
+ // `fast` — the zero-config path stays byte-for-byte today's behaviour.
2323
+ ...hasStrong && {
2324
+ strong: {
2325
+ provider: pluginConfig.llmStrongProvider,
2326
+ model: pluginConfig.llmStrongModel,
2327
+ baseURL: pluginConfig.llmStrongBaseURL
2328
+ }
2329
+ }
2330
+ });
2331
+ }
2332
+ const crudUpdateMinSim = pluginConfig.crudUpdateMinSim;
1904
2333
  const resolveAgentId2 = (ctx) => resolveAgentId(ctx, pluginConfig);
1905
2334
  const buildScope2 = (rawAgentId) => buildScope(rawAgentId, pluginConfig, createStorageContext);
1906
2335
  const defaultScope = buildScope2(resolveAgentId2());
@@ -2001,17 +2430,22 @@ function register(api) {
2001
2430
  type: "array",
2002
2431
  items: { type: "string" },
2003
2432
  description: "Story 26B: filter knowledge notes by topics (all must match)"
2433
+ },
2434
+ subject: {
2435
+ type: "string",
2436
+ description: "Who you are talking to or about (e.g. a player name). Returns memories about them plus memories about nobody in particular. Omit to search everything."
2004
2437
  }
2005
2438
  },
2006
2439
  required: ["query"]
2007
2440
  },
2008
2441
  async execute(_toolCallId, params) {
2009
- const { query, limit = 5, topicsFilter } = params;
2442
+ const { query, limit = 5, topicsFilter, subject } = params;
2010
2443
  const start = Date.now();
2011
2444
  const hookWarning = convBlocked ? BLOCKED_WARNING_SUFFIX : "";
2012
2445
  try {
2013
2446
  const results = await searchMemory(query, limit, scope.agentId, {
2014
2447
  topicsFilter,
2448
+ subject,
2015
2449
  storageCtx: scope.storageCtx
2016
2450
  });
2017
2451
  logger.info(
@@ -2052,15 +2486,20 @@ ${text}${hookWarning}` }],
2052
2486
  parameters: {
2053
2487
  type: "object",
2054
2488
  properties: {
2055
- text: { type: "string", description: "Fact or information to remember" }
2489
+ text: { type: "string", description: "Fact or information to remember" },
2490
+ subjects: {
2491
+ type: "array",
2492
+ items: { type: "string" },
2493
+ description: "Who this memory is about (e.g. player names). Use several for a shared experience \u2014 it will surface for each of them. Leave empty for a fact about the world or about yourself."
2494
+ }
2056
2495
  },
2057
2496
  required: ["text"]
2058
2497
  },
2059
2498
  async execute(_toolCallId, params) {
2060
- const { text } = params;
2499
+ const { text, subjects } = params;
2061
2500
  const start = Date.now();
2062
2501
  try {
2063
- const id = await addMemory(text, scope.agentId, { storageCtx: scope.storageCtx });
2502
+ const id = await addMemory(text, scope.agentId, { subjects, storageCtx: scope.storageCtx });
2064
2503
  logger.info(`openclaw-amem: memory_add OK id=${id} (${Date.now() - start}ms)`);
2065
2504
  return {
2066
2505
  content: [{ type: "text", text: "Memory saved successfully." }],
@@ -2238,7 +2677,21 @@ ${text}${hookWarning}` }],
2238
2677
  if (target) {
2239
2678
  const newEmbedding = await encode(op.fact);
2240
2679
  const hash = (0, import_crypto2.createHash)("md5").update(op.fact).digest("hex");
2241
- await storageCtx.updateNoteContent(target.id, op.fact, newEmbedding, hash);
2680
+ const targetNote = await storageCtx.getNote(target.id, agentId);
2681
+ if (!targetNote || !isPlausibleUpdateTarget(newEmbedding, targetNote.embedding, crudUpdateMinSim)) {
2682
+ await addMemory(op.fact, agentId, { storageCtx });
2683
+ logger.warn(
2684
+ `openclaw-amem: CRUD UPDATE on ${target.id.slice(0, 8)} looks mis-targeted \u2014 stored as a new memory instead`
2685
+ );
2686
+ continue;
2687
+ }
2688
+ const ok = await storageCtx.updateNoteContent(target.id, op.fact, newEmbedding, hash, agentId);
2689
+ if (!ok) {
2690
+ logger.warn(
2691
+ `openclaw-amem: CRUD UPDATE denied id=${target.id.slice(0, 8)} \u2014 ${agentId} not in writers`
2692
+ );
2693
+ continue;
2694
+ }
2242
2695
  logger.info(
2243
2696
  `openclaw-amem: CRUD UPDATE id=${target.id.slice(0, 8)}: "${op.fact.slice(0, 60)}${op.fact.length > 60 ? "..." : ""}"`
2244
2697
  );
@@ -2246,7 +2699,13 @@ ${text}${hookWarning}` }],
2246
2699
  } else if (op.action === "DELETE" && op.existingIdx !== void 0) {
2247
2700
  const target = existingMemories[op.existingIdx];
2248
2701
  if (target) {
2249
- await storageCtx.invalidateNote(target.id);
2702
+ const ok = await storageCtx.invalidateNote(target.id, agentId);
2703
+ if (!ok) {
2704
+ logger.warn(
2705
+ `openclaw-amem: CRUD DELETE denied id=${target.id.slice(0, 8)} \u2014 ${agentId} not in writers`
2706
+ );
2707
+ continue;
2708
+ }
2250
2709
  logger.info(
2251
2710
  `openclaw-amem: CRUD INVALIDATE id=${target.id.slice(0, 8)}: "${op.fact.slice(0, 60)}${op.fact.length > 60 ? "..." : ""}"`
2252
2711
  );
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-amem",
3
3
  "name": "Memory (A-MEM v2)",
4
4
  "description": "OpenClaw memory plugin implementing A-MEM — memories evolve, not just accumulate. Graph linking, hybrid retrieval, LLM-driven evolution. No Python.",
5
- "version": "1.2.2",
5
+ "version": "1.3.0",
6
6
  "kind": "memory",
7
7
  "openclaw": {
8
8
  "compat": {
@@ -31,6 +31,13 @@
31
31
  "OPENAI_API_KEY",
32
32
  "AMEM_LLM_BASE_URL",
33
33
  "AMEM_LLM_MODEL",
34
+ "AMEM_LLM_STRONG_PROVIDER",
35
+ "AMEM_LLM_STRONG_MODEL",
36
+ "AMEM_LLM_STRONG_BASE_URL",
37
+ "AMEM_LLM_CRUD_ROLE",
38
+ "AMEM_CONFLICT_MODE",
39
+ "AMEM_LLM_TIMEOUT",
40
+ "AMEM_CRUD_UPDATE_MIN_SIM",
34
41
  "AMEM_COLLECTION",
35
42
  "AMEM_DATA_DIR",
36
43
  "AMEM_EVO_COUNTER_PATH",
@@ -90,6 +97,53 @@
90
97
  "type": "string",
91
98
  "description": "Qdrant collection name (mode A shared default)."
92
99
  },
100
+ "llmProvider": {
101
+ "type": "string",
102
+ "enum": [
103
+ "anthropic",
104
+ "openai"
105
+ ],
106
+ "default": "anthropic",
107
+ "description": "LLM API dialect for the engine's own calls. Overridden by AMEM_LLM_PROVIDER."
108
+ },
109
+ "llmModel": {
110
+ "type": "string",
111
+ "description": "Model the engine uses for note construction, linking and evolution. Overridden by AMEM_LLM_MODEL. Defaults to claude-sonnet-4-6 (anthropic) or gpt-4o-mini (openai)."
112
+ },
113
+ "llmBaseURL": {
114
+ "type": "string",
115
+ "description": "Base URL for the LLM endpoint, e.g. an OpenAI-compatible gateway. Overridden by AMEM_LLM_BASE_URL. API keys are read from the environment only and cannot be set here."
116
+ },
117
+ "llmStrongProvider": {
118
+ "type": "string",
119
+ "enum": [
120
+ "anthropic",
121
+ "openai"
122
+ ],
123
+ "description": "Optional stronger tier: request format. Falls back to llmProvider. Overridden by AMEM_LLM_STRONG_PROVIDER."
124
+ },
125
+ "llmStrongModel": {
126
+ "type": "string",
127
+ "description": "Optional stronger tier: model for the few genuinely hard judgements (merge adjudication, contradiction classification). Falls back to llmModel, so leaving it unset keeps single-model behaviour. Overridden by AMEM_LLM_STRONG_MODEL."
128
+ },
129
+ "llmStrongBaseURL": {
130
+ "type": "string",
131
+ "description": "Optional stronger tier: endpoint. Falls back to llmBaseURL. Set all three strong fields to run the tiers on entirely different backends, e.g. a local Ollama for fast and a hosted API for strong. Overridden by AMEM_LLM_STRONG_BASE_URL."
132
+ },
133
+ "llmCrudRole": {
134
+ "type": "string",
135
+ "enum": [
136
+ "fast",
137
+ "strong"
138
+ ],
139
+ "default": "fast",
140
+ "description": "Which tier the agent_end CRUD decision runs on. Defaults to fast: it runs every turn, and its destructive failure mode is handled by the update guard rather than by model tier. Overridden by AMEM_LLM_CRUD_ROLE."
141
+ },
142
+ "crudUpdateMinSim": {
143
+ "type": "number",
144
+ "default": 0.35,
145
+ "description": "Similarity floor (0-1) for accepting an LLM-chosen CRUD UPDATE target. Below it the fact is stored as a new memory instead of overwriting, so a mis-picked memory is never destroyed. Raise it for cheaper/weaker models. Overridden by AMEM_CRUD_UPDATE_MIN_SIM."
146
+ },
93
147
  "agents": {
94
148
  "type": "object",
95
149
  "description": "Per-agent overrides keyed by agentId.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-amem",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "OpenClaw memory plugin implementing A-MEM — memories evolve, not just accumulate. Graph linking, hybrid retrieval, LLM-driven evolution. No Python.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -16,7 +16,7 @@
16
16
  }
17
17
  },
18
18
  "dependencies": {
19
- "@anthropic-ai/sdk": "^0.110.0",
19
+ "@anthropic-ai/sdk": "^0.112.1",
20
20
  "@huggingface/transformers": "^4.2.0",
21
21
  "@node-rs/jieba": "^2.0.1",
22
22
  "@qdrant/js-client-rest": "^1.18.0",
@@ -32,7 +32,7 @@
32
32
  "tsup": "^8.4.0",
33
33
  "tsx": "^4.23.1",
34
34
  "typescript": "^6.0.3",
35
- "typescript-eslint": "^8.60.1",
35
+ "typescript-eslint": "^8.64.0",
36
36
  "vitest": "^4.1.10"
37
37
  },
38
38
  "optionalDependencies": {