openclaw-amem@1.3.0
Minor Changes
-
#63
9b22d73Thanks @heichaowo! - Let a host choose the engine's LLM provider, model and endpoint (Story 35).The engine's own LLM settings were frozen at module load:
PROVIDERandMODEL
were top-level consts, so the only way to change them was to set an environment
variable before the process started. A host embedding the engine had no way in.They now resolve per call, and
configureLlm({ provider, model, baseURL })lets a
host set them after import. The OpenClaw plugin wires this to three new config
keys —llmProvider,llmModel,llmBaseURL— soopenclaw.jsoncan point
amem's note construction, linking and evolution at a different model than the one
your agent session uses. Precedence, highest first: environment variable, then
plugin config, then the built-in default per provider. Configure nothing and
behaviour is exactly as before.Two deliberate choices worth naming. There is no way to inject an API key:
keys come from the environment only. Configuration arrives from a host config
file, and a key field would make the memory engine a channel for a user's gateway
credentials — endpoint and model are enough to route a call. And an environment
variable set to the empty string now counts as unset, so an exported-but-blank
AMEM_LLM_MODELcan no longer silently outrank a valid configured model.The engine still does not follow whichever model your agent session is using;
that needs host APIs this change does not depend on, and is tracked separately.
Following it is also not obviously desirable — these are cheap, high-frequency
utility calls, and inheriting a large reasoning model would make every memory
write slow and expensive. -
#71
78f2190Thanks @heichaowo! - Split the engine's LLM calls into afastand an optionalstrongtier (Story 42, PR 1/2).Published results are consistent that memory quality is mostly architecture-bound:
for fact extraction a cheap model scores within ~2 points of a strong one, and
retrieval method moves accuracy far more than write strategy does. There is one
exception — judging whether new information contradicts what is stored, where
the cheap/strong gap is large.So the calls now split by how hard they actually are.
fastruns note
construction, link judgement, neighbourhood refresh and the per-turn CRUD
decision.strongruns only merge adjudication and EVOLVE/CONFLICT/EXPAND/NEW
classification.Configure nothing and nothing changes.
strongfalls back tofastfield by
field, so the three useful shapes all work: set onlyllmStrongModelfor "same
endpoint, better model"; set all threellmStrong*fields to run the tiers on
entirely different backends (a local Ollama forfast, a hosted API for
strong); set none and the engine behaves exactly as before. There is
deliberately no built-in strong default — inventing one would start spending an
existing user's money without them asking.New config:
llmStrongProvider/llmStrongModel/llmStrongBaseURLand
llmCrudRole, plusAMEM_LLM_STRONG_PROVIDER/AMEM_LLM_STRONG_MODEL/
AMEM_LLM_STRONG_BASE_URL/AMEM_LLM_CRUD_ROLE.The CRUD decision defaults to
fasteven though it is a contradiction judgement:
it runs every turn, and its one destructive failure mode — overwriting the wrong
memory — is already handled architecturally by the update guard rather than by
model tier.llmCrudRole: "strong"moves it for operators who prefer that.SDK clients are now cached per base URL instead of as singletons, since the two
tiers may point at different backends. -
#74
c4f3e91Thanks @heichaowo! - Addsubjects— who a memory is about (Story 44).agent_idsays whose memory store a note lives in. It never said who the note
is about. For a companion that meets several people those are different
questions, and without the second one every player's memories land in the same
pool and contaminate each other's retrieval.Every note now carries
subjects, a list:[]— a fact about the world, or about the character itself. Visible to everyone.["alex"]— about one person. Surfaced only when scoped to them.["alex", "sam"]— a shared experience. Surfaced for either of them.
A list rather than a single value because shared experience is the normal case
for a companion, not an edge case: "we beat the dragon together" belongs to both
people, and splitting it into two near-identical notes would only give the
deduplicator something to merge back. The three-way visibility rule then falls
out of the shape, with no extra mode switch.searchMemory(query, topK, agentId, { subject })and thememory_search/
memory_addtools expose it. Scoping is applied inside the vector-store query,
so an out-of-scope memory is never fetched — and it is applied to BOTH retrieval
paths, semantic and keyword, since scoping only the vector side would leak
another person's memories through BM25.subjectsdefaults to empty, so existing memories are all world facts and stay
visible exactly as before; omittingsubjecton a search is today's behaviour.
Patch Changes
-
#60
d903552Thanks @heichaowo! - Enforce thewritersaccess rule on every write path (Access Protocol, Story 33).Notes have carried
owner/readers/writerssince per-agent isolation landed,
but onlyreaderswas enforced. Because the agent filter matches
agent_id == caller OR agent_id == 'shared', every query can return another
agent's shared note — and each mutation then wrote to it unchecked. An audit of
the engine found eight such write sites: high-similarity dedup, bidirectional
link generation, evolution (neighbour rewrite and strengthen), the plugin's
agent_end CRUD update and delete, the quality scan, and consolidation's link
rewriting. In the worst of them, one agent's write could silently overwrite the
content and embedding of another agent's shared memory.All eight now gate on a new exported rule,
canWrite(note, callerAgentId)— true
for the owner, an agent listed inwriters, orwriters: ['*']. Denial degrades
gracefully and never throws: dedup inserts the caller's own note instead of
overwriting, back-links and evolution skip that note, CRUD ops are logged and
skipped, and the quality scan no longer flags notes it cannot act on.
updateNoteContentandinvalidateNotetake an optionalcallerAgentIdfor
callers that hold only an id (they returnfalse, unwritten, when denied);
omitting it preserves existing behaviour, so consolidation and merge — already
scoped to their own private notes — are unchanged. -
#64
634d280Thanks @heichaowo! - Harden LLM response parsing and add a request timeout (Story 40, mem0 取经).Three robustness fixes for the engine's LLM layer, all in
llm.ts, prompted by
reading how mem0 handles the same problems:-
Strip reasoning scaffolding before JSON.parse. The engine accepts any
OpenAI-compatiblebaseURL, so it can be pointed at reasoning/open-weight
models (DeepSeek-R1, Qwen, LLaMA-3 via Ollama/vLLM) that wrap their output in
<think>…</think>blocks and chat special tokens (<|eot_id|>,<|im_end|>,
…). Those brokeJSON.parse, and every JSON task silently fell back to its
blank default on an otherwise-valid response — with nothing in the logs to say
why.stripReasoning()now removes them first, instripFences(covering the
five object-JSON tasks) and on the CRUD array path. This was a latent silent
degradation, not just a nicety. -
Tolerate a preamble before the JSON object.
parseJsonLoose()replaces the
four directJSON.parse(stripFences(raw))calls: on a parse failure it retries
against the first{…}region, recovering the common "Sure! Here is the JSON:"
preamble smaller models emit. It still throws when nothing parses, so every
caller's existing try/catch → safe-default path is unchanged. The CRUD path
already did array extraction and is untouched. -
Configurable client timeout. The SDK clients were built with no timeout, so
a slow or stuck endpoint (loaded vLLM, unreachable gateway) could hang the whole
addMemorypipeline indefinitely. NewAMEM_LLM_TIMEOUTenv var (default
30000 ms) andLlmConfig.timeoutMs, threaded into both client constructors.
No behaviour change for a well-formed response from a normal model. New tests
drive the real note-construction and CRUD functions with mocked SDKs; the
reasoning-strip and preamble-recovery tests were verified to fail against mutated
source (a no-opstripReasoning, a dropped brace fallback), so they are not
vacuous.Deliberately NOT changed, after comparing with mem0: the CRUD integer-index
referencing (amem's JSundefined+if (target)guard is already safer than
mem0's unguarded index), the three-layer dedup (stronger than mem0 v3's single
hash pass), and retry/fallback-on-error (mem0 has none either — a real runtime
fallback is designed separately, with Story 39). -
-
#68
f52a083Thanks @heichaowo! - Make the CRUDUPDATEpath non-destructive (Story 41).When the
agent_endhook decides an existing memory should be updated, it picks
one from a numbered candidate list. Picking the wrong number was the engine's one
silent, unrecoverable failure: the index is valid and the note is usually one the
caller owns, so neither bounds-checking nor the access protocol catches it — and
updateNoteContentoverwrites content and embedding in place. (DELETEwas
never affected;invalidateNoteis a soft delete.)This is a documented failure class, not a hypothetical. mem0 removed its own CRUD
step partly because "overwrites sometimes erased key information from the original
fact", and Memory-R1 exists because vanilla LLMs mis-classify additive facts as
contradictions. The risk scales inversely with model capability, and the notes
reaching this step have already survived hash and vector dedup — the hardest
subset, exactly where a cheap model is least reliable.Two guards, both default-on:
isPlausibleUpdateTarget— before overwriting, check the replacement text is
plausibly about the memory it replaces (cosine ≥crudUpdateMinSim, default
0.35). Both embeddings are already in hand, so this costs one dot product and
no LLM call. On failure the fact is stored as a new memory instead: nothing
is lost, and consolidation can merge a duplicate later — whereas it can never
resurrect an overwritten note. Tunable viaAMEM_CRUD_UPDATE_MIN_SIMor the
crudUpdateMinSimplugin config; raise it for cheaper models.- History snapshot — an accepted overwrite now records the replaced text in the
note'sevolution_history(action: "crud_update", newoldContentfield), so
even a false negative stays recoverable. Only the caller-scoped path pays for
this; the dedup and merge paths pass nocallerAgentIdand are unchanged, so
they take no extra read.
The threshold is a heuristic, not a tuned constant — it sits just above the
0.3
bar the engine already uses for "related at all", because a legitimate update is
often a correction ("drinks tea" → "switched to coffee") that is related but not
near-identical. Set it to0to disable the check deliberately. -
#72
a7e34f7Thanks @heichaowo! - Add a cold-layer contradiction sweep (Story 43).The per-turn CRUD decision runs on the fast model. That is safe — the update
guard stops it writing to the wrong memory — but dull: it misses contradictions
it should have caught, scoring around 8.7% at noticing a stored memory has
quietly stopped being true. This sweep is the other half of that trade.conflictSweep()runs offline, in batches, on thestrongtier. It hands the
model a whole batch of memories at once rather than comparing them pairwise,
because the contradictions that matter are usually far apart in meaning — "is
vegetarian" and "loved the steak" would never be paired by a similarity gate, and
the existing consolidation's 0.75 cosine threshold structurally excludes exactly
the class this exists to find.When a pair is found, BOTH notes are marked with a pointer to the other
(conflicts_with) and the model's reason (conflict_reason). Those fields are
what let a conflict be reviewed as one decision instead of two disconnected
entries — the review batch now renders each pair side by side with timestamps,
the reason, and a recommendation, so it is one glance and one tick.AMEM_CONFLICT_MODEchooses what happens next.review(default) marks and
stops.autoalso retires the older note of each pair, needing no human — but
even a strong model is only around 55% accurate here, so roughly two in five
retirements will silence a memory that was still true. The retirement is a soft
delete and recoverable, but for a system answering in real time that only helps
once somebody notices. The docs say so plainly, in a danger callout.Hallucinated, self-referential and duplicate pair indices are all dropped before
they can reach a note.