DSH-MEMORY · TECHNICAL DEEP-DIVE

Memory that
survives every session

dsh-memory is an installable memory bundle: it watches the conversation for signals, distills what is worth keeping into a store, and hands the relevant memories back to the model in every future session. This page tears down the full pipeline — from capture and storage to retrieval, injection, and decay.

7collaborating plugins
3durable tables
9model-facing tools
2×write / read defense
SCROLL ↓
00

The big picture: memory is a pipeline

Memory is not a static table — it is a loop with intake, outflow, and decay.

Every session produces signals — a "remember this", a correction, a debugging session where repeated failures finally succeed. dsh-memory collects those signals, distills them into entries via an LLM, and pushes them through a security gate into a durable KV store; in the next session, the retrieval plane hands the relevant ones back into the context. Memories that keep getting used stay fresh; the neglected ones quietly fade out.

FIG.0 — THE LOOP
memory.jsonsingle source of truth
The memory loop: perceive → extract → store → retrieve → inject → back into the conversation. Six stages, each owned by an independent plugin, all sharing one storage domain.

In practice it is seven Cordis composition rows: six functional plugins, each with one job — memory-store opens the storage domain and registers the ctx.memory service; tool-memory exposes the nine model tools; memory-review drives automatic extraction; memory-context manages system-prompt injection; memory-notes renders conventions and pitfalls into the project-notes prompt section (it writes no files into your repo); memory-remote backs the memory manager in the settings UI (remote writes default to off and must be enabled explicitly) — plus a no-op memory-root entry that exists only for client-module discovery. The storage medium is a single file, ~/.dsh/storages/memory.json — uninstalling the plugins never loses your memories.

01

Capture: learn only when signaled

A purely synchronous projection accumulator folds the session event stream — and only disturbs the LLM once enough signal has accumulated.

Every user message and tool result flows through a zero-LLM-cost projection accumulator (session-projection). It collects just three kinds of candidate fragments — the collection layer deliberately keeps the funnel wide; the conservatism about what is worth keeping is the extraction prompt's job: a missed signal costs a little, a false one costs almost nothing.

SIGNAL / 01

Explicit intent keyword

The user directly asks to remember. Patterns like "remember" / "from now on" / "going forward" put the message into the candidate pool.

"Remember: this service deploys on port 8080"
SIGNAL / 02

Correction correction

The user overturns an earlier statement. "No, actually…" / "I meant…" — a correction means the old belief was wrong, and the new conclusion is worth keeping.

"No — we actually use pnpm, not npm"
SIGNAL / 03

Failure streak pitfall-resolved

The same tool call fails repeatedly (default ≥ 2, same signature) and then finally succeeds — that success is a verified workaround, distilled into a pitfall entry.

tool error ×2 → ✓ → [pitfall] symptom / root cause / fix
FIG.1 — EXTRACTION, LIVE
Event stream → projection accumulator0 / 3 candidates
// only signal-matching events count — zero LLM calls
Drain: extract → judge → storeExtraction budget 20/20 (this session)
Demo threshold set to 3 (the real default is 10, live-configurable). The high-water mark advances only after a successful extraction — failed batches wait for retry, and dedup keeps replays idempotent.
Flush

Two flush paths guarantee no signal is lost: compaction end (compaction/end) extracts from the raw events that compaction shadowed; session dispose (session/disposed) runs one final extraction over the full un-compacted session (5 s timeout, fire-and-forget).

Budget

Periodic review, compaction flush, dispose flush, and the low-frequency curator share one 20-extraction-per-session budget: each drain counts as 1 — the multiple LLM calls inside a batch and the dedup judge calls are not counted; set 0 for unlimited. Once exhausted, no more automatic extraction in this session. That is the hard cost ceiling.

Gate

The extraction prompt is itself a gate: anything the repository already records, ephemeral state and unverified hypotheses, workflows not validated by a tool are all excluded; preference entries require an explicit user request or repeated topic recurrence. Date prefixes are stripped at the parse layer — timestamps always come from the program; output follows the scope: content line protocol, fragments are flattened to single lines first, so forged lines cannot pollute the protocol.

02

Storage: through the gate, then to the store

Every write — whether from a model tool or from background extraction — must pass through the same gate.

