Skip to content

feat(memory): working-memory tier — capacity-bounded STM + STM→LTM promotion#81

Merged
Sandermage merged 2 commits into
mainfrom
memory-working-tier-2026-07-08
Jul 8, 2026
Merged

feat(memory): working-memory tier — capacity-bounded STM + STM→LTM promotion#81
Sandermage merged 2 commits into
mainfrom
memory-working-tier-2026-07-08

Conversation

@Sandermage

Copy link
Copy Markdown
Owner

Why

Point 2 of the memory-brain roadmap — the missing working-memory tier. The taxonomy (#77) made working memories decay fast; this adds the other two properties a real working memory has.

Stacked on #80 (reflection).

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_access times graduates to a durable episodic memory (re-stored under the new type → slow decay; transient original 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 (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).

…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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread sndr/memory/engine.py
Comment on lines +258 to +265
self.remember(
owner_id=owner_id,
text=node.content,
kind=to_kind,
importance=node.importance,
properties={**node.properties, "promoted_from": "working"},
dedup=False,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Redundant Embedding Call & Loss of Graph Associations during Promotion

There are two major issues with the current implementation of promote_working:

  1. 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 in node.embedding, re-embedding is highly inefficient and unnecessary. We should call self.store.add_node directly.
  2. Loss of Graph Connectivity: Deleting the original node and creating a new one completely destroys all existing associations (such as Hebbian co_access edges and semantic similar_to edges) 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.
Suggested change
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)

Comment thread sndr/memory/engine.py
Comment on lines +230 to +235
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 = [

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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 logthis 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 logthis is its capacity bound. Returns how many were
evicted. Non-working memories are never touched."""
capacity = max(0, capacity)
working = [

@Sandermage Sandermage changed the base branch from memory-reflection-2026-07-08 to main July 8, 2026 07:42
@Sandermage Sandermage merged commit 34e2693 into main Jul 8, 2026
5 checks passed
@Sandermage Sandermage deleted the memory-working-tier-2026-07-08 branch July 8, 2026 07:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant