dsh-note
Long-term markdown memory for DeepSeek Harness (dsh) agents — remember, so you don't re-send.
dsh-note is the coding agent's durable memory, written in TypeScript with
zero runtime dependencies. Instead of carrying a long conversation (and
re-paying tokens for it every turn), the agent keeps the important parts in
local markdown files and recalls only the small relevant chunk later. Context
stays small and on-demand, which saves tokens over a session.
dsh-note itself is token-free — that is its whole value. Every tool below
is plain local file work (free). Tokens are only spent when the model is
actually asked to think; the note layer just makes that thinking small:
search, tail-reads and bounded context packets instead of whole-file dumps.
Two zones, never mixed
Storage is split into two directories under the session workspace, so article
writing and long-term memory never get confused (each tool accepts a dir
override):
| Zone | Default dir | What lives there |
|---|
writing | ./notes | working notes, drafts, articles, session logs |
memory | ./memory | the long-term memory bank: per-topic files of timestamped entries |
Memory bank files are topic files (e.g. decisions.md) whose body is a list
of entries headed ## <ISO timestamp> [title]. Files can nest inside a zone
(e.g. log/today.md); names can never escape the zone root, and a new note
cannot be named with a leading dot in any folder (.draft.md, .cache/n.md) —
every listing and search skips dot files, so it would be invisible. Files or
subdirectories the process cannot read are skipped, so one bad permission
never fails a whole listing, recall or search. Reading a zone never creates
it — a missing folder simply reads as empty, and only a write makes one.
Easy front matter
Memory files (and any note) can start with a simple --- block:
---
title: decisions
tags: tooling, workflow
type: decision
created: 2025-01-03T08:00:00.000Z
updated: 2025-01-05T12:30:00.000Z
summary: compact one-paragraph digest (model-written, on request)
---
## 2025-01-05T12:30:00.000Z — merges
decided: use merge commits on paired PRs
created/updated timestamps are maintained for free on every write.
summary is optional and is what compact recalls show first.
aliases (or the legacy alias) is a comma separated list of other names a
note answers to, used when resolving links (see note_links).
type: archive marks compaction archives, which recalls/search skip.
- Unknown keys are preserved on rewrite; values are plain
key: value lines.
- YAML lists work too, the way Obsidian and basic-memory write tags and
aliases: a block list (
tags: then - coffee lines) under any key, and the
flow form (tags: [coffee, brewing]) for tags/aliases/alias. They read
as the same comma list and are written back in the style they came in.
- A leading
--- block is front matter only when it holds at least one
key: value line. A note that opens with a --- rule keeps it as body text
instead of having the block read (and then deleted) as metadata.
Tools
| Tool | What it does | Cost |
|---|
note_remember | Append a snippet to a writing note (create if missing) | free |
note_recall | Read a note back — full, tail, compact, one section, or outline | free |
note_write | Fully replace a note's body (updates, rewrites, reordering) | free |
note_edit | In-place body edits — literal text, or one section by its heading | free |
note_list | List a zone's notes; filter by tags / type / changed since, newest first | free |
note_search | Free keyword search across writing/memory/all zones | free |
note_forget | Move a note to the zone's .trash/ (restorable), or delete permanently | free |
note_stats | Size up a zone: files, archives, entries, bytes, largest file, tags | free |
note_map | Outline a zone: file lines + newest entry headings, under a budget | free |
note_links | Follow links between notes: out, back, broken, orphans, islands | free |
note_rename | Rename/move a note and rewrite the links that pointed at it | free |
note_context | Assemble the small bounded context around a focus (tail-first) | free |
memory_add | Append a timestamped entry to the long-term bank | free |
memory_recall | Recall the recent/relevant small chunk (query/tags/type/date filters) | free |
memory_update | Merge front matter (summary/tags/…) and/or append an entry | free |
memory_compact |
The actual reasoning features — summarizing the context above, inferring
what comes next, extracting key information, putting a draft in order — are
agent workflows that combine these free tools with one deliberate thinking
step. See docs/workflows.md for ready-to-use recipes.
How it saves tokens
- Small recall:
note_recall with tail, and memory_recall which
returns newest-first entries bounded by limit + a chars budget — never
the whole history.
- Free search:
note_search is plain local keyword search with snippets,
so the model can find the right file without scanning everything itself.
- Compact fallback:
note_recall with compact returns the summary
field plus the recent tail. memory_compact keeps the active bank small by
moving old entries into archives. When memory processing would be too long,
grab the compact version instead.
- Edit, not just append:
note_write/note_edit let the model rewrite a
note in place — only the text it actually thinks about changes.
- Forget the rest:
note_forget/memory_remove keep stored context
small, so future prompts stay lean.
- Size before recall:
note_stats reports how many files, entries and
bytes a zone holds — free — so the model can pick a recall budget instead of
guessing.
- Map before recall:
note_map returns the outline — file lines, the
newest entry headings and the zone's link structure — so the model can pick
the right file without paying for entry bodies at all.
- Follow, don't search:
note_links walks the links a note already has, so
related context costs one hop instead of a keyword scan — and orphans
shows which notes nothing reaches.
Installation
The package ships from this repository rather than npm, so install it by git
specifier:
# CLI profiles (web, tui, …) — the CLI forwards the specifier to pnpm
dsh plugin --profile web add github:jingchangzhao-gif/dsh-note
lib/ is generated and git-ignored, so the install builds it through the
package's prepare script. pnpm blocks dependency build scripts until they are
allowed: dsh prints the exact key to put under allowBuilds in the profile's
pnpm-workspace.yaml, then re-run the command.
Prefer to build it yourself? Clone, build, and add the checkout path instead —
that also gets you the launchers and a CLI you can run without dsh at all:
git clone https://github.com/jingchangzhao-gif/dsh-note.git
cd dsh-note
pnpm install
pnpm build
dsh plugin --profile web add "$PWD" # the checkout path, e.g. D:\dsh-note on Windows
The packed tarball carries lib/ (the plugin), cli.mjs (the CLI),
cordis.patch.yml (bundle registration) and the docs; scripts/check-pack.mjs
asserts exactly that in CI, because a git-ignored build directory is easy to
forget. The checkout is also useful on its own: node cli.mjs help,
./run.command on macOS, run.bat on Windows.
The desktop app's profile is managed by the app itself: add
github:jingchangzhao-gif/dsh-note from the GUI's plugin manager.
dsh plugin --profile desktop … is refused — the CLI does not manage that
profile.
Harness compatibility
dsh refuses to load a plugin whose @deepseek-ai/dsh* peer range does not
satisfy the running runtime, comparing the runtime version with prereleases
included, so the declared peers are ranges over the 0.1 line:
| Peer | Range | Used for |
|---|
@deepseek-ai/dsh-tools | >=0.1.0-rc.8 <0.2.0-0 | defineTool, ctx.tools.register, exec.agent.session.header.cwd |
@deepseek-ai/cordis | ^4.0.1 | the plugin entry (name / inject / apply) |
Verified against dsh 0.1.0-rc.8, 0.1.5-rc.3 and 0.1.7-rc.2. pnpm test includes
tests/compatibility.test.ts, which fails if the range stops accepting the
@deepseek-ai/dsh-tools the suite installs.
Tool reference
These are the dsh plugin tool calls; outside the GUI every one of them has a
direct CLI equivalent (see Usage),
and docs/workflows.md shows the reasoning workflows in both forms.
note_remember
{ "name": "session", "content": "Decided: use pnpm + merge commits on paired PRs." }
Creates the note if missing, otherwise appends after a --- separator.
note_recall
{ "name": "session", "tail": 2000 }
Returns { name, content, truncated }. With tail, only the last N
characters; with compact: true, the summary field plus the recent ~800
chars.
For a long structured note, read the map first and then only the part you need:
{ "name": "plan.md", "outline": true }
{ "name": "plan.md", "section": "Setup" }
outline returns the headings, indented by level, each with its section size
( ## Setup (240 chars)). section returns that heading and everything under
it up to the next heading of the same or a higher level. Headings match loosely
(case, spacing and leading #s don't matter). A missing heading names the ones
that exist, and a heading that appears twice is refused rather than guessed.
Use at most one of tail, compact, section, outline.
note_write
{ "name": "article.md", "content": "# New order\n\n…", "tags": "draft" }
Replaces the whole body; front matter keys given merge in, others are kept.
When writing a new note, pass "createOnly": true: if a note of that name
already exists the write is refused and the existing note left untouched,
instead of being replaced and reported as created: false afterwards.
note_edit
{ "name": "session.md", "old": "pnpm install", "new": "pnpm i", "all": true }
Replaces literal text in the body (never inside front matter).
Or rewrite one section by its heading, without quoting its old text:
{ "name": "plan.md", "section": "Setup", "new": "pnpm 10", "mode": "replace" }
mode is replace (the default: everything under the heading, subsections
included, which is what note_recall's section returns), append (at the end
of the section) or prepend (right under the heading). The heading must match
exactly one heading in the note; old/all cannot be combined with section.
new is required here — a missing one would wipe the section — so pass ""
explicitly to clear it.
note_list
{ "zone": "memory" }
Lists notes with title/type/tags; archives are hidden from the memory view.
Narrow it for free, e.g. "what changed since the last session?":
{ "since": "2025-01-05T09:00:00Z", "sort": "recent", "limit": 10 }
{ "tags": "work, urgent", "type": "plan" }
tags matches notes carrying any of them (any case), type is exact, since
keeps notes modified at/after a date, and sort: "recent" puts the newest
first. "Modified" is the file's modification time, so an edit made in another
editor counts; those entries then carry modified. A plain listing is
unchanged.
note_search
{ "query": "pnpm merge", "zone": "all" }
Returns ranked hits { name, title, snippet, score }. Zero tokens.
note_forget
{ "name": "session.md", "zone": "writing" }
Moves the note to the zone's .trash/ folder (the name Obsidian uses), keeping
its relative path — log/today.md goes to .trash/log/today.md, or
.trash/log/today-2.md if that is taken. Dot folders are skipped by every
listing, search and stat, so the note is gone from view, and restoring it is
moving the file back. "permanent": true deletes it for good instead.
note_stats
{ "zone": "memory" }
Read-only inventory of a zone: live files, archives, memory entries,
total bytes, the largest file (name + size), and the zone's tags with how
many live notes carry each, most used first (the top 20, plus moreTags for
the rest). Nothing is returned to the model beyond these numbers and names, so
it costs no content tokens — and the tag list tells it what to filter
note_list and memory_recall by.
note_map
{ "zone": "memory", "chars": 1200 }
Free outline of a zone: one line per file (name — title (entries, bytes))
plus each file's newest entry headings, newest first, truncated to chars.
Entry headings usually carry the gist (2024-06-01 — use pnpm), so a map is
often enough to decide which file to recall without paying for entry bodies.
total counts the files in the zone, omitted the ones the budget left out.
The outline also carries the zone's link structure — links, broken and
orphans — rendered as a trailing links: 3 (1 broken) / orphans: … line, so
one cheap call answers both "what is here" and "what relates to what". A zone
with no links between notes says nothing extra (see note_links
for the details of one note).
note_links
{ "name": "session.md" }
Free link graph. With a name: what that note points at (out), what points
back at it (back), and its dangling targets (broken). Without one: the
zone's links count, its orphans (nothing links to or from them), the linked
islands cut off from the biggest cluster, and every broken target.
Both markdown links ([label](decisions.md), and <…> for names with spaces)
and wikilinks ([[decisions]], optionally [[target|label]]) count. A target
resolves the way the note-taking ecosystem resolves links: exact zone-relative
path, then +".md", then a unique file basename, case-insensitively and
shortest path first — so [[decisions]] finds decisions.md and [[today]]
finds log/today.md. Failing all that, a bare name may match a front matter
aliases: entry (aliases: adr, choices lets [[adr]] reach the note), which
can never shadow a real file name. A name with two equally good candidates
stays unresolved rather than guessing.
Not links between notes, so ignored: URLs, in-page anchors, absolute paths,
image and media targets,  embeds, links inside fenced code blocks or
inline `code` spans, and a note linking to itself (which would only hide
that nothing reaches it).
note_rename
{ "from": "session.md", "to": "log/session.md" }
Moves the note and rewrites every link that resolved to it, so the graph does
not break. Wikilinks keep their style (no extension), markdown links keep
their extension, and #anchors, |labels and link titles survive. Links that
reached the note through an alias are rewritten too.
dryRun: true returns the plan — which notes would change and how many links —
without touching a file. A target name that already exists is refused rather
than overwritten, and renaming a note to its own name is a no-op.
note_context
{ "focus": "what should I do next?", "notes": ["session.md"] }
Returns one bounded context packet: recent tails of the named notes, search
snippets for the focus, and the recent/relevant memory tail. Older parts are
dropped first when the chars budget (default 6000) is tight.
memory_add
{ "content": "user prefers dark mode", "tags": "prefs", "type": "preference" }
Appends a ## <ISO timestamp> entry to memory.md (or name when given).
memory_recall
{ "query": "dark mode", "limit": 5, "chars": 2000 }
Newest-first, bounded. Supports name, tags, type, newerThan,
olderThan. Each matching file leads with its summary front matter field
when set, then its entries. Without filters it returns just the recent tail of
the bank.
memory_update
{ "name": "decisions.md", "meta": { "summary": "digest: …" } }
Merges front matter fields; content appends an entry.
memory_compact
{ "name": "decisions.md", "keep": 20 }
Keeps the 20 newest entries, moves the rest to decisions.archive.md
(type: archive, invisible to recalls and search).
memory_remove
{ "name": "decisions.md", "match": "superseded idea" }
Usage — run.bat / run.command: point it at a folder, then operate
The launcher is the primary way to use dsh-note. One logic file (cli.mjs)
runs on Windows and macOS alike; run.bat and run.command are thin
shells that just forward every argument — so you give the tool a concrete
folder address plus an operation, and the free file work happens locally.
No model, no network, no tokens.
Build once before first use (cli.mjs drives the built lib/):
pnpm build
Basic shape:
run.bat <command> [dir] [--flags...]
<dir> is the folder the command operates on. Plain commands expect the
notes folder; memory-* commands expect the memory-bank folder. When
omitted, plain commands default to ./notes and memory-* to ./memory.
- Run
run.bat help (or run.bat --help) for the full reference.
Windows:
run.bat list "C:\Users\me\notes"
run.bat remember "C:\Users\me\notes" --name session.md --content "decided: pnpm"
run.bat recall "C:\Users\me\notes" --name session.md --tail 2000
run.bat write "C:\Users\me\notes" --name article.md --file new-draft.md --title "Article"
run.bat search "C:\Users\me\notes" --query "pnpm merge"
run.bat memory-add "D:\memory" --content "user prefers dark mode" --tags prefs --type preference
run.bat memory-recall "D:\memory" --query "dark mode"
run.bat memory-compact "D:\memory" --name decisions.md --keep 20
macOS is identical with ./run.command instead of run.bat.
| Command | What it does |
|---|
list [dir] [--zone …] [--tags] [--type] [--since] [--sort recent] [--limit] | List notes, optionally filtered and newest first |
remember <dir?> --name f [--content | --file | -] | Append a block (create if missing) |
recall <dir?> --name f [--tail N | --compact | --section h | --outline] | Full text, tail, compact view, one section or the outline |
write <dir?> --name f (--content | --file) [--title/--tags/--type] [--create-only] | Fully replace the body (or only create) |
edit <dir?> --name f (--old x [--all] | --section h [--mode m]) [--new y] | Literal body edit, or rewrite one section |
search <dir?> --query words [--limit N] [--zone writing|memory] | Free keyword search with snippets |
forget <dir?> --name f [--permanent] | Move a note to .trash/ (or delete it for good) |
stats [dir] [--zone writing|memory] | Zone inventory: files, archives, entries, bytes, tags |
map [dir] [--chars N] [--zone writing|memory] | Outline a zone: file lines + newest headings |
mindmap [dir] [--chars N] [--zone ...] [--file out.md] | The outline as a Mermaid mind map (renders) |
links [dir] [--name <note>] [--zone writing|memory] | Follow links: out, back, broken, orphans |
linkmap [dir] [--zone ...] [--max N] [--file out.md] | The link graph as a Mermaid flowchart |
rename [dir] --from <old> --to <new> [--dry-run] | Move a note and rewrite the links into it |
export <dir?> --file f.json | Snapshot the whole folder into one JSON file |
import <dir?> --file f.json [--force] | Write a snapshot back (skips existing files) |
context <dir?> [--focus q] [--notes a,b] [--memory <dir>] | Assemble a small bounded context packet |
memory-add <dir?> (--content | --file) [--name] [--tags] [--type] | Append a timestamped bank entry |
|
Notes:
- Append
--json to any command to print the structured result.
- A flag the command does not take is refused (
unknown flag --…) rather than
ignored, so a typo cannot quietly run the command on its defaults.
--zone memory makes list and search work on ./memory instead of
./notes and hides archives, the same rule note_list/note_search apply;
a dir argument still points them at any other folder.
run.bat <dir> with a single bare argument keeps the legacy launcher form
(list that directory).
- A note file name typed alone is the note in the default folder:
recall session.md is recall ./notes --name session.md (unless a folder
called session.md exists).
- Quote multi-word values. For Chinese or multiline content, pass
--file <path> (UTF-8) or --content - and pipe stdin. "$@" on macOS/Linux
forwards arguments byte-exactly; on Windows %* is re-parsed by cmd (%,
! and ^ are special), so prefer --file or stdin there for tricky text.
linkmap draws the graph as a Mermaid flowchart: hubs first, orphans dashed
and island members outlined, so the picture says what links reports.
--max N caps the nodes drawn (default 40, ceiling 500).
export/import move a whole folder as one JSON file (backup, another
machine, another workspace). import skips files that already exist unless
--force is passed, so a restore can never silently clobber newer notes.
These two are CLI-only — the agent tools have no bundle equivalent.
- The same package also works as a dsh plugin inside the DeepSeek Harness
GUI (
dsh plugin --profile web add github:jingchangzhao-gif/dsh-note, or the
GUI's plugin manager for the desktop profile); its 17 tools cover the note
and memory commands above.
Development
pnpm install
pnpm format:check
pnpm typecheck
pnpm build # build first: the CLI and its end-to-end tests run against lib/
pnpm test
pnpm coverage # same suite with a v8 report and a regression threshold
pnpm lint
CI runs the full check on Linux, macOS and Windows (the CLI ships run.bat
and run.command). Node 22 is pinned because the build toolchain requires it
— tsdown needs ^22.18 || >=24 and rolldown/oxlint need
^20.19 || >=22.12; the engines.node >=18 floor describes the published
lib/ output, not the dev toolchain.
License
MIT