FIG.2 — THE WRITE PATH, INTERACTIVE
memory_add
Validation
scope + projectName
Security scan
16 secrets · 17 injections · 4 exfil
KV put
entries.put
Audit append
audit + seq
// write path standing by: validate → scanContent → put → appendAudit
ENTRIES · AUDIT (tail)
audit #41 · update · source:janitor · "…staleSince stamped"
The scanner is a pure function: 37 regexes cover 16 secret classes, 17 injection patterns (8 of them Chinese counterparts), and 4 exfiltration classes, with a whitelist for exemptions. The read side has a second line of defense (see 06).
{
  "id": "mem_9f2c…",
  "scope": "project",  // ①
  "category": "convention",  // ②
  "content": "commit messages in English…",
  "summary": "commit-message language",  // ③
  "projectName": "dsh-memory",
  "createdAt": 1756…, "updatedAt": 1756…,
  "pinned": false,  // ④
  "accessCount": 7,  // ⑤
  "lastRecalledAt": 1756…,  // ⑥
  "staleSince": undefined  // ⑦
}
scopeThree scopes: global is cross-project, project is per-repo (inferred from cwd), user follows the individual
categorySeven categories: failure / correction / insight / preference / convention / tool-quirk / procedure
summaryOptional short summary — index mode and auto recall render this first (progressive disclosure)
pinnedPinned = immune to decay: "the user wants this kept"
accessCount+1 and refreshed lastRecalledAt on every hit — when the cap evicts, the lowest use signal goes first
lastRecalledAtRefreshed on every retrieval hit — use is the preservative
staleSinceThe soft-decay stamp: once stamped, it leaves the injection surface but stays searchable and revivable

entries

KvTable<MemoryId, MemoryEntry> · soft cap 500

The main entry table. Reads hit the in-memory authoritative state synchronously; writes are serialized on the domain write chain, applied to the backend before the in-memory copy updates. Writing past the soft cap (entriesCap, configurable) evicts by use signal: pinned entries are exempt, the lowest accessCount / lastRecalledAt goes first; if everything is exempt the table may exceed the cap — healthy data is never hard-deleted.

audit

cap = 200 rows

One audit row per successful change: the operation, the source (tool/review/flush/ui/janitor), a monotonic seq, and a content preview. Who wrote what, and when — always knowable.

suggestions

cap = 200 rows

The pending queue in human-review mode. A suggestion is not a memory — not injected, not searched, not decayed; it only waits for a human verdict (see 06).

The store ships one more layer: cross-process single-writer detection. The host storage backend assumes one writer per process — two DSH processes sharing a storage root each hold their own in-memory authoritative state and republish the whole unit file, silently clobbering each other. So on every boot, memory-store writes an owner stamp (pid + bootId) into the domain's global slot and re-reads it periodically: if its own stamp comes back as someone else's, a concurrent publisher exists and is reported at once. It detects, never locks — a foreign stamp whose pid is dead, or one closed cleanly, is no live threat, so restarts stay silent.

03

Retrieval: dependency-free BM25

No vector store, no embedding model — a ~270-line dependency-free Okapi BM25 with CJK-aware tokenization and a conservative Latin stemmer.

Retrieval in two steps: structured filtering first (scope / category / projectName), then BM25 scoring over the surviving candidates. The tokenizer splits Latin text by word and strips one conservative rule suffix to collapse inflections — uses→use, studies→study, while stems like class and basis are never damaged (one shared tokenizer for documents and queries); for CJK it emits single-character unigrams plus adjacent bigrams — the bigrams tighten matches, the unigrams keep single-character queries recallable. Summaries join the index alongside content, so a short summary recalls the entry too. Ranking proceeds by: relevance ↓, pinned ↓, recency ↓.

score = Σ IDF(term) × tf·(k₁+1) / (tf + k₁·(1−b + b·|d|/avgdl))  k₁ = 1.2, b = 0.75, non-negative IDF variant
FIG.3 — THE RETRIEVAL KERNEL, LIVE (THIS PAGE RUNS THE SAME ALGORITHM)
golden set: success@5 = 100% · P@1 = 82.9% · MRR = 0.902 (35 entries × 35 queries, en+zh, measured in CI)
The widget above is the same algorithm ported from src/store/bm25.ts, running live in your browser. A hit stamps lastRecalledAt — recall itself is the preservative.
04

Injection: frozen, budgeted, progressive

How memory enters the context decides every token it saves the model.

