elephant-memory
shtukbase/elephant-memory
Elephant Memory: your DeepSeek agent never forgets. An endless memory for the DeepSeek harness: the agent keeps every chat it has in a project and reads all of it at every turn through a fixed-size set of summary lines (after Victor Taelin's OptChat speci
Install
dsh plugin --profile web add github:shtukbase/elephant-memoryREADME
Elephant Memory
Your DeepSeek agent can look back across its chats.
Elephant Memory is a plugin for the DeepSeek harness
(dsh). Every chat you have in a folder becomes one long memory: each new turn
starts with a bounded summary of earlier turns, matching source passages and a
dated decision journal. The agent can open any summary line down to the exact
words. Keeping history does not guarantee that a model finds or uses every fact.
Ranked recall and the decision journal
The Endless preset retains the ordinary coding tools and all earlier look-back
tools. It adds recall(question), a ranked search over spoken messages, with
short query expansion on the conversation's own model and account. Expansion
has a six-second deadline; failure leaves the original query usable. It never
searches another project's memory.
Before a turn's first model request, automatic recall can add up to twenty
passages, within a 40,000-byte content budget plus framing. Passages follow the
summary view, not the standing context. Irrelevant messages add no block. Manual
recall freezes its sources before expansion and forwards cancellation to the
model request. Raw recall_search and zoom remain available for tool traces,
which ranked recall deliberately excludes.
The preset also extracts decisions, rules, reversals and open work from finished spoken turns in the background. Four turns, or 16,000 characters of split long messages, form one extraction window. Each saved batch binds its exact source and predecessor; a changed source invalidates its dependent cache. A failed write advances nothing. A restart resumes saved progress. There is no bundled conversation or static research journal.
The request's journal is limited to 16,000 bytes. Its cache can grow with the history. It is derived history, not new instructions or current repository state; it may lag behind incoming turns. Summaries must be ready before a turn starts, but a journal still being written does not hold that turn. Recall-only answers use memory; a request to change files still requires the normal work. The journal is model-written: valid source references do not guarantee its paraphrases or speaker attribution. Look back at exact words when accuracy matters; a source hash alone does not establish semantic support.
For custom hosts, ledger is off until explicitly enabled. The preset enables
ledger, lookBackRules, recall, autoRecall and lineTools. A host can turn
off automatic passages with autoRecall: false, or journal extraction with
ledger: false. recallMost accepts 1–50; ledgerBytes accepts 512–64,000.
No three-step cap or tool-free answer mode is imposed on coding sessions.
It is an independent implementation of Victor Taelin's UniiChat specification (first published as OptChat). Source, issues and releases: https://github.com/Shtukbase/elephant-memory.
Install
The source release is 0.4.0. Install that exact version from its tag; the npm registry version can lag behind the source release:
git clone --branch v0.4.0 --depth 1 https://github.com/Shtukbase/elephant-memory.git elephant-memory
dsh plugin --profile web add ./elephant-memory
The registry command, dsh plugin --profile web add dsh-elephant-memory, installs
the version currently published there, not necessarily this source release.
The plugin supports Node 22 or newer; the acceptance runs used Node 25.2.1.
Use that runtime for the current harness CLI: Node 23.10.0 can exit without
starting it because it lacks import.meta.main. You need pnpm on your PATH (the harness's plugin command
runs it), and harness 0.2.1-alpha.1 or newer, below 0.3
(npm install -g @deepseek-ai/dsh@0.2.1-alpha.1 installs the tested version).
-
Add the plugin to a profile. The web profile is the one
dsh webstarts; the command creates it if it does not exist yet:Use the source-folder command above for this release, or the registry command for its separately published version.
addalso takes a folder that holds this package or a.tgzmade bynpm pack. Because the package ships a bundle patch,addalso turns that patch on. The harness checks the package'speerDependenciesat install and at every start, and refuses a harness outside 0.2.x. -
Start, or restart, the harness:
dsh web. It prints the address of its page; open it in a browser.
Version 0.4.0
The default view is twice the size: viewBytes 128,000 becomes 256,000 and
viewFloorBytes 64,000 becomes 128,000. A long chat then spends longer before
its oldest lines are merged, more of it is read word for word, and the summary
of the same history is read back more often. The measurement behind the change
was a live memory of 544 messages, of which the view showed 173 lines; a memory
already on disk rebuilds to the new ceiling once its view sits under the new
floor.
Version 0.3.0
Ranked recall and automatic source passages complement the existing summary, raw search and zoom tools. Query expansion is bounded and keeps the original query on failure. The Endless preset enables a bounded decision journal with durable progress, restart recovery and source-change invalidation. Ordinary coding tools remain available; the plugin does not set an effort or call cap. Manual recall has readable transcript rows with opening, closing and reload coverage. The supported harness floor is 0.2.1-alpha.1.
Choose the Endless preset
A preset is what the harness calls a mode. The plugin adds one, endless, shown as Endless in the mode list of a new conversation. It is the harness's Creator mode with this memory in place of the usual compaction (compaction is how the harness shortens a long history; this memory replaces it).
- Start a new session. Above the message box, next to the workspace, open the mode picker (it shows Standard mode) and pick Endless before the first message. A session's mode cannot change after its first message, and every new session starts in the default mode again.
- Work as usual. When a turn ends, it is kept word for word and summarized in the background. Every later turn starts from the summary.
To make Endless the default for new sessions, edit
$DSH_HOME/profiles/web/cordis.patch.yml ($DSH_HOME is the harness's home
folder, ~/.dsh unless you set it). The file starts as an empty list, [];
replace that with this list, or add the item to the list already there:
- id: agent-preset-registry
config:
default: endless
Set up a model
The memory uses the chat's own model: whichever model and provider the session runs also write its summaries. When you switch provider or model in the composer, the summaries follow at the next turn: the writer uses the route that turn's own request uses, not the one the chat was created on. The preset's model context window must be at least 122,560 tokens. Summary-only hosts need 65,536; default automatic recall without the journal needs 106,560. These conservative floors reserve the configured additions. Smaller models run as ordinary chats without reading or writing this memory.
Choose only a reasoning effort the adapter actually declares. The research
adapter's medium setting was cheaper than its high setting in the recall
grid. The official DeepSeek adapter declares off, low, high and max,
not medium; this plugin does not invent that level or change the chat's effort.
-
DeepSeek's official API works out of the box: give the harness your key in its page's "Add an API key" dialog, or set
DEEPSEEK_API_KEYbeforedsh web. -
An OpenAI-compatible gateway goes through the harness's
dsh-llm-pi-aiadapter. This example declares OpenCode Go's endpoint (it requires anx-opencode-sessionheader) and makes it the default model. Add the items to the samecordis.patch.ymllist:- id: llm-pi-ai config: providers: opencode-go: displayName: OpenCode Go apiKeyEnv: OPENCODE_GO_API_KEY api: openai-completions baseURL: https://opencode.ai/zen/go/v1 headers: x-opencode-session: my-machine compat: thinkingFormat: deepseek models: - id: deepseek-flash name: DeepSeek Flash contextWindow: 1000000 reasoningEfforts: off: high: high - id: agent-default-model config: provider: opencode-go model: deepseek-flashapiKeyEnvnames a credential, never the key itself: set it in the environment, or in$DSH_HOME/.credentials.yaml(version: 1, thenrefs:withOPENCODE_GO_API_KEY: "<your key>", file mode 0600). The emptyoff:level lets the summary writer turn thinking off (deviation 13). A declared model without it still works: the writer reads the efforts the model declares and asks for the lowest one, or for none when the model declares no reasoning at all, and logs that once. Such a model may think on every summary, which costs output tokens.
How memory is keyed
A memory belongs to one mode in one workspace folder. Every Endless chat in
the same workspace reads and writes the same memory, so a new chat there already
knows the earlier ones and can say which chat something was said in. A chat in
another workspace has a memory of its own. The workspace is the folder shown
next to the mode picker; the web page's first one, Default workspace, is
~/Documents/deepseek-harness/default-workspace.
The memory is plain files under $DSH_HOME/endless/. To forget a memory, delete
its folder there (see Where the files are).
A program that drives the harness (a "host") can bind a chat to its own key, an agent and a project, before the chat's first turn; see For hosts.
History
Open the right sidebar and pick History on its start page, or type /history in the composer. History shows the memory of the current chat's folder, and a picker offers any other memory.
- Conversation is every turn of every chat, grouped by day. The search box finds raw words anywhere in the memory and highlights them.
- Remembers is the list of summary lines the agent starts its next turn with. Each line opens into the two lines it was made from, down to the exact message.
A turn you stop with Esc is kept too: your message, whatever the agent already said (a reply cut short is kept as far as it went), its tool uses and their results so far, then a note that you stopped it. This holds when you stop the turn while it is still waiting for the memory, before the agent has said anything: your message is kept once, and it is not sent again by itself. A turn cut off because the harness stopped running is kept the same way, and ends with a note that it was interrupted.
Looking back
The agent has four tools for its memory:
zoom(id, n)opens one summary line into the two lines it was made from;zoom(id, 1)gives one message whole.date(id)gives the date and time of a message.recall_search(query)searches every message ever written, word for word, for a phrase (any case) or a/regular expression/. It returns up to 20 hits.recall(question)ranks spoken source passages, expands a short query on the chosen route and returns dated words, not a newly generated answer.
In the transcript each call shows as one row, Looked back · «what» · date ·
in «chat», which opens to the answer. A memory turn is not marked "Context
compacted", because nothing was lost; a real /compact in an ordinary chat is
still marked as usual.
Cost and cache
These numbers come from one measured run: 201 scripted turns on 2026-10-08,
model deepseek-flash on DeepSeek's official API, harness 0.2.0-rc.2. The costs
are that run's token counts at DeepSeek's list prices for deepseek-flash, read
on 2026-10-09 from the
pricing page. Per million
tokens at peak: $0.006 for cached input, $0.30 for other input, $1.20 for
output. Off-peak is half of each: $0.003, $0.15, $0.60. Peak is 01:00-04:00 and
06:00-10:00 UTC, Monday to Friday, except Chinese public holidays. DeepSeek can
change these, so read the page again before you rely on them.
| Measure | Result |
|---|---|
| Share of each turn's input read from the provider's cache | median 98.4% (196 ordinary turns, lowest 95.3%) |
| The one turn right after each merge batch (two in the run) | about 35% read from cache |
| Wait before a turn starts (summaries still being written) | median 5.1 s, 90% under 7.5 s |
| Cost of the agent's turns | $0.46 at peak, $0.23 off-peak |
| Cost of writing summaries (3,133 calls) | $1.50 at peak, $0.75 off-peak |
| Cost of one turn with its summaries | 0.98 cents at peak, 0.49 cents off-peak |
| Exact-quote recall questions | 7 of 7 answered word for word |
The summary writer was 76% of the bill. Since that run the writer takes at most
three tries per line instead of five and accepts a line up to 1.25 times the
limit (deviation 12 below), and a retry asks for 0.75 of the limit (deviation 16
below), which should cut its calls. A newer run measured them: 235 scripted
turns on 2026-10-08 with this package as published (0.1.1) in a clean harness
0.2.0-rc.2, the default 128,000-byte ceiling and 64,000-byte floor, and
deepseek-flash through OpenCode Go. OpenCode Go is a subscription, so its
costs are the run's token counts at the same DeepSeek list prices. The writer
made 2,119 calls, 9.0 a turn against 15.6 before, and cost $1.23 at peak
($0.61 off-peak); the agent's turns cost $0.51 ($0.26). One turn with its
summaries cost 0.74 cents at peak and 0.37 cents off-peak, and the writer was
71% of that.
Each summary call also re-reads about 16k cached tokens: the turn's system
prompt and tools, then the compaction view. Since 0.2.0 one call writes up to
six summary lines that are ready at the same time (writer.batch, deviation 20
below). A run measured it on 2026-10-08, 17:51-18:51 UTC. It used the same 235
scripted turns, the same harness, the same 128,000/64,000-byte view and
deepseek-flash through OpenCode Go, priced at the same DeepSeek list prices.
The writer made 1,498 calls, 6.4 a turn against 9.0 with 0.1.1, and cost
$0.995 at peak ($0.50 off-peak) against $1.23. The agent's turns cost $0.47
($0.24). One turn with its summaries cost 0.62 cents at peak and 0.31 cents
off-peak, 16% less than with 0.1.1. The writer trimmed 87 lines against 94, and
all 12 recall checkpoints were answered right in both runs.
A short live check on the same day used this package as published, installed
from its .tgz into a clean harness 0.2.0-rc.2, with deepseek-flash through
OpenCode Go: four Endless chats, six turns. A rule given in the first chat was
recalled, with the chat's name, in the second chat's first turn. After the very
first turn, every turn start read 96.9% to 98.3% of its input from the cache,
except one turn right after the plugin's system prompt was changed. The summary
writer made 22 calls, read 94.4% of their input from the cache, wrote about 145
tokens per call, and trimmed 3 lines.
Configuration
To change a setting, copy the preset-endless row from
presets/endless.patch.yml into your profile's
cordis.patch.yml without its - insert: line, and edit the endless-memory
row. A patch replaces a row's whole config, so keep every key.
| Key | Default | Meaning |
|---|---|---|
agentName | Assistant | The name the summary lines give the agent's replies in a chat nobody bound. A bound chat uses the display name its host gave, or a name read from the agent's address (team/agents/release-captain gives "Release Captain"). No colon, no line break. |
you | you | The word the summary lines use for the person. |
viewBytes | 256000 | The summary's ceiling in bytes: once it passes this, one batch of merges brings it down to viewFloorBytes. |
viewFloorBytes | half of viewBytes | The floor the batch merges down to (128,000 by default). |
lineBytes | 512 | The target size of one summary line, in bytes. |
granularity | turn | turn keeps one trace message per turn; step keeps every tool call and result as its own message (deviation 1). |
writer.model | '' | Another model of the chat's own provider for writing summaries; empty means the chat's model. A writer.provider key is refused. |
writer.batch | 6 | The most summary lines one call writes when several are ready at once (deviation 20). 1 is one line per call, as before 0.2.0. |
writer.reasoningEffort | 'off' | Reasoning effort for summary calls (deviation 13). Empty means off. A level the chat's model does not declare falls back to off, else the lowest it declares, else none, logged once. |
recallSearch | true | Offer the recall_search tool. |
recall | true | Offer ranked recall(question) over spoken source messages. |
autoRecall | true | Search before the first model step; changes the context floor. |
lineTools | true | Retain zoom, date and raw search, including traces. |
recallMost | 20 | Maximum ranked passages, 1–50; each reserves 2,000 bytes. |
lookBackRules | false | Scope direct-answer guidance to recall-only questions; the preset enables it. |
ledger | false | Extract the dated journal in the background; the preset enables it. |
ledgerBytes | 16000 | Request journal budget, 512–64,000 bytes; changes the context floor. |
root | '' | The folder that holds the memories; empty means $DSH_HOME/endless. |
contextKinds | [] | Message kinds of a host's own standing context, kept in front of the summary like the harness's own context messages. |
To give another preset this memory, put this group in its plugins list in
place of its compaction group (a preset has one compaction service):
- id: endless
name: cordis:group
group: true
isolate:
compaction: true
config:
- id: endless-memory
name: dsh-elephant-memory
config:
agentName: Assistant
Limitations
- Small models. Below the configured context floor a turn runs as an ordinary chat, and the log says so once. DeepSeek's official models are far above it.
- It spends tokens. Summaries are written in the background by the chat's
own model on the chat's own provider, never another one. Query expansion and
the journal spend tokens too. Every writer call is
logged and totalled in the memory's
usage.jsonl. - Dynamic additions change the cache suffix. The first turn after a merge batch misses from the first merged line. Journal updates and different recall passages can change later input too. Historical summary-only cache shares above do not guarantee the new preset's share.
- A turn can be refused. If summary lines are still missing after 180 s,
the turn ends with one sentence in the chat's refusal line: "The memory of
earlier messages is not ready yet, so this message will be sent with your
next one." Its messages wait in the chat's queue and go with your next
message; the chat stays usable. This happens the same way when the writer is
slow and when it fails at once on every try. Nothing is sent without the
summary, and nothing goes to another provider. Why it was refused is in the
log (
[ENDLESS_TURN], and the writer's[ENDLESS_WRITER]lines). - Everything is kept. The memory holds every word of every turn, including anything you pasted or a tool printed, as plain files on your disk, and the summaries are written by your chat's provider. There is no "forget this" yet: delete a memory's folder to forget it.
- Some tool output is clipped. A tool result keeps its first and last 15,000 characters, and a turn's whole tool trace its first and last 30,000 (deviation 1). Your messages and the agent's final replies are never cut.
- One process per memory. A second harness process on the same home is refused while the first one holds the memory.
- English only. The look-back row and History are drawn in English.
- Tested with one harness and its web UI. Written and run against
@deepseek-ai/dsh0.2.1-alpha.1 for ranked recall, with earlier measurements on 0.2.0-rc.2, and the web UI thatdsh webserves. The memory itself works in any profile, but the row, the quiet compaction and History are drawn only by the web UI. DSH Desktop loads the same browser bundle but has not been tried. Another harness version may need the checks inSEAMS.md. - Providers. Run on DeepSeek's official API (the 201-turn run) and on
OpenCode Go's OpenAI-compatible endpoint through
dsh-llm-pi-ai(the short live check). The writer asks for reasoning effortoffwhere the model declares it, else for the lowest effort the model declares, else for none (deviation 13). To keep summaries cheap on another provider, declare anofflevel (see Set up a model). - Retrieval and evidence use can fail. Automatic passages are search results, not a guarantee that all required words were found. The agent can still look back manually. The prompt tells it that a summary line is never anyone's exact words and to open the message before quoting, and in the live check it did. Before that sentence was added, the same model once quoted a summary line as the person's own words.
- Two rows. The harness serves a plugin's browser bundle only for a plugin
mounted at the top of the profile, so the patch also adds the row
endless-client. If a later harness reads bundles inside presets and refuses the package twice, setdisabled: trueon that row. /historyis a plain name. If the harness ever ships its own/history, this command steps aside and the sidebar entry stays.- Helper agents get no memory of their own; their look-back tools read the memory of the chat that started them.
How it works
The design and every constant come from Taelin's specification; this is a short tour.
- The log. The harness's own session logs are the history. When a turn ends, its messages are appended to the memory's order of turns, whole, and numbered.
- The tree. In the background, each message becomes a summary line of about 512 bytes. Two neighbouring lines are merged into one, then two of those, and so on: a binary tree. A message short enough to fit is its own line, word for word.
- The view. A list of lines that covers the whole history, recent messages
one per line and older ones many per line. It grows by one line per message.
Once it passes 256 KB, one batch merges the most overdue pairs until it is
back to 128 KB. A pair's "overdue" score is how long ago it ended, measured in
its own size:
(T - last) / 2^l. This reproduces the merge order of Taelin's rollback list. - Each turn starts fresh. Before a turn's first step, the conversation so far is replaced by a message holding the view and optional journal, followed by matching recalled passages and the new message. The view changes only at its end between batches. Dynamic additions follow that stable prefix; their cache share depends on the provider and run.
- Summaries in order. Up to 8 summary calls run at once. A message's line starts once fewer than 8 lines before it are unwritten; a merge starts once both halves exist. Each call sends the chat's own system prompt and tools, then a smaller view (16-32 KB) for context, then the task with a ruler of 512 dashes, because models cannot count bytes. A chat's first turn has no system prompt yet while it waits (the harness writes it after the wait), so its calls send those of another chat of the same memory. Only a memory with no other open chat, such as the first chat after a restart, sends the plugin's own section and no tools for those first calls; that is the same on every provider.
How it attaches to the harness, with file and line references, is in
SEAMS.md. TRY.md is a recipe for trying it in an
isolated harness home.
Thank you, Victor. The log, the tree, the build order, the merge rule, the prompts and the constants are from Taelin's specification as rewritten on 2026-10-08. This package is one implementation of that idea.
Deviations from the specification
Each one has a reason the specification does not already answer.
- Granularity
turn(default). The specification keeps every tool call and result as its own message (stephere, still available).turnkeeps, per turn, each message of the person, one trace message with the turn's tool calls and results, and the final reply. Why: far fewer summary calls, and more turns at one line each in the same view. Cost: a trace keeps the first and last 30,000 characters of a very long turn (each result already clipped to 30,000), so the middle of a turn with several huge tool outputs is cut. Instepmode a tool call is one message however long it is. recall_search. A raw search over the whole history. Why: an item a summary dropped can still be found. A/pattern/search stops after 2 s, because a pattern can take exponential time.- The view is its own message. The specification sends the view and the new message as one user message. Here the view is a message placed before the new one, because the harness builds every request from its session log. The harness's context messages (skill list, sandbox facts, workspace instructions) stay in front of the view, so they are read from the cache too.
- Plain tags. Lines tag items "you:", the agent's name, "did:", "result:", "colleague:" and "note:" instead of user, unii, tool, echo, work and note.
- No second log, and a lock file. The history is the harness's session logs; the plugin keeps only the order of finished turns and the tree. One writing process per memory is guaranteed by a lock file.
- No
spawnortell. Delegation uses the harness's own tools, andzoom("Name")is not offered. - A refused turn re-queues its messages and ends as a failure. The harness drops a refused step's messages otherwise. The turn ends as a failure with one plain sentence rather than as a rejected step, because the web UI draws a rejected step as a finished turn with nothing said and keeps the person's message drawn as still starting. The re-queued messages carry no submission id, so the web UI settles its pending copy; they are delivered with the next message, and nothing starts a turn by itself.
- No cache marks. DeepSeek caches by prefix without marks. Providers that need explicit marks (Anthropic) get none from this version.
- The view is logged once per turn, inside the harness's session log. That
is the price of the harness seam (
SEAMS.md). - Many chats, one memory. The specification has one conversation. Here every chat in a folder joins one memory, a whole turn at a time, and each turn's first message is marked with its chat's title ("in «title»").
- The writer's prompt and the "Compaction:" marker. The summary instructions live in the plugin's system prompt section, which turns and summary calls share, as the specification asks. A 201-turn run once answered a person's short message as if it were a summary task, so the section says a call is a summary task only when its last message starts with "Compaction:". Both tasks add a sentence to write in the language of the messages and one to keep the person's rules (deviation 18). The merge task adds another: use only facts present in the lines and their messages, because an earlier prompt's sample line leaked invented facts into real memories.
- Three tries, then a trim; a 1.25× tolerance. The specification allows five tries. In the 201-turn run the writer averaged 2.65 calls per line, mostly "Too long" retries, and 325 of 1,182 lines were still over 512 bytes after five. Here a line up to 1.25 times the limit (640 bytes at 512) is accepted with no retry and no cut. A line gets three tries; if none fits 1.25 times the limit, the shortest is cut at a word boundary and ends in "…", and the log counts it. A retry asks for less than the limit (deviation 16). The view's budgets are in bytes, so longer lines only mean fewer of them.
- Writer reasoning effort
off. The specification uses a cheap model at high effort. On DeepSeek, effort "high" (the adapter's default) produced a median 8,800 output tokens per 512-byte line and turns waiting two minutes, so the writer asks foroffunlesswriter.reasoningEffortsays otherwise. It asks only for an effort the chat's model declares (the harness's model catalog,resolveModelInfo): where the wanted one is not declared it takesoff, else the lowest declared, else names none, and logs that once per model. Before this, a route whose model declared nooffrefused every summary before it left, and the memory stopped at the first long message. - The language guard. In the 201-turn run 6% of the lines were written in Chinese from sources with no Chinese text. A try that uses a Chinese, Japanese or Korean script its sources do not use is rejected with "Wrong language" feedback; with no valid try, the line fails and is tried again.
- Retries every 10 s. The specification retries a failed call at the next message. Here a failed line is tried again every 10 s, because the next turn waits for it. A turn waits at most 180 s.
- A "Too long" retry asks for 0.75 of the limit. The specification's retry
cuts the line at the limit. In a 235-turn run on
deepseek-flash, 66% of the new summary lines were over 512 bytes on their first try, and 29% fit in no try even at 640 bytes, so they were cut with "…": retries aimed at 512 did not shrink enough. Here a try past 640 bytes gets the same message with the limit and the cut mark moved to 384 bytes, 0.75 of 512 (RETRY_TARGETinlib/config.js); the first try still gets the full limit and its ruler. Why 0.75: a model overshoots a stated length by up to about a quarter (the 90th percentile was 610 bytes at 512 in the 201-turn run), and aiming at 384 lets a retry overshoot by up to 1.67 times and still land under 640. A smaller factor would cost more of every retried line (0.5 halves it). The factor comes from those numbers and has not been measured over a long run yet. The message calls 384 "the limit", which is how the model is steered below the real one. - Heads are stripped from every line of a try. A summary call shows each
line as
id+n|text, and a model sometimes copies the head. The specification does not say what to do. Here every try loses a head at the start of each of its lines before it is measured or stored. The first version stripped only the start of the reply, and a merge reply that copied both of its input lines stored625+1|on its second line (1 of 214 new lines in the 235-turn run). A head in the middle of a line is left alone, because it cannot be told from the line's own words. - Both tasks ask to keep what the person states. Each task adds: "Keep every
rule, instruction, decision or preference the person states, word for word
where short, before any other detail." The shared instructions already rank the
person's own words first (
lib/system-section.js), but the task, which comes last in the conversation, says nothing of it. A real run (round 5 of the live walk, 2026-10-08) compressed a long first message, a rule ("release notes list only what ships") followed by a ten-item checklist, into a line that held the checklist and dropped the rule; the rule survived only in the agent's own memory-write line. The sentence sits after the language sentence and before the input (lib/prompts.js,KEEP). It asks and does not guarantee: the line limit still applies, so a very long rule can be shortened. - The writer reads look-backs in words. The order file keeps a turn's trace
as the harness gave it, and for the memory's own look-back tools that is
machine form:
did: zoom {"id":0,"n":1}, a line head0+0|, a search'sid|kind|date|snippethits. A writer told to tag items in plain words copied it into real summary lines ("Looked back id 0 n 1", "message 0", "(1, 7, 8)"; round 5 of the live walk). The writer, and a short trace's own line in the view, now readdid: looked back at «subject» in «chat»(a date asdid: looked back at when a message was written: …; nothing found as… and found nothing), made bylib/look-back-words.jsfrom the stored trace, which is not changed:zoom(id, 1)still gives it whole,recall_searchstill searches it, and the History view still reads it. Every tool call, these too, keeps the one tagdid:. Granularityturnonly: astepentry holds a call without its answer. Other tools' own answers are not touched, so an id the harness's own tool prints (undo-1from the harness'smemory_add) can still reach a line. - Several lines per summary call. The
specification writes one line per call. Every call re-reads the turn's
system prompt and tools and the compaction view, about 16k cached tokens, so
in a 235-turn run the writer was 74% of the bill at 1.97 calls per tree
node. Here (
writer.batch, 6 by default) the nodes the queues make ready at the same moment share one call, up to that many. Usually these are a turn's messages, or the merges one stored call made ready. Only ready nodes go in, so the build order, the 8-unbuilt rule and the views do not change. A node ready alone still gets the one-line call. The call opens with "Compaction:" and the jobs it holds ("compress message 5; merge lines 0+2 and 2+2"), then each job's own task, whole and numbered: its ruler, the language and keep sentences and its<input>. Each job aims at 0.75 of the limit (384 bytes at 512), in its ruler, its "at most" and its reply tag (<line id="n">job n's line, at most 384 bytes</line>). A line is judged alone, exactly as a one-line try: heads stripped, the CJK guard, and the 1.25× tolerance of the true limit. A line that fits is stored at once. A line too long, in an invented script or missing is asked for again alone, in the same conversation, with the per-line feedback ("Too long" with its cut mark at 0.75 of the limit, "Wrong language", or "Missing"). After three tries in all, the shortest valid try is trimmed, as for one line. A call that fails fails each of its lines, and they come back together after 10 s. The context is the longest of the jobs' own contexts, so a merge can see built lines after its own messages; its task still says to use only the facts of its lines and messages. The shared system section still says to output only the line; the batch task, which comes last, asks for the tags. The first design (2026-10-08) asked first tries for the full limit and carried a retry into a new call that did not hold the model's earlier line. In a live run 81% of first tries and 64% of retries came back over 640 bytes, and it trimmed 31 lines where one line per call trimmed 6, so it was stopped at turn 27. With the aim at 0.75 and the retry in the same conversation, a run of all 235 scripted turns on the same day measured these numbers (deepseek-flashthrough OpenCode Go, DeepSeek list prices at peak of $0.006 / $0.30 / $1.20 per million cached input, other input and output tokens):- 2.5 lines per batched call.
- 30% of first tries over 640 bytes, and 76% of retries then fitting.
- 1,498 writer calls against 2,119 for 0.1.1 on the same script.
- 87 trimmed lines against 94.
- A 19% cheaper writer and a 16% cheaper run.
- The same recall: 12 of 12 checkpoints.
- Lines keep decision dates, and the repository beats memory. Lite run 1
(2026-10-09) showed two losses. A decision's date, said in the message
("confirmed this morning, March 3rd"), was dropped from its line, so "when
did we decide" had no answer. And the agent acted on a remembered ruling
that a teammate's later commit had changed. Both tasks now add, after the
keep sentence: "Keep each decision, rule or change with its date when the
messages give one; when lines merge, keep the date of every decision they
hold, or, if space runs short, the latest one, marked "latest"."
(
lib/prompts.js,DATES). The shared instructions copy dates exactly too. The line limit is unchanged, and no code edits a line. The system section adds: before acting on a remembered decision or a remembered fact about files, checkgit logandgit status; if the repository disagrees with memory, the repository wins, and the agent says so (lib/system-section.js).
For hosts
A program on the same computer can read and bind memories through six methods,
served by the harness's web server as POST /api/endless.<name> in the
harness's Remote envelope: {type: 'client-request', rpcId, method, payload: {args}} answered by {type: 'server-response', rpcId, result}. Only callers on
this computer are answered, and a request with an Origin header (which every
browser page sends) is refused. Times are ISO 8601.
| Method | Arguments | Answer |
|---|---|---|
endless.bind | sessionId, agent, project, agentName? | {memoryId} |
endless.memories | agent? | {memories: [{memoryId, agent, project, turns, firstAt, lastAt, writerUsage}]} |
endless.view | memoryId | {lines: [{id, n, text, from, to}]}; text is null while a line is not written |
endless.zoom | memoryId, id, n | {lines} (the two halves), or for n = 1 {message: {id, kind, text, at, sessionId, sessionTitle}} |
endless.history | memoryId, before?, limit? ≤ 200 | {turns: [{at, sessionId, sessionTitle, entries: [{id, kind, text}]}], next?}; pages backwards, oldest first inside a page, and a page stops before 2 MB of text |
endless.search | memoryId, query, limit? ≤ 50 | {hits: [{id, at, sessionTitle, kind, snippet}]}, newest first, raw words, any case |
kind is user, trace, reply or work. writerUsage is {calls, cacheRead, miss, output}. A refusal is {ok: false, error: {code, message}}
with the code endless/bad-request, endless/unknown-memory, endless/no-line
or endless/already-bound (the chat's turns are already in another memory).
The harness's own page reads the five read methods (not bind) through
POST /api/endless-ui.<name>, behind the harness's own Host and Origin check
and its signed browser cookie.
Where the files are
<root> is $DSH_HOME/endless unless root says otherwise. memoryId is the
first 16 hex digits of sha256(agent + "\n" + project); for a chat nobody bound,
agent is the id of the mode it runs (endless) and project its workspace
folder.
| What | Where |
|---|---|
| The memory's key | <root>/memories/<memoryId>/key.json |
| Finished turns, in order | <root>/memories/<memoryId>/order-turn-v1.jsonl |
| Summary lines | <root>/memories/<memoryId>/tree-turn-v1/YYYY-MM-DD.jsonl |
| The view and the writer's view | view-turn-v1.json, compaction-view-turn-v1.json in the same folder |
| Writer spend | usage.jsonl in the same folder, one line per call |
| One-writer lock | lock in the same folder |
| A bound chat | <root>/sessions/<session>.json |
Every line is written with one write and fsync. A torn last line is skipped
at load. Nothing is edited or deleted. The view is saved after every change and
loaded at start, never rebuilt while its file is usable, because a rebuilt view
would differ and lose the cache. With granularity: step the file names say
step instead of turn.
Contributing
Issues and pull requests are welcome at https://github.com/Shtukbase/elephant-memory.
The package has no dependencies of its own. Its tests import the harness's packages, which are peers, so install them first:
npm install --no-save @deepseek-ai/dsh-compaction@0.2.0-rc.2 @deepseek-ai/dsh-home-paths@0.2.0-rc.2 \
@deepseek-ai/dsh-llm@0.2.0-rc.2 @deepseek-ai/dsh-tools@0.2.0-rc.2 @deepseek-ai/schemastery@^3.18.4
node scripts/build-client.js --check
node --test --test-timeout=20000 --test-force-exit test/*.test.js
The browser half is built: client/ holds its sources, and
node scripts/build-client.js writes lib/client.js (it also runs on
npm pack). Commit both. The tests run the real lib/client.js against a fake
of the harness's client, and the engine against a fake of the harness's session
and context owners. A real harness is needed to see anything drawn; see
TRY.md. When the harness changes version, walk the recheck list at
the end of SEAMS.md.
Changelog
Unreleased
- Summary lines keep the date of each decision, and a merge keeps the dates of both its lines, or the latest, marked.
- Before acting on a remembered decision or fact about files, the agent checks
git logandgit status; the repository wins over memory.
0.2.0
- One summary call writes up to six summary lines that are ready at the same
time (
writer.batch, default 6). Each line aims at 0.75 of the limit and is judged alone. A line that does not fit is asked again in the same conversation. Over 235 scripted turns ondeepseek-flashthe writer made 29% fewer calls and trimmed fewer lines, and a turn with its summaries cost 16% less, with the same recall. Setwriter.batch: 1for one line per call.
0.1.1
- A turn stopped with Esc is saved to memory: the person's message, what the agent already said, its tool uses with their results so far, and a closing note, "stopped by the person". Before, a turn stopped while it waited for the memory lost the person's message and was not saved at all, and a turn stopped later was saved without saying it had been stopped.
- A turn cut off because the harness stopped running keeps the person's message the same way, and ends with the note "interrupted".
0.1.0
- First release.
License
MIT. See LICENSE.