feat(memory): working-memory tier — capacity-bounded STM + STM→LTM promotion#81
Conversation
…lusters Point 1 of the remaining memory-brain roadmap. Every prior mechanic (Hebbian, decay, spreading activation, communities, importance) only connects/ranks/decays memories that already exist. Reflection is the one step that CREATES knowledge: it clusters related memories, asks the engine to synthesize a higher-level insight per cluster, and stores each as a NEW `semantic` node linked back to the observations it came from (`derived_from` edges) — the Generative-Agents reflection tree, and the heart of "memory that works like a brain". - engine.reflect(owner_id, llm, min_cluster, max_reflections): model-agnostic — `llm` is an injected `(prompt) -> text`. Only reflects over raw observations (skips prior insights, so passes don't runaway), caps output, and degrades to "no insight" on empty LLM output. - sndr/memory/llm.py `make_openai_llm`: a dependency-free (urllib) OpenAI-compat `/v1/chat/completions` caller that binds reflect() to the running vLLM engine (thinking disabled; non-2xx degrades to ""). - Reachable end-to-end: POST /api/v1/memory/reflect (503 until SNDR_OPENAI_BASE_URL is set) + MemoryHTTPClient.reflect + `sndr mem.reflect` (CLI_REFERENCE §7). TDD: test_memory_reflection.py (8) — creates a derived node, marks it semantic+derived+sources, links to sources, skips small clusters, empty-output creates nothing, caps at max_reflections, never reflects on derived nodes, and the prompt carries the cluster contents. test_memory_llm.py (3) — request construction, /v1 idempotence, error→empty. Plus the reflect client+CLI verb. Engine stays model-agnostic; regression clean (66 focused memory tests green).
…omotion Point 2 of the memory-brain roadmap. The taxonomy (working/episodic/semantic/ procedural) already made `working` memories decay fast; this adds the other two properties a real working memory has, and wires them into the nightly batch: - `engine.prune_working(owner_id, capacity)` — keep only the N most-recent working memories, evict the rest. Working memory is a scratchpad, not an ever-growing log. Non-working memories are never touched. - `engine.promote_working(owner_id, min_access)` — a working memory recalled at least `min_access` times graduates to a durable `episodic` memory (re-stored under the new type so it gets slow decay; the transient original is dropped) — the classic STM→LTM promotion. - `run_maintenance` now promotes-then-bounds the working tier per owner before consolidate+prune, so the background scheduler manages it automatically. Pure engine ops over existing store primitives (iter_nodes/delete_node/remember) — no store-contract change. TDD: test_memory_working_tier.py (6) — capacity keeps the most-recent, ignores other kinds, no-op under capacity; promotion graduates used items to episodic, leaves below-threshold + non-working alone. Stacked on #80 (reflection). 240 focused tests green; ruff clean.
There was a problem hiding this comment.
Code Review
This pull request introduces a working-memory tier to the memory engine, implementing capacity-bounded short-term pruning and short-term to long-term memory promotion during the maintenance pass, along with comprehensive unit tests. Feedback on these changes highlights a performance bottleneck and loss of graph connectivity in promote_working due to redundant embedding calls and deleted node associations, and suggests adding a defensive guard in prune_working to handle negative capacity values safely.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| self.remember( | ||
| owner_id=owner_id, | ||
| text=node.content, | ||
| kind=to_kind, | ||
| importance=node.importance, | ||
| properties={**node.properties, "promoted_from": "working"}, | ||
| dedup=False, | ||
| ) |
There was a problem hiding this comment.
Redundant Embedding Call & Loss of Graph Associations during Promotion
There are two major issues with the current implementation of promote_working:
- Performance Bottleneck (Redundant Embedding): Calling
self.remember(...)triggers a new embedding call (self.embedder.embed_one(text)) for the node's content. Since the node is already stored and we have its embedding innode.embedding, re-embedding is highly inefficient and unnecessary. We should callself.store.add_nodedirectly. - Loss of Graph Connectivity: Deleting the original node and creating a new one completely destroys all existing associations (such as Hebbian
co_accessedges and semanticsimilar_toedges) that the node accumulated while in working memory. To preserve these associations, we should retrieve the node's neighbors and re-create the edges for the newly promoted node before deleting the old one.
| self.remember( | |
| owner_id=owner_id, | |
| text=node.content, | |
| kind=to_kind, | |
| importance=node.importance, | |
| properties={**node.properties, "promoted_from": "working"}, | |
| dedup=False, | |
| ) | |
| neighbors = self.store.neighbors(node.id) | |
| new_id = self.store.add_node( | |
| owner_id=owner_id, | |
| kind=to_kind, | |
| content=node.content, | |
| embedding=node.embedding, | |
| importance=node.importance, | |
| properties={**(node.properties or {}), "promoted_from": "working"}, | |
| ) | |
| for neigh_id, rel, weight in neighbors: | |
| self.store.add_edge(new_id, neigh_id, rel, weight=weight) |
| def prune_working(self, *, owner_id: int, capacity: int) -> int: | ||
| """Keep only the ``capacity`` most-recent ``working`` memories for this | ||
| owner; evict the rest. Working memory is a scratchpad, not an | ||
| ever-growing log — this is its capacity bound. Returns how many were | ||
| evicted. Non-working memories are never touched.""" | ||
| working = [ |
There was a problem hiding this comment.
Defensive Guard for Negative Capacity
If capacity is negative, the slice working[capacity:] will slice from the end of the list (e.g., working[-1:]), which would evict only the most recent items instead of evicting everything or raising an error. Adding a defensive guard like capacity = max(0, capacity) ensures robust behavior.
| def prune_working(self, *, owner_id: int, capacity: int) -> int: | |
| """Keep only the ``capacity`` most-recent ``working`` memories for this | |
| owner; evict the rest. Working memory is a scratchpad, not an | |
| ever-growing log — this is its capacity bound. Returns how many were | |
| evicted. Non-working memories are never touched.""" | |
| working = [ | |
| def prune_working(self, *, owner_id: int, capacity: int) -> int: | |
| """Keep only the ``capacity`` most-recent ``working`` memories for this | |
| owner; evict the rest. Working memory is a scratchpad, not an | |
| ever-growing log — this is its capacity bound. Returns how many were | |
| evicted. Non-working memories are never touched.""" | |
| capacity = max(0, capacity) | |
| working = [ |
Why
Point 2 of the memory-brain roadmap — the missing working-memory tier. The taxonomy (#77) made
workingmemories decay fast; this adds the other two properties a real working memory has.What
prune_working(owner_id, capacity)— keep only the N most-recent working memories, evict the rest (a scratchpad, not an ever-growing log). Non-working memories untouched.promote_working(owner_id, min_access)— a working memory recalled ≥min_accesstimes graduates to a durableepisodicmemory (re-stored under the new type → slow decay; transient original dropped) — the classic STM→LTM promotion.run_maintenancenow promotes-then-bounds the working tier per owner before consolidate+prune, so the background scheduler manages it automatically (the "nightly batch").Pure engine ops over existing store primitives (no store-contract change).
TDD
test_memory_working_tier.py(6): capacity keeps the most-recent / ignores other kinds / no-op under capacity; promotion graduates used items to episodic / leaves below-threshold + non-working alone.Verification
240 focused tests green (maintenance + engine + working + api + wiring); ruff-clean.
Remaining
Obsidian path-keyed dedup · typed entity/relation KG + LLM fact-extraction · GUI/TUI coherence (need a store find-by-property/update primitive or React/textual work — deliberate next slices).