Each session freezes a memory snapshot at creation time and reuses it for the whole session — that keeps the KV-cache prefix stable: same prefix, full cache hits. The only moment it is broken is compaction (the prompt is being rebuilt anyway), and memories learned mid-session are injected exactly there. The snapshot has a dual budget: 5000 characters + 20 entries, with a trailing ≈N tokens estimate so injection cost is always visible.

FIG.4 — FIVE INJECTION MODES
The memory section sits at order 90 in the system prompt (before tool guidance), project-notes follow at @91. Switching is instant; the snapshot content stays frozen.

Index mode — the factory default since 0.8 — is the core of progressive disclosure: each entry renders as a one-line existence index (scope/category · id · summary), ordered by the project → user → global relevance tier; when the budget runs out, the tail collapses into per-category counts (project/convention ×12) — index size grows with the number of categories, not entries. The model sees "what exists" and fetches full text with memory_get on demand.

Optional step-level auto recall takes a different road: on each agent step, a BM25 search keyed on that step's user text appends the hit entries as a fenced <recalled-memory> user message — the system prompt is untouched, the prefix cache does not move a single byte. On load, corrected entries carry a conflict annotation (⚠ conflicts with a newer correction) so the model weighs old vs. new itself.

05

Lifecycle: memories age

The janitor patrols on every session creation. Drag the timeline to watch three entries meet different fates.

FIG.5 — DECAY SIMULATOR (decayDays = 30)
0 days · janitor patrolling
day 0day 15day 30 — decay lineday 45
project

"Deploy script lives in scripts/deploy.sh, port 8080"

// active · hard-deleted if not recalled for 30 days
global

"Behind a proxy, pnpm needs the registry mirror set first"

// active · soft-decayed if not recalled for 30 days
pinned · user

"Review comments must give a reason per item"

// pinned · immune to all decay
// audit stream will appear here
Hard deletion targets the project scope only; global/user get soft decay — staleSince stamped, out of the injection surface but still searchable, one recall revives it instantly.

The counterpart to decay is distillation: every 20 sessions, the curator picks oversized entries (≥ 400 chars) — at most 5 per pass — and has the LLM rewrite them into a concise single line; low-frequency, budget-constrained, and it can enter the human-review queue too. The memory store thus forgets, and it distills.

06

Boundaries: two defenses and a veto

Memories get read back into future contexts — so the store is itself an attack surface, and must be defended on both sides.

WRITE-TIME

scanContent gate

  • 37 regexes: 16 secrets · 17 injections (8 Chinese counterparts included) · 4 exfiltration
  • Intercepted at the tool boundary, re-checked at the storage layer (defense in depth)
  • Rejected content returns an error and is never stored; the whitelist exempts known example values
LOAD-TIME

redactBlocked backstop

  • Anything is re-scanned before it re-enters a prompt
  • Leaked content renders as a [BLOCKED: …] placeholder
  • The original stays in the store for human review — silent deletion would only hide the attack

Flip on confirmBeforeWrite and the system's trust model changes completely: every extraction and tool write is downgraded to a proposal in the pending queue. The same proposal observed repeatedly accumulates hits and floats to the top — frequency is the signal. A human can edit before adopting, or reject with one click; a proposed change to an existing entry never touches the original before adoption. The model never self-promotes.

FIG.6 — THE PENDING QUEUE, RULE IT YOURSELF
The queue is ordered by hits: the proposal the model keeps re-proposing is usually the one most worth your eyes.

Finally, memories flow back into every session: convention / preference entries and the failure / procedure / tool-quirk pitfall log render into the project-notes system-prompt section. Nothing is written into your repository — memory lives in the host-side store and is managed in the Memory settings UI (since 0.6; the ≤0.5.x in-repo notes files are cleaned up automatically). Entries already rendered into notes are excluded from the memory section — the same content never appears twice in the prompt. The store is the source of truth; the notes section is its read-only projection.

Frequency is the signal

Proposals observed repeatedly accumulate hits and float up; entries recalled repeatedly stay fresh automatically. The trail of use is the most important metadata.

Stability over freshness

The snapshot freezes the session for the KV-cache — rather than jitter the prefix, it accepts one beat of delay: what cache hits save far outweighs the immediate gain.

Memory is context, not instructions

Every injection surface declares: the current user request, the repo files, and tool outputs always outrank memory. On conflict, trust the present.

Every write is auditable

37 scan rules, 200-row rolling caps on audit and suggestions, a 500-entry soft cap, an optional human-review queue — a memory system must always be able to answer "who wrote this memory, when, and why".