@momenttttt/pi-zcode-memory
Share ZCode's per-project memory directory from pi (memory_read / memory_write / memory_list)
Package details
Install @momenttttt/pi-zcode-memory from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@momenttttt/pi-zcode-memory- Package
@momenttttt/pi-zcode-memory- Version
0.1.1- Published
- Oct 18, 2026
- Downloads
- 297/mo · 41/wk
- Author
- momenttttt
- License
- MIT
- Types
- extension
- Size
- 98.7 KB
- Dependencies
- 1 dependency · 2 peers
Pi manifest JSON
{
"extensions": [
"./extensions/zcode-memory.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
@momenttttt/pi-zcode-memory
One project memory shared between pi and ZCode.
- Three tools:
memory_list,memory_read,memory_write - Only the
MEMORY.mdindex is injected, once, at session start; bodies are never injected automatically and are read on demand - The directory algorithm, the index format and the fact-file frontmatter all follow ZCode item by item, so both sides read and write the same files
/memoryshows the status and checks the index;/memory reloadforces the next run to inject again
Install
pi install npm:@momenttttt/pi-zcode-memory
To try this package without installing it:
pi -e ./packages/pi-zcode-memory/extensions/zcode-memory.ts
How the memory directory is located
Computed with the shared memory directory's path algorithm:
~/.zcode -> append cli (not appended when the basename already is cli)
<storageRoot>/memories/projects/<slug>-<hash16>/memory/
hash16 = sha256(win32 ? resolve(cwd).toLowerCase() : resolve(cwd)).slice(0, 16)
slug = lowercase, non [a-z0-9._-] to -, trim leading/trailing -, cut to 48, "project" when empty
At runtime there are three tiers of fallback:
dirfrom the configuration (highest priority; for when the algorithm does not match, e.g. a workspace identity or a different spelling of the path)- The path the algorithm computes, when it exists
- When it does not exist: exactly one
<slug>-*directory undermemories/projects/is used, with a warning; otherwise the project is treated as having no ZCode memory yet (no directory is created;memory_writecreates it under the computed path when it is actually needed)
A directory carrying the project- prefix triggers a note: ZCode may have used a workspace identity (slug fixed to project, hash input not a path). Standalone CLI sessions and ordinary desktop projects do not look like that.
Configuration
Two locations, deep-merged, project over global:
- Global:
<agent-dir>/extensions/zcode-memory.json(default~/.pi/agent/extensions/zcode-memory.json) - Project:
<cwd>/.pi/zcode-memory.json
{
"enabled": true,
"dir": "D:/zcode/cli/memories/projects/chat-ace14747b286680c/memory",
"injectIndex": true
}
| Option | Default | Meaning |
|---|---|---|
enabled |
true |
when off, the tools stay registered but the index is not injected (the tools themselves still work) |
dir |
none | explicit memory directory. The fallback for any case the algorithm cannot handle |
injectIndex |
true |
whether to inject the index |
A config that fails to parse (corrupt file, invalid JSON) does not crash the extension; it falls back to the defaults.
Tools
| Tool | Behavior |
|---|---|
memory_list |
walks the memory directory recursively (skipping MEMORY.md, not following directory symlinks) and returns file name / name / description / type / mtime. Broken frontmatter degrades to an entry with no description instead of throwing |
memory_read |
locates a memory by name or file name and returns the frontmatter summary plus the body. The body is truncated at ZCode's recall limit (first 200 lines / 4096 bytes) with a pointer to where the full text can be read |
memory_write |
takes name / description / type / body / mode: create|upsert. Writes the fact file atomically and maintains its line in the MEMORY.md index |
A written frontmatter carries four keys and no more:
---
name: <kebab-case-slug>
description: <one-line description>
metadata:
node_type: memory
type: user | feedback | project | reference
---
originSessionId is not written: that is a lazy field the reading side fills in itself when it is missing, and borrowing another tool's session id would be a fabrication. No custom keys are added to metadata either.
Design tradeoffs
Why is the index injected once instead of on every turn?
Only MEMORY.md enters the context; bodies are read on demand. The injection is a display: false custom message returned from before_agent_start (not stuffed into the system prompt). A marker is then recorded with pi.appendEntry("zcode-memory/injected", { indexHash }), and session_start (new / resume / fork) rebuilds the marker from the current session projection. Re-injection happens in exactly three cases: the index content hash changed, the injected message was summarized away (no longer in the kept tail), or /memory reload was run. Runs with messageType === "custom" (started by pi.sendMessage(..., { triggerTurn: true })) are skipped outright.
Why does memory_write use withFileMutationQueue plus a revision retry instead of a cross-process lock?
ZCode's foreground writes have no cross-process lock either (only its background organizing process does); it detects concurrency through a revision (mtime:<truncated ms>:size:<bytes>). This package takes the same self-healing path: writes are serialized in-process through pi's queue, and before writing MEMORY.md the revision is compared; on a mismatch it re-reads and retries once, and on a second failure it returns an error for the model to retry. It never overwrites silently. The cost is that ZCode's next write hits a revision conflict, which it resolves by re-reading the index.
Why must memory files go through these three tools?
memory_write is the only path that keeps a fact file and its MEMORY.md index line in sync. A fact created with the built-in write, or one whose description was changed with the built-in edit, has no correct index line, and the injected index is what a later session actually sees, so the memory goes unnoticed. ZCode has the model maintain the index by hand because it has no memory tool, and hand-editing is safe there because of guarantees in its own filesystem adapter (atomic writes, a stale-write check) that pi's built-in edit does not have. The boundary is prompt-level only: the read-state bridge below stays, as a fallback for a model that ignores the descriptions and for hand edits the user asks for.
Why does memory_read emit a read-state event?
A memory read through memory_read and then modified with the built-in edit should not be rejected by pi-read-guard for not having been read. The two packages talk over pi.events (channel read-guard/file-read): the publisher only emits, the subscriber only ons, and either side's absence degrades silently. There is no import dependency between them. Compared with a "configure your read-state provider" approach, this drops a layer of configuration shape and parsing rules for the same benefit. A truncated read publishes kind: "truncated" with no content hash: a truncated read cannot become a write watermark, the same rule the built-in read follows through details.truncation.
Why cap what memory_read returns?
What memory_read returns is what is actually available in the session: a recalled memory carries at most its first 200 lines / 4096 bytes, and the full text is always reachable with the built-in read. That way the model does not mistake the tool output for the whole file.
Why does the directory algorithm skip ~ expansion and realpath?
The hash input is the path string resolve(cwd) and nothing else. realpath would fold symlinks and short paths into a different string and compute the wrong directory. Folding case on win32 serves the same purpose: different spellings of one directory land on the same hash.
Boundaries and known limits
- Eventual consistency with ZCode, not live sync. ZCode reads
MEMORY.mdonce for the entire run; it refreshes only at first context initialization, cold resume and/clear. Compaction is not a refresh point, so a stale index survives it. After pi writes a memory, a ZCode session that is already running cannot see it until a new session or a cold resume; fact-file bodies are not cached and are fresher than the index. - ZCode's
Writeis a full overwrite. When it writesMEMORY.mdfrom a stale snapshot, outside changes are wiped wholesale (it does not merge). The only mitigations are reading before every write, and not batch-writing while ZCode is actively writing memory. - The index can be corrupted by the model.
memory_listfalls back to the filesystem;/memoryreports orphan index lines and files missing from the index, but does not repair the index automatically. - The tool boundary is prompt-level. The descriptions steer the model to
memory_read/memory_write; mechanically, pi-read-guard still lets a built-ineditthrough after amemory_read, and nothing blocks a hand edit. - Frontmatter is only parsed within the first 30 lines. A file whose closing
---sits lower is treated as having no description by whichever side reads it, so a written frontmatter has to stay short. - Writes refuse to follow symlinks. A symlink target is an error, and the real path has to be passed instead.
- A truncated
memory_readdoes not become a write watermark: editing the same file with the built-ineditafterwards still triggers a re-read demand from pi-read-guard. That is the expected behavior of the read-before-write rule. - No delete tool, no automatic extraction. In v1, forgetting something means the built-in
bash rm; distilling memory is an explicit tool call, with no background model run. - The index is a
custom_message, outside pi-context-guard's prunable whitelist, so it is never pruned.
Verify before installing
npm install
npm run check # typecheck + tests
The checks run offline: they cover the directory algorithm, frontmatter parsing, index injection, and the hand-off with pi-read-guard.
License
MIT