Skip to content

dsh-ab-memory

Verified

dsh-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 by secret.ts and 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. distill takes 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 carry tags: [distilled, auto], which separates automatic growth from what the model asked to remember.
  • Which extractor. distillProvider: ollama sends the segments to distillModel (a local qwen3.6:27b by 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: none skips 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. recall runs FTS5 when index.sqlite exists and the keyword scorer otherwise; distillProvider selects the extraction engine and does not add an embedding path. A semantic retriever remains deferred.
  • node:sqlite FTS5 needs Node 22+. The package declares engines.node >= 22.19, and searchFts is only reached when the DB is present; on older runtimes the package still loads and degrades to keyword search.
  • index.sqlite is disposable and not the truth. It is rebuilt from the Markdown files via rebuildIndex; a missing or stale DB never loses data, only retrieval speed.
  • forget is a tombstone, not a deletion. dsh-fs exposes no delete, so the file stays on disk (marked deleted: true) for audit; nothing physically removes it.
  • Secrets are scrubbed, not encrypted. secret.ts redacts API keys, inner URLs, and credential-shaped strings before write; a value the patterns miss is still written in clear text, so do not remember a full secret and expect protection.
  • Automatic distillation without a model uses the deterministic extractor. extractAtoms splits 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, which distillProvider: ollama turns on. Either way every automatic topic carries tags: [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; staleDays governs 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 (no agent/created fires again); restarting dsh web is the supported way to pick up a new build.
  • The index line is the only structured pointer. searchMemory reads the index for the keyword pass; a hand-edited MEMORY.md that 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.