dsh-ab-memory
Verifieddsh-ab-memory · v0.1.9 · MIT
--- description: "The dsh-ab-memory package: a cross-session, file-backed agent memory with five tools (remember / recall / forget / load_skill / distill) and an always-on light injection." kind: "package-reference" ---
Install
dsh plugin add dsh-ab-memory Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Readme
description: "The dsh-ab-memory package: a cross-session, file-backed agent memory with five tools (remember / recall / forget / load_skill / distill) and an always-on light injection." kind: "package-reference"
dsh-ab-memory
Summary
One host-only package gives this machine's dsh profiles a cross-session, self-improving
memory. Memory is a local Markdown store that follows Claude Code's "the file is the source
of truth" model: every fact, lesson, skill, and wiki note is a Markdown file under
<memoryDir>/, and a single MEMORY.md index keeps the slugs and one-liners. An optional
Node 22 built-in node:sqlite FTS5 index is a disposable retrieval accelerator — no vector
database, no external service, no network when distillProvider: none.
The package registers the wire names on ctx.tools, contributes one always-on routing
section to ctx.systemPrompt, and reads/writes through whatever ctx.fs the composition
mounts. There is no browser card: this is a host-only (backend) tool, so it declares no
dsh.client half and the Web plugin table has nothing to discover. The manifest declares
dsh.bundle.patch for the host row, so one install — and one patch row — carries it.
# <DSH_HOME>/profiles/web/cordis.patch.yml
- insert:
- id: memory
name: 'dsh-ab-memory'
The deployment must also state the package's required Config guards in an id-only row
(see Mounting) — the schema declares every bound .required() on purpose, so a
hidden default would decide for the deployment how much memory it agreed to carry.
Build, test, and publish
pnpm install # standalone: this package ships its own settings-only pnpm-workspace.yaml
# (it anchors this dir as a standalone workspace root and declares koffi:false
# under allowBuilds), so install stays scoped here and never walks up to the
# harness checkout's root workspace. Do NOT replace node_modules with a symlink
# to another plugin's — that causes "Failed to create bin … ENOENT" WARNs.
pnpm run build # tsc -p tsconfig.json && tsdown -> lib/index.js (node:sqlite stays external)
pnpm test # node --test tests/ (run against the built lib/)
pnpm lint # publint + attw --pack --profile esm-only
Publishing is the dsh-plugin-release skill's step; its scripts take this package as an
argument. Write <skill> for that skill's base directory, which the loader prints when it
loads the skill:
node <skill>/scripts/src/index.mjs check --package <this package>
node <skill>/scripts/src/index.mjs release --package <this package> --bump patch
node <skill>/scripts/src/index.mjs publish --package <this package> --registry <url>
What one call does
Five tools, one store. remember writes; recall reads; forget tombstones; load_skill loads
a reusable SOP; distill upgrades raw captures into queryable topics.
The skill loader is load_skill, not skill: the harness ships its own skill tool for the
session skill catalog, and that registration shadows a same-named global one, which would leave
every stored SOP unreachable.
| Tool | Direction | Step | Detail |
|---|---|---|---|
remember |
Write | Scrub | Runs secret.ts over the content; API keys, inner URLs, and credentials become [redacted] before anything is written |
remember |
Write | Derive | Title from the explicit title or the first line; slug from slugify(title || firstLine) |
remember |
Write | Store | <memoryDir>/<type>/<slug>.md with frontmatter (name, description, type, slug, scope, confidence, modified, ttlDays, tags, deleted, path) plus a MEMORY.md index line |
remember |
Write | Refresh | Recomputes the always-on auto-injection text so the next prompt sees the new fact |
agent/turn-stopping |
Write | Capture | Appends the turn to raw/<date>.md under **user** / **agent** markers, secrets scrubbed and each message clipped |
agent/turn-stopping |
Write | Distill | Extracts atoms from the **user** segments only, skips slugs the store already holds, and refreshes the injection |
recall |
Read | Search | Two-stage: a cheap keyword pass over the index, then an FTS5 pass over the optional sqlite index whenever it exists; returns the top limit (default 5, capped 50) with excerpts and scores |
recall |
Read | Filter | Optional type filter (topic / lesson / skill / wiki); excludes tombstoned records |
forget |
Write | Tombstone | Logical delete (deleted: true + index-line removal); the file is kept for audit because dsh-fs exposes no hard delete |
forget |
Write | Locate | When type is omitted, scans every kind for the slug, then tombstones the one that matches |
load_skill |
Read | Load | Reads a skill asset and returns its full body for the agent to follow |
distill |
Write | Extract | Reads raw/*.md (or one named source), splits free text into atomic assertions with extractAtoms, dedupes against existing topics by slug |
Separating write from read is what keeps the injected context bounded: remember and
distill grow the store, but only the cached, byte-capped index plus the top lessons ever
reach the prompt.
The storage layout
<memoryDir>/MEMORY.md the index: one line per live record (slug, type, one-liner)
<memoryDir>/topics/<slug>.md a fact or distilled atom
<memoryDir>/lessons/<slug>.md a reusable lesson the agent learned
<memoryDir>/skills/<slug>.md a versioned SOP (trigger, steps, verification)
<memoryDir>/wiki/<slug>.md a longer reference note
<memoryDir>/raw/<slug>.md L0 captures awaiting distillation
<memoryDir>/index.sqlite disposable node:sqlite FTS5 accelerator (rebuilt from files)
memoryDir may be absolute or relative to the calling session's workspace; resolveMemoryRoot
prefers the session cwd so each project carries its own memory. The index.sqlite is never the
truth — rebuildIndex regenerates it from the Markdown files, and searchMemory falls back to
pure keyword scoring when the DB is absent.
The tools
remember
Persist a fact, preference, correction, or how-to. Arguments: content (required), scope
(project | user | global, default project), type (topic | lesson | skill |
wiki, default topic), title (optional). Returns { stored, slug, path, type }. Sensitive
strings are scrubbed before write; an empty content is rejected.
recall
Recall past memories relevant to the current task. Arguments: query (required), limit
(default 5, capped 50), type (optional filter). Returns { hits: [{ slug, type, path, excerpt, score }], total, mode }. mode is "fts" when the sqlite index is live, else
"keyword". Use it before re-deriving something the project may already hold.
forget
Forget a memory by slug. Arguments: slug (required), type (optional; when omitted the slug
is searched across kinds). Returns { forgotten, slug }. The record is tombstoned — kept on
disk for audit but excluded from the index and from recall — because dsh-fs exposes no
delete method.
load_skill
Load a versioned reusable skill distilled from past successes. Argument: slug (required).
Returns { slug, body }; throws when no such skill exists. The slug names a skills/ asset in
this plugin's store, not an entry in the session skill catalog.
distill
Distill raw captures (raw/, L0) into atomic topic facts (topics/, L1). Argument: source
(optional file name within raw/, or a path; when omitted, all raw/*.md are distilled).
Returns { extracted, written, skipped }; an atom whose slug already exists is skipped rather
than overwritten. Run it after a session dump lands in raw/.
Automatic capture and distillation
The store grows without a tool call. At every turn boundary the plugin appends the turn to
<memoryDir>/raw/<YYYY-MM-DD>.md and then distills it:
- Capture. Both sides of the conversation are written under
**user**/**agent**markers, secrets scrubbed bysecret.tsand each message clipped, so the dump is the L0 evidence a distill reads. Only messages whose source is the person are recorded as the person's; injected context — runtime snapshots, skill catalogs, compaction summaries — is harness machinery, not conversation, and is left out. A session that never carries a human turn is never captured, which keeps subagent sessions out of the store. - Distillation.
distilltakes only the**user**segments of a marked dump. The agent's own prose is re-derivable from the session log, and extracting it would fill the index that every later session injects with text nobody asked to keep. A source without markers is taken whole: it was placed by hand, so its author already chose what belongs there. Topics written on this path carrytags: [distilled, auto], which separates automatic growth from what the model asked to remember. - Which extractor.
distillProvider: ollamasends the segments todistillModel(a localqwen3.6:27bby default in this deployment) as one auxiliary model call per queued batch, asked for plain one-line entries and bounded by a ten-minute deadline (the same bound this deployment gives that provider's stream idle timeout). The design's rule is that extraction quality decides every layer above it, so a real model is the intended path. Any failure — capability absent, model not pulled, deadline hit — logs and falls back to the deterministic splitter for that batch, so a capture is never lost to a missing model.distillProvider: noneskips the model entirely and always uses the splitter. - Timing. Capture happens at each turn boundary rather than once at session end, so a session that is killed — or a process that never reaches disposal — has already recorded what it knew, and a topic stays attributable to the turn that produced it. Distillation is idempotent, so re-reading a dump is a no-op for atoms the store already holds. A model-backed distillation is detached: the turn never waits for it, and speech arriving while one batch is being extracted is merged into that root's next batch rather than queued behind it.
Configuration
Every field is required and has no default: a hidden one would decide for the deployment how
much memory it agreed to carry, and the schema enforces it with .required() so an omitted
value fails activation with $.<field>: missing required value.
| Field | Group | Meaning |
|---|---|---|
memoryDir |
store | Directory memory is written to; a relative value resolves against the session workspace |
maxAutoBytes |
guard | Hard UTF-8 byte ceiling on the always-on auto-injection text (minimum 256) |
maxLessons |
guard | Most recent lessons surfaced by the auto-injection (minimum 1) |
staleDays |
guard | Days before a record's ttlDays elapses and it is withheld from auto-injection (minimum 1) |
distillProvider |
extraction | none runs the deterministic offline splitter; ollama asks the configured distillModel through the mounted LLM capability |
distillModel |
extraction | The model id distillProvider: ollama calls, e.g. qwen3.6:27b on a local Ollama |
The always-on injection is the only unbounded-risk surface, and maxAutoBytes caps it. The
index plus the top maxLessons lessons are concatenated and then excerpt-clipped to that
ceiling, so the 300s idle timeout is never approached by a swelling memory.
Commands
pnpm build # tsc -p tsconfig.json && tsdown
pnpm test # node --test tests/ (build first)
pnpm test runs against lib/, so build first.
Mounting
The package is out-of-tree. Its dsh.bundle.patch (cordis.patch.yml, shipped in the
package) already inserts the host row under id: memory, so the profile needs only an
id-only config row — never a second - insert: row. The Config schema declares every
bound .required(), so the deployment must state these numbers; an id-only row feeds them
into the plugin the bundle patch already loaded, rather than registering it twice (which
would crash startup with tool "remember" is already registered / prompt section "memory:auto" is already registered).
## <DSH_HOME>/profiles/web/cordis.patch.yml
# id-only config row: the package loads itself via its own bundle patch.
# Add config ONLY here — a `- insert:` row with the same id would double-register.
- id: memory
config:
memoryDir: .dsh-memory
maxAutoBytes: 4096
maxLessons: 8
staleDays: 30
distillProvider: 'none'
distillModel: 'qwen3.6:27b'
// <DSH_HOME>/profiles/web/package.json (dsh.profile.bundles)
"bundles": [ "...", "dsh-ab-memory" ]
The bundle name must resolve from the profile's node_modules — resolveBundleDir looks the
package up under the dsh installation and then under <profileDir>/node_modules. For a
published package, declare it as a link: / file: / version dependency so a pnpm install places it. For local development against the tree-external source at
.dsh/plugins/dsh-ab-memory, the harness resolves the bundle through a directory junction:
# from <DSH_HOME>/profiles/web/node_modules
mklink /J dsh-ab-memory ..\..\..\plugins\dsh-ab-memory
The bundle row needs no filesystem provider of its own: it reads and writes through whatever
ctx.fs the composition mounts, and respects the session's sandbox fence (see
Writing under the session's sandbox policy).
Writing under the session's sandbox policy
A deployment that mounts @deepseek-ai/dsh-fs-sandbox fences every write by a per-call
sandbox policy, and that policy — not the backend's own default — names the workspace the
calling session runs in. src/sandbox.ts owns that seam. MutationPolicy resolves
ctx.sandboxPolicy for the call and stamps it onto every write through saveText, so a
remembered or distilled file lands inside the session's own workspace; a confining backend
that receives no policy is refused at load (it cannot be caught later because the backend
declares the policy service as its own injection and is never constructed without it).
saveText is the one path every artifact is written through, so a new call site cannot omit
the fence; it restates a refusal with the path, the mode, and the workspace to move under,
keeping the FS_SANDBOX_DENIED code the backend raised.
Model Experience
System-prompt section
memory:auto at order 120, above the built-in tool band and near the top of the system
prompt so it is always visible. It says the agent keeps a long-term memory, that a short
index is already injected, to call recall before re-deriving a fact/preference/correction/
how-to, to call remember when the user confirms a preference or corrects the agent, and to
call distill after a session dump lands in raw/. The section is empty in any scope where
the memory tools are not visible.
The text below that prose is the session's own workspace: the provider reads the assembly's
agent, resolves that session's memoryDir, and appends the index and top lessons cached for
that root. An assembly with no session gets the routing prose and no workspace's index, so one
project's memories are never injected into another's prompt.
Tool schema
Five schemas. remember takes content, scope, type, title. recall takes query,
limit, type. forget takes slug, type. load_skill takes slug. distill takes
source (optional; when omitted, all raw/*.md).
Tool-call history and result
A remember call followed by one line naming the stored type, slug, and path; a recall
call returning the matched hits with excerpts; a forget call returning whether the slug was
tombstoned; a load_skill call returning the skill body; a distill call returning
extracted → written, skipped.
Evidence
| File | Proves |
|---|---|
| tests/index.test.mjs | The core pure functions (userSegments included), the secret scrub, writeMemoryWithIndex / forgetMemory / searchMemory, the registered section and tools, the sandbox refusal, per-session injection isolation, turn capture → topic extraction, and the end-to-end remember→recall→forget→load_skill→distill flow through a real mounted Context |
| tests/loader-composition.test.mjs | A real cordis.yml boots with the six required fields, the auto-injection section assembles, and a missing/ out-of-range bound refuses registration |
| tests/hmr-safety.test.mjs | The tools and the routing section leave with their contributing fiber |
| tests/conventions.test.mjs | The source rules the skill scaffolds a package with |
Known Limitations and Deferred Work
- Retrieval is still keyword, not semantic.
recallruns FTS5 whenindex.sqliteexists and the keyword scorer otherwise;distillProviderselects the extraction engine and does not add an embedding path. A semantic retriever remains deferred. node:sqliteFTS5 needs Node 22+. The package declaresengines.node >= 22.19, andsearchFtsis only reached when the DB is present; on older runtimes the package still loads and degrades to keyword search.index.sqliteis disposable and not the truth. It is rebuilt from the Markdown files viarebuildIndex; a missing or stale DB never loses data, only retrieval speed.forgetis a tombstone, not a deletion.dsh-fsexposes no delete, so the file stays on disk (markeddeleted: true) for audit; nothing physically removes it.- Secrets are scrubbed, not encrypted.
secret.tsredacts API keys, inner URLs, and credential-shaped strings before write; a value the patterns miss is still written in clear text, so do notremembera full secret and expect protection. - Automatic distillation without a model uses the deterministic extractor.
extractAtomssplits sentences on punctuation and a length window, so an automatic topic is sentence-shaped and can be noise; the design's answer is the 27B extraction step, whichdistillProvider: ollamaturns on. Either way every automatic topic carriestags: [distilled, auto], so the growth is auditable and can be filtered or forgotten in bulk. Nothing else caps it: a long session with long prompts writes many topics into the index every later session injects. - Capture reads the session snapshot once per turn.
snapshotEvents()returns the whole log, so capture is O(events) per turn; a session with many thousands of events pays for that each turn. It is a plain array scan, not a log read, and the cursor keeps any of it from being written twice. raw/only grows. Every captured turn is appended and nothing prunes or ages the files out;staleDaysgoverns the injected index, not the dump. It also duplicates part of the durable session log, which is the price of keeping L0 evidence inside the memory store.- The injected text is cached per memory root. It is warmed when the agent is created, so
a session sees its workspace from its first prompt, and refreshed by
remember/forget/distill. A session that outlives a plugin reload keeps its cached text but is not re-warmed (noagent/createdfires again); restartingdsh webis the supported way to pick up a new build. - The index line is the only structured pointer.
searchMemoryreads the index for the keyword pass; a hand-editedMEMORY.mdthat drifts from the files is trusted as-is until the next write refreshes it.
Dev Note
pnpm build compiles with tsc and bundles with tsdown; pnpm test runs node --test
over the built lib/index.js, so a green suite means the artifact the profile row resolves is
the artifact that behaves. node:sqlite stays external to the bundle (the package relies on
the Node 22 built-in), so the built entry does not carry a database dependency. The plugin is
host-only: defineTool registrations and the memory:auto section live in src/index.ts,
the pure core (slug, frontmatter, index lines, scoring, atom extraction) in src/core.ts, the
store (read/write/forget/search + sqlite) in src/store.ts, the sandbox seam in
src/sandbox.ts, the secret scrub in src/secret.ts, and the shared vocabulary in
src/types.ts. When you add an asset kind or a retrieval mode, add it to types.ts,
core.ts, and store.ts together, then extend tests/index.test.mjs.